Skip to content

Add Google SecOps (Chronicle) support: events.import output and UDM parser - #790

Draft
seanthegeek wants to merge 23 commits into
masterfrom
google-secops-parser
Draft

Add Google SecOps (Chronicle) support: events.import output and UDM parser#790
seanthegeek wants to merge 23 commits into
masterfrom
google-secops-parser

Conversation

@seanthegeek

@seanthegeek seanthegeek commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds two delivery paths for getting parsedmarc data into Google Security Operations (Chronicle), sharing one UDM mapping:

  1. [gsecops] output (new) — sends reports directly to the GA v1 Chronicle API events.import method as pre-normalized UDM events. No collector, no custom parser to install; standard Google Cloud IAM auth (service-account key file or Application Default Credentials, Chronicle API Editor role in the project linked to the SecOps instance). Batches per the documented best practices (1,000 events / 60 s timeout) and bisects rejected batches, since events.import is all-or-nothing, so one invalid event can't discard a run. Because additional is a protobuf Struct, counts/ASNs stay numbers and alignment flags stay booleans — range-queryable with no coercion. Config: project_id, instance_id, region, optional credentials_file, wired through INI schema, env vars (PARSEDMARC_GSECOPS_*), docs, and SIGHUP reload. Fully unit-tested against real sample reports with payload-level assertions on a mocked AuthorizedSession.

  2. CBN parser (google_secops_parser/) — the collector-based alternative for deployments that want raw-log retention or already run a Bindplane pipeline: parsedmarc's [syslog] output → Bindplane → custom log type → this parser. Both paths emit the same UDM event shape and the same additional keys, so searches and dashboards port between them.

Google's ingestion docs recommend sending UDM events when possible; the README cross-links both paths and explains the trade-off (API path = no tenant-side install, CI-tested mapping; parser path = raw logs retained in SecOps).

One paired library fix ships with it: parsed_smtp_tls_reports_to_csv_rows now includes policy_domain / policy_type on SMTP TLS failure-detail rows (RFC 8460 §4.3 nests each failure detail inside a policy). Without this, failure-detail rows carried none of the shape-identifying fields — the CBN parser could not detect them at all, and the policy_domain / policy_type CSV columns were always empty on those rows. The same commit fixes a cross-policy leak where policy_strings / mx_host_patterns from an earlier policy bled into a later policy that didn't define them. Both regression tests fail on the previous serializer.

What it does

parsedmarc report Detected by UDM metadata.event_type
DMARC aggregate xml_schema EMAIL_TRANSACTION
DMARC failure feedback_type or arrival_date_utc EMAIL_TRANSACTION
SMTP TLS (RFC 8460) policy_type or result_type GENERIC_EVENT

The or fallbacks matter: text-format failure reports have no Feedback-Type field (parsedmarc emits no feedback_type key for them, but always computes arrival_date_utc), and SMTP TLS failure-detail rows from parsedmarc versions predating this PR lack policy_type (every RFC 8460 failure detail carries result_type).

What actually identifies an aggregate report is its <feedback> XML root element — parse_aggregate_report_xml anchors on it. The raw XML never reaches SecOps, so the CBN parser tests xml_schema instead: the field parsedmarc synthesizes on every row that came from a <feedback> document (guaranteed non-empty as of this PR — "draft" whenever <version> is missing, empty, or whitespace). Testing xml_schema is therefore one-to-one equivalent to testing for the <feedback> root.

  • Sets the required metadata.event_timestamp from the report's own window (via date{}) for all three types — aggregate/TLS use the report window, failure uses arrival time — so events land on the right day rather than at ingest time.
  • Maps disposition (aggregate) and delivery_result (failure) to security_result.action using valid enum values, and sets security_result.category = AUTH_VIOLATION when DMARC alignment fails.
  • Derives the required noun for SMTP TLS from policy_domaintarget.hostname — present on every row as of the paired serializer fix — with a receiving_mx_hostname fallback for rows from older parsedmarc versions.
  • Uses real network.email field names. to and subject are repeated UDM fields per the field list, so they are merged (like principal.ip), not replaced.
  • Strips syslog framing (the <PRI> prefix) before JSON parsing.
  • Handles Chronicle's type-preserving json{} filter: booleans (*_aligned, normalized_timespan) are converted to strings for comparison (stored as string_value), numbers (count, *_session_count, source_asn) are stored as number_value so they stay range-queryable, and every if-tested field is initialized and guarded with != "" so an absent field neither triggers _failed_parsing_ nor misfires detection. Note testing is not a boolean — parsedmarc emits the RFC 9990 t= flag as the string "y"/"n" — so it is guarded with != "".

Every UDM field and enum value used is grounded in the official docs, cited inline in parsedmarc.conf (UDM overview, parsing overview, parser-syntax reference, UDM field list, SecurityResult reference), and cross-checked against Google's content-hub CBN parsers.

Validation status

Built strictly to the documentation, but not yet run against a live SecOps tenant. The README ships six single-line sample events, all byte-identical to real [syslog] serializer output generated from this repository's samples/ files (each event names its source sample).

How to validate (in order):

  1. The failure sample first. It is the only shape that emits JSON null values, and Google documents only absent-field behavior (the pre-json "" initialization), not whether json{} overwrites an initialized field with a null. This is the least-grounded behavior the parser depends on.

  2. The two aggregate samples — confirm the dmarc_aligned=false one yields security_result.category = AUTH_VIOLATION, that count / source_asn land as number_value, and the boolean→string handling holds.

  3. The three SMTP TLS rows — success (noun + counts, no security_result), failure with MTA IPs, and failure without MTA IPs (noun from policy_domain only).

  4. Count-weighted dashboards. The per-record message count is what the existing OpenSearch/Splunk/Grafana DMARC dashboards sum, stored here as number_value in additional.fields. Confirm a UDM-search aggregation sums it correctly — e.g. "messages by disposition":

    metadata.product_name = "parsedmarc"
    metadata.event_type = "EMAIL_TRANSACTION"
    $disposition = additional.fields["disposition"].value.string_value
    $messages    = additional.fields["count"].value.number_value
    match:   $disposition
    outcome: $total = sum($messages)
    order:   $total desc
    

    $total must equal the true sum of the count values — verify it is not multiplied by the number of additional.fields entries per event. YARA-L's sum() has a documented over-count on repeated fields, but it should not apply to this key-isolated map-sum (count lives on a single entry); this test confirms it.

Notes

  • A CBN parser can't be exercised in this repo's CI; validation happens in a SecOps tenant. The paired serializer fix is covered by regression tests.
  • Caveats (boolean coercion, JSON nulls in failure rows, aggregate count representing N messages, GENERIC_EVENT only surfacing in raw/UDM search) are documented in the README.
  • Needs review — numeric number_value idiom. The numeric additional.fields land a typed number_value via the content-hub string-hop: build string_value from %{field}convert to uintegerrename to number_value. This matches the only number_value example in chronicle/content-hub (Azure Cosmos DB's number_of_rows_returned), which interpolates a raw numeric field the same way. Each interpolation now carries on_error so a tenant where %{} rejects non-string fields degrades to a missing field instead of _failed_parsing_. During tenant validation, confirm the number_value lands correctly.

🤖 Generated with Claude Code

@seanthegeek
seanthegeek marked this pull request as draft June 4, 2026 13:26
@codecov

codecov Bot commented Jun 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.25%. Comparing base (e48521b) to head (579626d).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
parsedmarc/cli.py 65.00% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #790      +/-   ##
==========================================
+ Coverage   93.15%   93.25%   +0.09%     
==========================================
  Files          19       20       +1     
  Lines        5216     5394     +178     
==========================================
+ Hits         4859     5030     +171     
- Misses        357      364       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

seanthegeek and others added 11 commits July 11, 2026 13:58
A SecOps-side custom parser (CBN) that maps parsedmarc's [syslog] JSON
events to the Unified Data Model. No library changes: parsedmarc already
emits structured JSON, so the DMARC->UDM mapping lives in the parser and a
downstream UDM schema change is a parser edit, not a parsedmarc release.

Covers all three report types:
- aggregate -> EMAIL_TRANSACTION
- failure   -> EMAIL_TRANSACTION
- smtp_tls  -> GENERIC_EVENT (noun from policy_domain, present on every row)

Built strictly against the official UDM and parser-syntax docs (cited
inline). Sets metadata.event_timestamp from the report window via date{},
maps disposition / auth-failure to security_result with valid action and
category enums (AUTH_VIOLATION on DMARC fail), uses real network.email
field names, and strips syslog framing before JSON parsing. Ships real
sample events generated from the project's sample reports for validation.

Not yet validated against a live SecOps tenant; caveats are documented in
the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
adkim is the published policy's DKIM alignment mode (defaulted to "r" by
parsedmarc), an obscure thing to key detection on. Switch the aggregate
detector to "domain" -- the reported From-domain, a required element present
and non-empty in every aggregate record (2388/2388 sample rows) and unique to
aggregate (failure uses reported_domain, SMTP TLS uses policy_domain).
header_from is unsuitable: it can be empty when a record carries no
identifiers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two CBN behaviors, confirmed against Google's own "How to parse JSON data"
guide (statedump shows JSON true/199 retaining boolean/integer type) and the
published Corelight production parser:

1. The json{} filter preserves the original JSON type, so parsedmarc's boolean
   *_aligned / testing / normalized_timespan and numeric count / *_session_count
   / source_asn would never match string comparisons. Add a mutate{convert} step
   turning them into strings before any == "true"/"false" test or %{...} use.

2. CBN raises _failed_parsing_ when an `if [field]` references a field absent
   from the log, and most detection/mapping fields are absent in 2 of the 3
   report shapes (or null within one). Initialize every conditionally-checked
   field to "" before the json{} filter.

Without these, DMARC-fail records would not be categorized AUTH_VIOLATION and
aggregate/TLS reports could fail parsing outright. README caveat and PR
validation steps updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
xml_schema is aggregate-only (failure/SMTP TLS rows don't carry it) and a
distinctive, non-generic field name, addressing the concern that "domain"
could be confused with other logs. parsedmarc defaults xml_schema to "draft"
when the report omits <version> (parsedmarc/__init__.py:832), so it survives a
missing version element -- unlike a field with no default.

It is also a native JSON string straight out of the json{} filter, so unlike
dmarc_aligned it needs no convert step to be testable, keeping detection
independent of the type-conversion in step 1b. xml_schema is added to the
pre-json init block (required for any if-tested field); domain stays
initialized since it is still mapped to target.hostname.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the remaining official Google docs the parser is built on (parser tips
& troubleshooting, manage parsers, UDM search, Bindplane install) and a
clearly-separated "Additional sources and tooling" section for the community
resources that drove the JSON type-handling and field-init fixes
(thatsiemguy's Parsing 101, the Corelight production parser, chronicle/cbn-tool).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a short, skippable callout explaining what a parser / configuration-based
normalizer (CBN) is and how it fits the SecOps ingest flow (log type → parser →
UDM event), so the README serves newcomers without slowing experienced users.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add github.com/chronicle/content-hub (Google's official third-party SecOps
parser repo) to the README references and re-anchor the in-code citations to
it. Its current CBN parsers (e.g. CLOUDFLARE_PAGESHIELD, Copyright 2025 Google
SecOps) confirm both fixes this parser makes: initialize every field before the
json{} filter, and convert JSON booleans/numbers to strings before comparison.
Replaces the dated "How to parse JSON data" citation with the authoritative,
actively-maintained source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two corrections confirmed against Google's official content-hub parsers
(content/parsers/third_party/community/*/cbn):

1. Numbers as numbers. count, source_asn, successful_session_count and
   failed_session_count were being stored in additional.fields as string_value.
   Store them as number_value instead (build string -> convert to uinteger ->
   rename to number_value, the content-hub idiom), so SecOps can range-query and
   sort them, per parsedmarc's "store numbers as numbers" rule. Booleans stay
   string_value (content-hub never uses bool_value) and are still converted in
   step 1b for the == "true"/"false" comparisons.

2. Conditional guards. Replaced bare `if [field] {` with `if [field] != "" {`
   (76 guards + the detection cascade + policy_override). After 1a initializes
   every tested field to "", a bare `if` is true for an empty field (Logstash/CBN
   semantics), which would misfire detection and emit empty labels. content-hub
   uses `!= ""` ~111x vs 2 bare (both flags); parser flags (no_json_payload,
   not_json, *_nan) correctly stay bare.

Verified: braces balance, no stray bare field-guards, all if-tested fields
initialized, all four numeric fields emit number_value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Google's content-hub parsers tag dropped logs (drop { tag => "TAG_..." })
so they surface correctly in the unparsed-log views, rather than bare drop{}.
Use TAG_MALFORMED_ENCODING for the two JSON-extraction/parse failures (matches
content-hub) and TAG_UNSUPPORTED for valid JSON that matches no parsedmarc
report shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RFC 8460 §4.3 nests each failure detail inside a policy, but
parsed_smtp_tls_reports_to_csv_rows only attached policy_domain and
policy_type to the per-policy summary row. Failure-detail rows therefore
had empty policy_domain/policy_type CSV columns, and flat-JSON consumers
(syslog, GELF) could not attribute a failure detail to its policy — the
Google SecOps parser in this PR could not even detect those rows as SMTP
TLS reports, since they carried none of the shape-identifying fields.

Also rebuild the row template per policy: policy_strings and
mx_host_patterns from an earlier policy leaked into a later policy that
did not define them, because the template dict was created once per
report and mutated inside the policies loop.

Both regression tests fail on the previous serializer with
KeyError: 'policy_domain'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings verified against the parsedmarc serializers, the UDM
field list, Google's parser troubleshooting doc, and the content-hub
parsers:

- Detect failure reports by feedback_type OR arrival_date_utc: the
  text-format failure path synthesizes a feedback report with no
  Feedback-Type field, so those rows had no feedback_type key and were
  dropped as TAG_UNSUPPORTED.
- Detect SMTP TLS rows by policy_type OR result_type, and fall back to
  receiving_mx_hostname for target.hostname, so failure-detail rows from
  parsedmarc versions without the paired serializer fix still map.
- Merge (not replace) network.email.to and network.email.subject: both
  are repeated fields per the UDM field list, so a scalar replace is a
  type mismatch. The unguarded subject replace could have failed parsing
  for every failure event with a subject.
- Guard testing with != "": parsedmarc emits the RFC 9990 t= flag as the
  string "y"/"n", not a boolean, so the == "true"/"false" test could
  never fire and dmarc_testing was never emitted. Drop testing from the
  boolean convert list accordingly.
- Add on_error to the four numeric %{} interpolations (count,
  source_asn, *_session_count) so a tenant where interpolating a
  non-string field errors degrades to a missing additional.fields entry
  instead of _failed_parsing_ (per the official parser tips doc).
- Drop the concatenated security_result.description strings: the tips
  doc says not to pack multiple values into one UDM field, and every
  value already lands individually in additional.fields.
- Use TAG_MALFORMED_MESSAGE for non-JSON input, matching the tips doc's
  canonical not-JSON example (TAG_MALFORMED_ENCODING is for character
  encoding problems).
- Map org_extra_contact_info/errors (aggregate) and the source
  enrichment fields (failure) into additional.fields; the failure branch
  previously dropped enrichment the aggregate branch kept.
- Replace the SMTP TLS success sample, which was not generated from this
  repository's samples, with rfc8460.json output; regenerate all sample
  events with the fixed serializer and name each event's source sample
  file. All six samples are now byte-identical to real syslog output.
- Reorder the validation plan to start with the failure sample: it is
  the only shape that emits JSON nulls, whose json{} interaction with
  the pre-initialized "" fields is undocumented.
- Update Google doc links to docs.cloud.google.com (the old URLs 301)
  and link the parser from the syslog section of the usage docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@seanthegeek
seanthegeek force-pushed the google-secops-parser branch from e6e9369 to 95e881b Compare July 11, 2026 18:10
@seanthegeek

Copy link
Copy Markdown
Contributor Author

Review follow-up: verified the draft's claims empirically against the parsedmarc serializers, the UDM field list, Google's parser troubleshooting doc, and the content-hub CBN parsers, then pushed two commits (branch also rebased onto current master):

0e3f5f3 — library fix. SMTP TLS failure-detail rows carried none of feedback_type / policy_type / xml_schema, so the parser dropped every one of them as TAG_UNSUPPORTED — the PR's original "policy_domain is present on every row" claim was false, and its own failure-detail sample disproved it. parsed_smtp_tls_reports_to_csv_rows now attaches policy_domain / policy_type to failure rows (RFC 8460 §4.3 nests failure details inside a policy), and rebuilds the row template per policy so policy_strings / mx_host_patterns no longer leak across policies. Both regression tests fail on the previous serializer with KeyError: 'policy_domain'.

95e881b — parser/README fixes.

  • Detection fallbacks: arrival_date_utc for failure reports (text-format failure reports have no Feedback-Type, and parse_failure_report never defaults it) and result_type for SMTP TLS rows from pre-fix parsedmarc versions, with a receiving_mx_hostname noun fallback.
  • network.email.to and network.email.subject are repeated UDM fields — switched from scalar replace (a type mismatch; the unguarded subject one could have failed parsing on every failure event) to merge, matching the principal.ip idiom.
  • testing is the RFC 9990 t= flag emitted as "y"/"n", not a boolean — the == "true"/"false" guard could never fire, so dmarc_testing was never emitted. Now guarded with != "".
  • Hardening per Google's parser tips doc: on_error on the four numeric %{} interpolations, TAG_MALFORMED_MESSAGE for non-JSON input, dropped the concatenated security_result.description strings (multiple values in one UDM field is a documented anti-pattern; each value already lands in additional.fields).
  • The SMTP TLS success sample was not generated from this repo's samples (it looked like real third-party report data: dengage.com / inboxsys.net / Synametrics) — replaced with rfc8460.json output. All six README samples are now byte-identical to real [syslog] serializer output, verified by exact string comparison.
  • Validation plan reordered to start with the failure sample (the only shape emitting JSON nulls — the one json{} behavior the docs don't cover), doc links updated to docs.cloud.google.com, parser linked from the syslog section of the usage docs, CHANGELOG entry added under ## Unreleased.

pytest (685 passed), ruff check / ruff format --check, and pyright (0 errors, 0 warnings) are all clean. The PR description has been updated to match reality. Tenant validation is still pending — the README's validation order is the checklist.

Sends parsed reports to a Google SecOps instance as pre-normalized UDM
events through the GA v1 Chronicle API:

    POST https://chronicle.{region}.rep.googleapis.com/v1/{parent}/events:import

Pre-normalized events bypass SecOps's server-side (CBN) parsing layer,
so no parser needs to be installed in the tenant; the CBN parser in
google_secops_parser/ remains the collector-based alternative for
deployments that want raw-log retention, and both paths emit the same
UDM shape and additional-field keys so searches port between them.
Google's ingestion docs recommend the UDM-events path when possible.

Implementation notes, all grounded in the v1 reference docs:

- Auth is standard Google Cloud IAM (Chronicle API Editor role /
  chronicle.events.import permission): a service account key file when
  [gsecops] credentials_file is set, Application Default Credentials
  otherwise. google-auth was already a transitive requirement via
  mailsuite[gmail]; it is now declared directly.
- Batches follow the documented best practices (1,000 events per
  request, 60 s timeout). events.import is all-or-nothing — one invalid
  event rejects the whole request — so a rejected batch is bisected to
  isolate invalid events; valid events are still delivered and the drop
  count is raised as an output error at the end.
- to and subject are repeated fields in the UDM Email message;
  SecurityResult action and category are repeated enums.
- additional is a protobuf Struct, so counts and ASNs stay numbers and
  alignment flags stay booleans — range-queryable without the CBN
  string-hop.

New [gsecops] config section (project_id, instance_id, region,
credentials_file) is wired through the INI schema, _parse_config,
Namespace defaults, PARSEDMARC_GSECOPS_* env vars, the usage docs, and
per-batch client construction (SIGHUP-reload safe by construction).

Tests build events from real sample reports and assert on the payloads
sent through a mocked AuthorizedSession, including batching and the
400-bisect path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@seanthegeek seanthegeek changed the title Add Google SecOps (Chronicle) UDM parser Add Google SecOps (Chronicle) support: events.import output and UDM parser Jul 11, 2026
@seanthegeek

Copy link
Copy Markdown
Contributor Author

Added the direct API output discussed in review (commit fba083f): a new [gsecops] config section that sends reports straight to the GA v1 Chronicle API events.import method as pre-normalized UDM events — no collector or tenant-side parser required.

Key points, all grounded in the v1 reference docs:

  • Endpoint/auth: POST https://chronicle.{region}.rep.googleapis.com/v1/projects/{project}/locations/{region}/instances/{instance}/events:import, standard Google Cloud IAM (Chronicle API Editor role). Uses a service-account key file when credentials_file is set, Application Default Credentials otherwise. google-auth was already installed transitively via mailsuite[gmail]; it's now a declared dependency.
  • Batching/robustness: 1,000 events per request and 60 s timeout per the documented best practices. events.import is all-or-nothing, so a rejected batch is bisected to isolate invalid events — valid events still deliver, and the drop count surfaces as an output error (compatible with fail_on_output_error).
  • Same mapping as the CBN parser: both paths emit identical UDM shapes and additional keys, so searches/dashboards port between them. Because additional is a protobuf Struct on this path, counts and ASNs stay numbers and alignment flags stay booleans — none of the CBN string-coercion caveats apply.
  • Tested: 21 new tests build events from real sample reports and assert on the payloads sent through a mocked AuthorizedSession (event shapes, repeated-field handling, batching, the 400-bisect path, config parsing incl. PARSEDMARC_GSECOPS_* env vars). A CLI smoke run confirmed the wiring end-to-end up to the credential boundary. Full suite: 706 passed; ruff and pyright clean.

The parser README now cross-links the two paths and explains the trade-off (API = no tenant install + CI-tested mapping; syslog+parser = raw-log retention). Unlike the CBN parser, the API output needs no parser-validation-tool pass — but its first live run against a real tenant is still the remaining validation step for both.

The alignment flags and normalized_timespan were stored as
string_value "true"/"false" in additional.fields, which diverged from
the [gsecops] API output (a protobuf Struct, where they are real
booleans) — a UDM search written for one delivery path would not match
the other for those four fields.

Google's parser extension examples document the boolean idiom this
uses: build a string, convert to boolean, rename into the boolean
field. It is the boolean analogue of the number_value chain already
used for count/source_asn (proven in the content-hub Azure Cosmos DB
parser), and the UDM search docs confirm additional fields are
matchable via value.bool_value. The string conversion in step 1b
remains, because CBN conditionals compare against the preserved JSON
type and the == "true"/"false" guards need strings.

With this, both delivery paths emit identical value types for every
additional.fields key. The prior justification ("content-hub never
uses bool_value") described the absence of precedent, not a
prohibition; the documented boolean example is the stronger authority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@seanthegeek

Copy link
Copy Markdown
Contributor Author

Follow-up on a review question about why the CBN parser stored booleans as string_value when UDM supports booleans: resolved from Google's docs, and the questioner was right — commit pushed switching the four boolean fields (dmarc_aligned, spf_aligned, dkim_aligned, normalized_timespan) to typed bool_value.

Documentation trail:

  • Parser extension examples document the exact idiom: build a string → convert => "boolean"rename into a boolean UDM field (their example targets network.tls.established).
  • The UDM search docs explicitly state additional fields can be matched with value.bool_value (and value.number_value).
  • The same string→convert→rename chain into a typed value.* slot is production-proven for number_value in the content-hub Azure Cosmos DB parser.

The earlier "content-hub parsers never use bool_value" observation was accurate but was absence of precedent, not prohibition — the documented boolean example is the stronger authority. The string conversion in step 1b stays, because CBN conditionals compare against the preserved JSON type, so the == "true"/"false" guards still need strings; only the stored value type changed.

Net effect: the CBN parser and the [gsecops] API output now emit identical value types for every additional.fields key (booleans as bool_value, counts/ASNs as number_value), so UDM searches and dashboards port between the two delivery paths unchanged. The validation checklist now includes confirming the booleans land as bool_value in the tenant.

seanthegeek and others added 3 commits July 11, 2026 15:38
The step-1b comment still said booleans are "stored as string_value
(matching Google's content-hub parsers, which never use bool_value)" —
stale since the switch to typed bool_value storage. 1b now says what it
actually does: convert booleans to strings for the `if` guards only,
because CBN conditionals compare against the preserved JSON type;
storage is typed bool_value per the alignment blocks. Also tightened
the README's Corelight citation, which overstated the string idiom as
something the whole parser relies on rather than just the conditionals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comment described the behavior but not the reason: CBN's documented
conditional syntax only compares a token against a quoted string
literal -- there is no boolean literal and no truthiness test on event
fields -- so a type-preserved JSON boolean can never match any
conditional without first converting it to a string. The conversion is
in place because CBN mutate has no copy function. Cite the syntax
reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… <version>

The <feedback> XML root element (anchored at the top of
parse_aggregate_report_xml) is what identifies an aggregate report;
<version> is optional metadata per RFC 7489 Appendix C. The "draft"
fallback only covered a fully absent <version>: an empty <version/>
(which xmltodict parses as None) or a whitespace-only value produced
xml_schema=None, which the flat-row serializer coerced to "" — and any
consumer detecting aggregate rows by a non-empty xml_schema, such as
the Google SecOps CBN parser's report-type cascade, silently dropped
the row. The fallback now applies unless <version> carries non-empty
text (attribute-wrapped values are unwrapped via _text like other
elements).

The regression test fails on the previous code with
xml_schema=None != "draft".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@seanthegeek

Copy link
Copy Markdown
Contributor Author

Review follow-up on aggregate-report detection: the <feedback> XML root element is the true identifier of an aggregate report — parse_aggregate_report_xml already anchors on it — while <version> is optional metadata (RFC 7489 Appendix C). But the xml_schema value derived from <version> didn't honor that: the "draft" fallback only covered a fully absent element, so an empty <version/> (parsed as None by xmltodict) or whitespace-only value produced an empty xml_schema in the flat rows — and the CBN parser's report-type cascade would silently drop such aggregate rows as TAG_UNSUPPORTED.

Fixed in the library so the guarantee holds at the source: xml_schema now falls back to "draft" unless <version> carries non-empty text (attribute-wrapped values unwrapped via _text). Regression tests fail on the previous code; the CBN parser comment and README now state the invariant (xml_schema is guaranteed non-empty on every aggregate row, so the detection cascade needs no fallback for aggregates). 708 tests passing, ruff/pyright clean.

seanthegeek and others added 5 commits July 11, 2026 15:57
The README presented xml_schema as the aggregate detector. What
actually identifies an aggregate report is its <feedback> XML root
element, which parse_aggregate_report_xml anchors on; the raw XML never
reaches SecOps, so the CBN parser tests xml_schema — the field
parsedmarc synthesizes on every row that came from a <feedback>
document — as its serialized marker. Say so in the detection table and
the explanation paragraph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In DMARC vocabulary "row" reads as the <row> element inside an
aggregate report record, which carries no schema information — the
changelog entry was about the flattened JSON/CSV output lines, where
xml_schema is a synthesized per-record field. Reword the entry and the
two related comments to say which kind of row is meant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ensure that the JSON or CSV output from a parsed DMARC aggregate report
always has an xml_schema value (falling back to "draft" when <version>
is missing, empty, or attribute-wrapped), so that the reports can be
detected by Google SecOps and other output parsers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring parsedmarc/gsecops.py from 96% to 100% coverage:

- principal hostname from an enriched source reverse DNS name
- the literal "none" placeholder in policy_override_reasons /
  policy_override_comments is dropped from additional
  (samples/aggregate/empty_reason.xml)
- SMTP TLS failure details map sending-mta-ip to principal.ip and
  receiving-ip to target.ip (the RFC 8460 Appendix B sample)
- target hostname falls back to receiving-mx-hostname when a report
  carries an empty policy-domain

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@seanthegeek
seanthegeek requested a review from Copilot July 26, 2026 16:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class Google SecOps (Chronicle) integrations to parsedmarc, enabling both direct UDM event ingestion via the Chronicle events.import API and an alternative collector-based path via a SecOps CBN parser, while fixing SMTP TLS CSV row identity fields and hardening aggregate xml_schema defaults for downstream detection.

Changes:

  • Add a new [gsecops] output path that converts parsedmarc results into UDM events and uploads them to Chronicle events.import, including batching and 400-response bisection.
  • Add a SecOps-side CBN parser (google_secops_parser/) for mapping parsedmarc [syslog] JSON into the same UDM shape as the API path.
  • Fix SMTP TLS CSV row serialization (include policy_domain/policy_type on failure-detail rows; prevent cross-policy field leakage) and ensure aggregate xml_schema never becomes empty/None.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_init.py Adds regression tests for aggregate xml_schema fallback behavior and SMTP TLS CSV-row policy identity/leak fixes.
tests/test_gsecops.py New unit tests for UDM mapping and Chronicle client batching/bisection behavior.
tests/test_cli.py Adds config parsing tests for new [gsecops] section and env var resolution.
pyproject.toml Adds google-auth dependency required for Chronicle API auth/session support.
parsedmarc/gsecops.py Implements UDM event mapping + Chronicle events.import client with batching and 400-bisect retry logic.
parsedmarc/cli.py Wires [gsecops] config parsing, path expansion, and output publishing into the CLI execution path.
parsedmarc/init.py Fixes SMTP TLS CSV row policy identity + prevents per-policy field leakage; ensures aggregate xml_schema defaults to non-empty draft when <version> is empty/whitespace.
google_secops_parser/README.md New documentation for the collector-based SecOps CBN parser path, installation, mappings, and validation samples.
google_secops_parser/parsedmarc.conf New CBN parser that maps parsedmarc syslog JSON to UDM for aggregate/failure/SMTP TLS shapes.
docs/source/usage.md Documents the new [gsecops] output and links to the SecOps parser alternative.
CHANGELOG.md Records the new SecOps features and the paired SMTP TLS + aggregate schema bug fixes under “Unreleased”.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread parsedmarc/gsecops.py
Comment on lines +390 to +395
if response.status_code == 400:
logger.error(
"Google SecOps rejected event {0}: {1}".format(events[0], response.text)
)
self._dropped += 1
return

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 579626d (pushed together with a merge of current master that resolves the branch conflicts): the 400 handler now logs only productEventType and productLogId from the rejected event's metadata, keeping response.text for debugging. The bisect regression test now also pins the log content — identifier present, event body absent.

This comment was written by Claude on behalf of the maintainer.

seanthegeek and others added 2 commits August 18, 2026 17:56
Conflict resolutions:
- CHANGELOG.md: keep this branch's Unreleased section above the
  10.4.1-10.4.3 release sections added on master.
- pyproject.toml: keep both dependency additions (google-auth from this
  branch, httpx from master).

Follow-up to the merge: convert gsecops.py's str.format() calls to
f-strings, required by the ruff 0.16.0 UP030/UP032 enforcement adopted
on master after this branch's base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the Copilot review finding on PR #790: the HTTP 400 handler
logged the entire rejected UDM event, and aggregate/failure events carry
email addresses and subjects that do not belong in application logs. Log
the event's identifying metadata (productEventType, productLogId) plus
the API's response text instead. The bisect regression test now pins the
log content: identifier present, event body absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants