ae0ccc9a3a
All 35 findings fixed in 544a6dd and marked Status: Resolved with resolution
notes. README regenerated: 0 pending / 35 total across 6 libraries.
228 lines
14 KiB
Markdown
228 lines
14 KiB
Markdown
# 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 `DetailsJson` — `Target`, 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).
|