Files
scadaproj/code-reviews/Audit/findings.md
T
Joseph Doherty ae0ccc9a3a Mark all baseline code-review findings resolved
All 35 findings fixed in 544a6dd and marked Status: Resolved with resolution
notes. README regenerated: 0 pending / 35 total across 6 libraries.
2026-06-01 11:22:37 -04:00

14 KiB

Code Review — Audit

Field Value
Library ZB.MOM.WW.Audit/
Packages ZB.MOM.WW.Audit
Component spec components/audit/spec/SPEC.md
Shared contract components/audit/shared-contract/ZB.MOM.WW.Audit.md
Status Reviewed
Last reviewed 2026-06-01
Reviewer Claude (automated baseline)
Commit reviewed 5f75cd4
Open findings 0

Summary

ZB.MOM.WW.Audit is a small, clean, well-scoped library: one canonical AuditEvent sealed record, a 3-value AuditOutcome enum, two narrow seams (IAuditWriter, IAuditRedactor), five shipped helpers, and a single AddZbAudit registration. The record matches EVENT-MODEL.md field-for-field (names, types, required/optional split, and the OccurredAtUtc UTC-normalization contract — including value-equality on the normalized instant). The dependency footprint is exactly as specified: the only non-BCL reference is Microsoft.Extensions.DependencyInjection.Abstractions (verified against the built deps.json), with no Akka/EF/SQLite/Serilog/Telemetry leakage. Guard clauses are present on every public entry point, RedactingAuditWriter guarantees redaction runs before the inner writer (no unredacted-PII leak), and CompositeAuditWriter isolates per-writer failures so a throwing writer does not lose downstream events. 19 tests cover the happy paths and the most important contract guards. No Critical or High findings. The five findings are all Medium/Low and concern: a cancellation-propagation tension with the "never throw to the caller" writer contract (Audit-001), a partial over-redaction path in TruncatingAuditRedactor (Audit-002), mutable options on a singleton redactor (Audit-003), un-emitted XML docs (Audit-004, a family-wide baseline pattern), and a couple of missing edge-case tests (Audit-005).

Checklist coverage

# Category Examined Notes
1 Correctness & logic bugs Truncate boundary logic correct for sane configs; marker-longer-than-max clips safely. Partial over-redaction edge under negative max → Audit-002.
2 Public API surface & compatibility Minimal, intentional surface; sealed records/classes; nullable annotations correct; no internal-type leakage. Options type is a mutable class, not the record the contract describes → Audit-003.
3 Concurrency & thread safety AuditEvent immutable (readonly backing field); singletons (NoOp/Null) stateless; composite snapshots its writers via ToArray(). Mutable singleton options noted in Audit-003.
4 Error handling & resilience Guard clauses on all entry points; composite swallows writer faults. OCE re-throw vs writer contract → Audit-001; partial over-redact → Audit-002.
5 Security & secret handling RedactingAuditWriter applies the redactor strictly before the inner writer — no unredacted-event leak. Actor kept a plain string (no Auth coupling), as specified. No secrets in code/messages.
6 Performance & resource management No IDisposable to manage; no hot-path allocations beyond a single with-copy per redact; DI registration is trivial. No issues found.
7 Spec & shared-contract adherence AuditEvent + AuditOutcome match EVENT-MODEL.md exactly; AddZbAudit uses TryAdd as specified. OCE re-throw deviates from the §1 writer hard rule → Audit-001.
8 Packaging, dependencies & project layout Sole non-BCL dep confirmed via deps.json; net10.0; correct PackageId/version (0.1.0). XML docs not packed → Audit-004.
9 Testing coverage 19 tests; public contract exercised (round-trip, UTC normalization, fan-out, cancellation, redaction ordering). Missing never-throw/over-redact and null-writer cases → Audit-005.
10 Documentation & XML docs XML docs present and accurate on the whole public surface; README accurate. Docs not emitted to a file → Audit-004.

Findings

Audit-001 — CompositeAuditWriter re-throws OperationCanceledException to the caller, contradicting the "must not throw" writer contract

Severity Medium
Category Error handling & resilience
Status Resolved
Location ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs:24

Description

IAuditWriter's hard contract is unambiguous: "Best-effort; must not throw to the caller" (IAuditWriter.cs:11), and SPEC §1 states "WriteAsync MUST NOT throw to the caller … a failed write must never abort the user-facing action it is recording." The shared contract further says a cancellation "does not constitute a contract violation" and the implementation "may choose to complete a partially-written event anyway" — it permits ignoring the token, not throwing on it.

CompositeAuditWriter.WriteAsync re-throws OperationCanceledException (catch (OperationCanceledException) { throw; }, line 24). The very common call shape is to pass the ambient request token, e.g. writer.WriteAsync(evt, httpContext.RequestAborted). When a client disconnects mid-request, the inner writer observes the cancelled token, throws OCE, and the composite propagates it straight into the user-facing action — exactly the abort the seam promises never to cause. This also directly contradicts the helper's own XML doc, which claims "the fan-out drains and the caller is never aborted" (CompositeAuditWriter.cs:4). Because the composite is the recommended way to wire multiple sinks, every consumer that uses it inherits this behaviour; the only workaround is to not pass a request-scoped token, which is non-obvious.

Note this is a deliberate, tested choice (CompositeAuditWriterTests.cs:41), so it is a design/contract conflict rather than an oversight — but the conflict is real: the writer contract says "never throw," the composite says "drains, never aborts," and the code throws on cancellation.

Recommendation

Reconcile the contract and the implementation. Either (a) honour the "never throw" rule literally — swallow OCE like any other writer failure (drop the remaining writers or continue, but do not propagate), and update the XML doc accordingly; or (b) if cancellation propagation is genuinely wanted, amend the IAuditWriter SPEC/shared-contract to carve out OCE explicitly and add a doc warning that consumers must not pass a request-scoped token they cannot afford to see surface. Option (a) is the lower-blast-radius choice and matches the seam's stated intent.

Resolution

Resolved in 544a6dd, 2026-06-01 — CompositeAuditWriter.WriteAsync now swallows OperationCanceledException like any other writer failure (single bare catch), so cancellation never surfaces to the caller; XML doc and the cancellation test updated to assert non-propagation.

Audit-002 — TruncatingAuditRedactor over-redaction is partial: the catch path scrubs only DetailsJson, leaving Target unredacted

Severity Low
Category Error handling & resilience
Status Resolved
Location ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs:27-31, :34-40

Description

The redactor's over-redact fallback returns rawEvent with { DetailsJson = null } (line 30). That scrubs only DetailsJsonTarget, which this redactor is also responsible for capping, is carried through untouched. The spec's "over-redact" rule is "returns a strictly safer event" (SPEC §2); leaving an oversized/sensitive Target in place is not strictly safer.

This becomes reachable under misconfiguration. Truncate(value, max) does marker[..max] when marker.Length >= max (line 38). For a negative MaxTargetLength or MaxDetailsJsonLength, that slice throws ArgumentOutOfRangeException, the Apply catch fires, and the result keeps the original, untruncated Target while only nulling DetailsJson. So a config bug that should fail safe instead leaves the Target field unredacted. (max == 0 is handled correctly — marker[..0] is "".) The redactor never throws, so the "must not throw" half of the contract holds; it is the "strictly safer" half that is violated.

Recommendation

Make the fallback strictly safer across the fields this redactor owns, e.g. return rawEvent with { DetailsJson = null, Target = null }; in the catch. Additionally, guard against nonsensical caps at the boundary — clamp max to >= 0 in Truncate (treat negative as 0), or validate the options in the constructor — so the catch path is only ever hit by truly unexpected failures rather than a predictable negative-length misconfiguration.

Resolution

Resolved in 544a6dd, 2026-06-01 — the over-redact catch now returns rawEvent with { DetailsJson = null, Target = null } (strictly safer), and Truncate clamps a negative max to 0 so a negative-length misconfiguration fails safe instead of throwing; new tests pin both behaviours.

Audit-003 — TruncatingAuditRedactorOptions is a mutable class, not the immutable "options record" the contract describes

Severity Low
Category Public API surface & compatibility
Status Resolved
Location ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs:4-12

Description

The shared contract lists TruncatingAuditRedactorOptions as an "Options record" (shared-contract/ZB.MOM.WW.Audit.md:93), but it is implemented as a sealed class with three mutable get; set; auto-properties. TruncatingAuditRedactor captures the instance by reference (_options = options ?? new(...), TruncatingAuditRedactor.cs:14) and the redactor is typically registered as a singleton. Because the options object stays mutable, a caller (or any other holder of the same reference) can change MaxDetailsJsonLength / MaxTargetLength / TruncationMarker after the redactor is built, changing redaction behaviour at runtime under a concurrent host with no synchronization. This is a (minor) thread-safety footgun and a drift from both the documented "record" shape and the library's otherwise consistent immutability posture (AuditEvent is a record with init-only members).

Recommendation

Make the options immutable: convert to a sealed record with init-only properties (matching the contract wording), or have the redactor defensively snapshot the three values into private readonly fields at construction so post-construction mutation cannot affect it.

Resolution

Resolved in 544a6dd, 2026-06-01 — TruncatingAuditRedactorOptions is now a sealed record with init-only properties, matching the contract's "options record" and removing the post-construction mutation footgun on the singleton redactor.

Audit-004 — XML documentation is authored but not emitted, so IntelliSense docs do not ship to consumers

Severity Low
Category Documentation & XML docs
Status Resolved
Location ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj:1-18, ZB.MOM.WW.Audit/Directory.Build.props:1-10

Description

Every public type and member carries accurate XML doc comments, but neither the project nor Directory.Build.props sets GenerateDocumentationFile. As a result no ZB.MOM.WW.Audit.xml is produced or packed alongside the DLL (confirmed: no XML doc file in bin/), so consuming apps get no IntelliSense/tooltip documentation from the package. For a shared library where, per the review process, "public docs matter," the authored docs are effectively invisible to consumers. (This is a family-wide baseline gap — none of the sibling ZB.MOM.WW.* libraries set the flag either — so it is a baseline issue rather than an Audit-specific regression, hence Low.)

Recommendation

Set <GenerateDocumentationFile>true</GenerateDocumentationFile> (ideally once in the shared Directory.Build.props so it applies across the family). Because the public surface is fully documented, no CS1591 "missing XML comment" warnings should appear.

Resolution

Resolved in 544a6dd, 2026-06-01 — set <GenerateDocumentationFile>true</GenerateDocumentationFile> in the packable ZB.MOM.WW.Audit.csproj; ZB.MOM.WW.Audit.xml now builds with zero CS1591 warnings and packs into the nupkg under lib/net10.0/.

Audit-005 — Missing edge-case tests for the redactor never-throw/over-redact contract and composite null/empty handling

Severity Low
Category Testing coverage
Status Resolved
Location ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs, .../CompositeAuditWriterTests.cs

Description

The 19 tests cover the happy paths and the key guards (UTC normalization + equality, fan-out, cancellation propagation, redaction ordering, marker-longer-than-max). Two contract-critical behaviours are untested:

  1. The IAuditRedactor "never throws / over-redacts on failure" contract — no test drives TruncatingAuditRedactor.Apply into its catch branch, so the over-redaction fallback (and the asymmetry flagged in Audit-002) is unverified.
  2. CompositeAuditWriter boundary inputs — no test covers an empty writer list (should be a no-op) or a list containing a null writer (currently swallowed via the bare catch, silently dropping that sink — behaviour that should be pinned by a test).

Pinning these would have surfaced Audit-002 and would lock the documented never-throw guarantee.

Recommendation

Add: (a) a redactor test that forces an internal failure (e.g. a misconfigured cap) and asserts Apply returns a strictly-safer event without throwing; (b) a composite test for the empty-list no-op; and (c) a composite test documenting the null-entry behaviour (whether swallowed or guarded — pair with the chosen fix for the null case).

Resolution

Resolved in 544a6dd, 2026-06-01 — added four tests: redactor over-redact catch branch scrubs both fields, negative-max clamp, and CompositeAuditWriter empty-list no-op + null-writer-entry swallow (19 → 23 tests).