# Security Hardening

> **Status:** v0.1 — first pass. Last updated 2026-05-14.
> A library-scoped security audit: walked the secret-handling
> paths (vault, redactor, crash reporter, runtime snapshot), the
> source-mutating CLI commands (`migrate`, `catch-up --apply`),
> the deserialization surface, and the supply-chain posture. Every
> finding is pinned to `file:line` and verified against the code.
>
> **Scope:** mgf-common's **own** security posture — is the vault
> crypto sound, does redaction cover every off-box path, is the
> codemod safe to run against consumer source. This is NOT a
> re-audit of [`docs/standards/SECURITY_STANDARD.md`](../standards/SECURITY_STANDARD.md)
> (that's the *guidance*; this is the library's compliance with
> itself — same relationship `RELIABILITY_AND_ACCURACY.md` has to
> the reliability standards). It is also NOT an audit of the
> sibling packages — `mgf-fastapi`'s auth surface (`BearerSession`,
> `AuthVerifier`), `mgf-http`'s TLS posture, etc. each get their
> own tracker in their own repo.
>
> **Audience:** Bassam-tomorrow, AI coding agents, future hires
> doing security work on the federation core.
> **Cross-references:** [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md)
> (sibling tracker — several security-adjacent invariants were
> closed there; see §3), [`docs/standards/SECURITY_STANDARD.md`](../standards/SECURITY_STANDARD.md)
> (the `SC-*` rules), repo-root [`SECURITY.md`](../../SECURITY.md)
> (the vulnerability-reporting policy — distinct from this tracker).

This document is the **living priority tracker** for security
work in `mgf-common`. The shape mirrors
[`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md):
priority table at the top, work-unit detail below, status flipped
as items close. The discipline that makes it work is keeping the
items **narrow** (each one a single concrete commit) and
**falsifiable** (each one has a written "concrete output" that
can be checked off).

---

## §1 You are here

`mgf-common` is a **leaf library** — no network listener, no
database, no auth, no deployment, no multi-tenancy. That makes
its adversarial surface genuinely small, and this is a short
list *because of that*, not despite it. Most of PlasmaMapper's
39-item security tracker — auth/session, tenant isolation,
network-ingest DoS — has no analogue here: that surface lives
in the deployed consumers and (for auth) in the `mgf-fastapi`
sibling.

What `mgf-common` *does* have is three concentrated risk areas,
and the audit found one load-bearing pattern:

**The crash reporter ships secrets off-box without redaction.**
The library has a fully-wired `Redactor` — `JsonFormatter.format`
runs it on every log record, and
[`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md) RA-12
property-tests its eight built-in patterns. But the crash
reporter — the *last* line of defence in every top-level handler
— builds its report dict from the raw exception (`str(exc)`,
`repr()`'d args, full traceback, `__cause__` message) and ships
it to the local file **and** every remote sink (Sentry / OTLP /
arbitrary HTTPS webhook) with **no Redactor pass anywhere on the
path** ([`crash_reporter.py:84-121`](../../src/mgf/common/observability/crash_reporter.py#L84),
[`:232-264`](../../src/mgf/common/observability/crash_reporter.py#L232)).
A consumer that does
`raise ConnectionError(f"failed: postgres://u:hunter2@db/app")`
leaks that DSN to a third-party SaaS. This is **SH-01**, the
single P0, and it is the root cause that makes every other
secret-flow item secondary.

The other two areas are smaller: the opt-in **runtime snapshot**
(`MGF_DOCTOR_RUNTIME_PATH`) writes a world-readable JSON dump of
the full `MgfSettings` — including `crash.sentry_dsn` — with no
redaction (**SH-02**); and the **source-mutating CLI commands**
(`migrate`, `catch-up --apply`) rewrite consumer files
non-atomically (**SH-04**) — an integrity gap for a high-trust
tool. `catch-up --apply` at least always writes a timestamped
`.bak` first; `migrate`'s backup is an opt-in `--backup` flag
(off by default), so a default `migrate` run rewrites source
with neither a backup nor an atomic write.

**Counts (2026-05-14, first pass — all items now closed):**

| Priority | Count | Effort | Status |
|---|---|---|---|
| P0 — v1.0 surface-lock blockers | 1 | ~M | ✅ closed (SH-01) |
| P1 — should land before/with v1.0 | 3 | ~1 session-week (3×S) | ✅ closed (SH-02 / SH-03 / SH-04) |
| P2 — post-v1.0 hardening | 4 | varies | ✅ closed (SH-05 / SH-06 / SH-07 / SH-08) |

**Effort calibration:** S ≤ 1 day, M ≈ 2–4 days, L ≈ 1
session-week. Intuition-anchored; recalibrate after the first
items close.

**Tracker complete (2026-05-14).** All eight items shipped in
the same session: P0 + P1 first (the secret-flow + integrity
work), then the four P2 items (gitleaks CI gate, repr()
allowlist, SBOM emission, env-var trust documentation). See
§5 for the closure timeline and SHAs. Document stays in
`docs/inprogress/` for ~one release cycle as historical
reference; can move to `docs/archive/` once a v1.0 surface
lock cuts.

---

## §2 Priority table

### P0 — v1.0 surface-lock blockers (1 item)

| ID | Theme | One-liner | Effort | Status |
|---|---|---|---|---|
| [**SH-01**](#sh-01-crash-reports-bypass-the-redactor) | Secret leakage | The crash reporter builds its payload from the raw exception and ships it — local file + Sentry + OTLP + HTTP — with NO Redactor pass anywhere; a secret in an exception message reaches a third-party SaaS | M | ✅ closed |

### P1 — should land before/with v1.0 (3 items)

| ID | Theme | One-liner | Effort | Status |
|---|---|---|---|---|
| [**SH-02**](#sh-02-runtime-snapshot-is-world-readable-and-unredacted) | Secret leakage | `MGF_DOCTOR_RUNTIME_PATH` snapshot is written at the default umask (≈0o644) and contains `settings.model_dump()` — including `crash.sentry_dsn` / `crash.http_url` — with no redaction | S | ✅ closed |
| [**SH-03**](#sh-03-redactor_walk-has-no-recursion-depth-limit) | DoS / robustness | `Redactor._walk` recurses into nested dicts/lists with no depth cap and no cycle guard — a deeply-nested or self-referential payload raises `RecursionError` *inside* the redactor, mid-scrub | S | ✅ closed |
| [**SH-04**](#sh-04-source-mutating-cli-commands-write-non-atomically) | Integrity | `migrate` and `catch-up --apply` rewrite consumer source / `pyproject.toml` / `CLAUDE.md` with a plain `write_text` — non-atomic; `migrate`'s backup is opt-in (`--backup`, off by default) so a default run has no net at all. The shared `atomic_write` helper already exists. | S | ✅ closed |

### P2 — defer to post-v1.0 (4 items)

| ID | One-liner | Effort | Status |
|---|---|---|---|
| SH-05 | No secret-scanner gate (`gitleaks` / `trufflehog`) in CI. Low realistic exposure — `my_stuff/` is blanket-gitignored and the library has no runtime secrets — but mgf-common publishes to PyPI, so a leaked token is a federation-wide supply-chain event. ~20 lines of YAML; deferred only because the realistic probability is low. | S | ✅ closed |
| SH-06 | Even after SH-01 redacts the crash payload, `_build_report` uses `repr(a)` on each exception arg ([`crash_reporter.py:111`](../../src/mgf/common/observability/crash_reporter.py#L111)). `repr()` of a custom exception's arg can surface more state than `str()` — and the Redactor only scrubs *string* values, not the internals of an arbitrary object's repr. Decide whether `str()` + a typed-field allowlist is the safer shape. | S | ✅ closed |
| SH-07 | No SBOM / build-provenance step in the release flow. `uv build` + `uv publish` produce the wheel; nothing emits a CycloneDX/SPDX manifest or signs the artifact. Supply-chain provenance for a foundational library is worth having before v1.0's broader adoption — but it's additive, not a gap in existing behaviour. | M | ✅ closed |
| SH-08 | The legacy `<APP>_CRASH_SINK` env var accepts an arbitrary `https://` URL ([`crash_reporter.py` sink resolution](../../src/mgf/common/observability/crash_reporter.py#L232)) — a process whose environment is attacker-influenced can have its crash reports exfiltrated to any host. This is a *documented trust assumption*, not a bug (env-var trust is inherent), but it should be stated explicitly in the crash-reporter docs + `SECURITY_STANDARD.md` rather than left implicit. Document, don't "fix". | S | ✅ closed |

---

## §3 Invariants we hold

Load-bearing security properties confirmed sound by the audit, or
closed by the sibling reliability tracker. Security work either
preserves these or escalates to the standard owner.

**Confirmed sound by this audit:**

- **Vault crypto is sound.** [`vault/_file_vault.py`](../../src/mgf/common/vault/_file_vault.py)
  uses Fernet (AES-128-CBC + HMAC-SHA256 — authenticated
  encryption), PBKDF2-HMAC-SHA256 at **600 000 iterations**
  ([`:67`](../../src/mgf/common/vault/_file_vault.py#L67) — OWASP
  2023 baseline), a **per-vault 16-byte salt** that is generated
  fresh for a new vault and read back from the existing file
  header on rewrite (never reused across vaults, never config-
  derived), and writes at mode `0o600` via the shared
  `atomic_write` helper with `fsync_dir=True`. Fernet's decrypt
  is constant-time on the HMAC; no hand-rolled comparison.
- **`EnvVault` audit-logs the key NAME, never the value**
  ([`vault/_env_vault.py`](../../src/mgf/common/vault/_env_vault.py)).
- **`JsonFormatter` redacts the log path.** `JsonFormatter.format`
  applies the configured `Redactor` to the full payload — extras,
  OTel `trace_id`/`span_id` context, exception text — *before*
  serialisation ([`json_formatter.py:131`](../../src/mgf/common/observability/json_formatter.py#L131)).
  The redactor's eight built-in value-patterns are
  property-tested (RA-12). The crash-report path (SH-01) is the
  one off-box egress this does NOT cover — that's the whole P0.
- **No unsafe deserialization anywhere in `src/`.** Every YAML
  load is `yaml.safe_load`
  ([`config/_loader.py:92`](../../src/mgf/common/config/_loader.py#L92),
  [`versioned/_store.py:391`](../../src/mgf/common/versioned/_store.py#L391),
  [`cli/_validate.py:71`](../../src/mgf/common/cli/_validate.py#L71)).
  No `pickle`, `marshal`, `eval`, `exec`, or `yaml.load` /
  `yaml.full_load` in the library.
- **`process.run` is injection-safe by design.**
  [`process/_run.py`](../../src/mgf/common/process/_run.py)
  rejects bare shell strings (`_validate_command` raises
  `TypeError`), requires a `list[str]` / `tuple[str, ...]` argv,
  and never uses `shell=True`. The shell-string opt-out is the
  documented invariant — a consumer who wants a shell passes
  `["/bin/sh", "-c", ...]` explicitly and owns that surface.
- **Path resolution does not join attacker-controlled
  components.** [`config/_paths.py`](../../src/mgf/common/config/_paths.py)
  reads `XDG_*` / `APPDATA` / `LOCALAPPDATA` env vars but
  constructs paths only as `Path.home() / <literal-suffix>` or
  `Path(env_value)` directly — no `os.path.join` of an
  attacker-influenced relative segment. `expand_path` uses
  stdlib `os.path.expandvars` + `expanduser` (CF-04 lazy
  resolution).
- **The PyPI publish token is not in git.** `my_stuff/` is
  blanket-listed in `.gitignore` ([`.gitignore:64`](../../.gitignore#L64));
  `git check-ignore my_stuff/keys/pypi_tokens.txt` confirms it
  is ignored, and `git ls-files my_stuff/` returns nothing.
- **`pip-audit --strict` runs in CI** ([`.woodpecker.yml:203-204`](../../.woodpecker.yml#L203)
  — rule SC-10) — a CVE in a direct or transitive dependency
  fails the build.
- **The Redactor's built-in regexes have no catastrophic
  backtracking.** The PEM pattern's lazy `.*?` under `re.DOTALL`
  scans linearly even on a `BEGIN`-without-`END` input; the
  other seven are prefix-anchored. (The recursion issue, SH-03,
  is orthogonal — it's the dict-walk, not the regexes.)
- **`report_now` never raises.** Every failure during report
  build / local write / remote ship is caught and logged at
  WARNING ([`crash_reporter.py:54-81`](../../src/mgf/common/observability/crash_reporter.py#L54))
  — the EH-05 last-line-of-defence invariant. SH-01's fix MUST
  preserve this (a redactor pass that raises cannot break the
  guarantee).

**Closed in [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md) — security-adjacent, do not re-open:**

- **RA-01 / RA-28** — the shared `mgf.common._io.atomic_write`
  helper (fsync body + optional parent-dir fsync, mode `0o600`)
  is used by the vault, the crash-report *file* write, the
  config loader, and the versioned-file store. SH-04 is the
  residual: the *CLI mutation* commands don't use it yet.
- **RA-05** — every dependency carries an upper bound; a
  breaking major can't land silently. Supply-chain hygiene at
  the dependency-spec level.
- **RA-12** — per-pattern property tests for the eight built-in
  redactor value-patterns (Bearer / JWT / PEM / OpenAI / GitHub
  / AWS / Slack / Google) — proof the redactor catches what it
  claims to.
- **RA-18** — the v0.33.0 drift guard
  (`test_v033_drift_guard.py`) — the allowlist pattern is
  reusable for any future "removed name must not return" gate.

---

## §4 Work units

> One section per row. Each entry has: **Why** (with `file:line`
> evidence), **Concrete output**, **Dependencies**, **Status**.

### SH-01 Crash reports bypass the Redactor

**Why:** `report_now`
([`crash_reporter.py:54-81`](../../src/mgf/common/observability/crash_reporter.py#L54))
runs `_build_report` → `_write_local` → `_ship_remote` with **no
Redactor anywhere on the path.** `_build_report`
([`:84-121`](../../src/mgf/common/observability/crash_reporter.py#L84))
captures:

- `"message": str(exc)` ([`:110`](../../src/mgf/common/observability/crash_reporter.py#L110))
  — the raw exception message verbatim.
- `"args": [repr(a) for a in getattr(exc, "args", ())]`
  ([`:111`](../../src/mgf/common/observability/crash_reporter.py#L111))
  — every exception arg, `repr()`'d.
- `"traceback": traceback.format_exception(...)`
  ([`:113`](../../src/mgf/common/observability/crash_reporter.py#L113))
  — the full formatted traceback, which includes the repr of
  every frame's locals when the exception carries them.
- `"cause": {"message": str(exc.__cause__)}`
  ([`:99`](../../src/mgf/common/observability/crash_reporter.py#L99))
  — the chained-cause message verbatim.

That dict then goes to **the local file** (mode `0o600` — at
least local-only) AND, via `_ship_remote`
([`:232-264`](../../src/mgf/common/observability/crash_reporter.py#L232)),
to **every registered sink**: the built-in `sentry` / `otlp` /
`http` sinks fan the *entire payload* to a third-party SaaS, an
OTLP collector, or an arbitrary HTTPS webhook. The
`JsonFormatter` redacts the *logging* path; the crash reporter —
which is the **last line of defence in every top-level handler**,
i.e. fires exactly when something went wrong and the exception
text is most likely to carry a connection string, a token, a
file path with a username — has no redaction at all.

Concretely: a consumer that does
`raise ConnectionError("auth failed for postgres://svc:S3cr3t@db:5432/prod")`
leaks `S3cr3t` to Sentry, the OTLP collector, and any HTTP sink.
The library's own redactor would catch a `postgres://...@`
DSN — if it were ever asked to.

**Concrete output:**
- A `Redactor` pass applied to the report dict **before** it
  leaves `_build_report` (or as the first step of both
  `_write_local` and `_ship_remote` — pick the single
  choke-point). The redactor walks the dict and scrubs `message`,
  each `args` entry, the `cause.message`, and the `traceback`
  list-of-strings.
- The redactor used is the **bootstrap-wired** one when a
  context is active (so consumer `extra_keys` /
  `extra_value_patterns` apply), falling back to
  `Redactor.default()` when no context is active (the crash
  reporter can fire pre- or post-`shutdown()`).
  `mgf.common.process._streamer.get_active_redactor()` already
  implements exactly this "find the wired redactor" walk —
  reuse it.
- The redactor pass MUST NOT break the `report_now` "never
  raises" invariant (§3) — wrap it so a redactor failure logs a
  WARNING and falls through to shipping the *unredacted-but-
  better-than-nothing*… **no.** Decide deliberately: on a
  redactor failure, the safer choice is to ship a *minimal*
  report (`type`, `report_id`, `timestamp`, a "redaction
  failed — payload withheld" marker) rather than the raw one.
  Write that decision into the docstring.
- A test: `report_now` on an exception whose message + args +
  cause carry a `postgres://`/`Bearer`/`sk-` shaped secret, with
  a recording sink registered — assert the sink received a
  payload with `[REDACTED]` in place of every secret, and that
  the local file is likewise scrubbed.
- A test that the "never raises" guarantee survives a redactor
  that raises (monkeypatch `Redactor.redact` to throw; assert
  `report_now` still returns and the WARNING fired).

**Dependencies:** none. This is the root-cause item — do it
first.

**Status:** ✅ closed (commit ffdfeb8, 2026-05-14). Effort: M.

---

### SH-02 Runtime snapshot is world-readable and unredacted

**Why:** When `MGF_DOCTOR_RUNTIME_PATH` is set,
`bootstrap()` calls `_maybe_emit_runtime_snapshot`
([`_lifecycle.py`](../../src/mgf/common/_lifecycle.py) — the
snapshot dict is built with
`"settings": settings.model_dump(mode="json", exclude_unset=False)`),
and `emit_runtime_snapshot`
([`cli/_doctor_live.py:168-195`](../../src/mgf/common/cli/_doctor_live.py#L168))
writes it with a plain
`path.write_text(json.dumps(snapshot, ...))`
([`:183-186`](../../src/mgf/common/cli/_doctor_live.py#L183)) —
**no `mode=` argument**, so the file lands at the process umask
(typically `0o644`: world-readable).

Two problems compound:

1. **The settings dump is unredacted.**
   `MgfSettings.model_dump()` includes the `crash` sub-model,
   whose `sentry_dsn: str | None` and `http_url: str | None`
   fields are **secrets** — a Sentry DSN embeds an auth key; an
   HTTP-sink URL can carry a token. It also dumps `vault` config
   and every OTel endpoint. None of it goes through the Redactor.
2. **The file is world-readable.** On any multi-user host, every
   user can read `MGF_DOCTOR_RUNTIME_PATH`.

This is opt-in (off unless the env var is set), which is why
it's P1 not P0 — but a consumer who turns on `doctor --live`
diagnostics on a shared host has just published their Sentry
DSN to `/tmp`.

**Concrete output:**
- `emit_runtime_snapshot` writes via the shared
  `mgf.common._io.atomic_write` helper with `mode=0o600` — both
  hardens the permission AND makes the write atomic (a partial
  snapshot read by `doctor --live` is a confusing failure).
- The settings portion of the snapshot is run through the
  bootstrap-wired `Redactor` before serialisation — OR, cleaner,
  `_maybe_emit_runtime_snapshot` builds the settings dump with a
  redaction-aware serialiser / an explicit secret-field
  exclusion list (`crash.sentry_dsn`, `crash.http_url`, and any
  `vault.config` field). Prefer the explicit exclusion: the
  snapshot is a *diagnostic*, and "the Sentry DSN is set
  (value withheld)" is exactly as useful as the raw value.
- A test: set `MGF_DOCTOR_RUNTIME_PATH`, bootstrap with a
  `CrashSettings(sentry_dsn="https://KEY@sentry.io/1")`, assert
  (a) the file mode is `0o600`, (b) the raw key does not appear
  in the file.

**Dependencies:** none. Shares the "redact before off-box /
on-disk egress" discipline with SH-01.

**Status:** ✅ closed (commit 2b6c765, 2026-05-14). Effort: S.

---

### SH-03 `Redactor._walk` has no recursion-depth limit

**Why:** `Redactor._walk`
([`observability/redaction.py:298-312`](../../src/mgf/common/observability/redaction.py#L298))
recurses into nested dicts and lists with **no depth cap and no
cycle guard**:

```python
def _walk(self, node, keys):
    if isinstance(node, dict):
        for k in list(node.keys()):
            ...
            node[k] = self._walk(node[k], keys)   # ← unbounded
        return node
    if isinstance(node, list):
        return [self._walk(v, keys) for v in node]  # ← unbounded
    ...
```

Two failure modes:

1. **Deep nesting.** A consumer logs `extra={"data": d}` where
   `d` is a few-thousand-level-nested structure (parsed JSON
   from an untrusted source, a recursive data model) →
   `RecursionError` raised *inside* `Redactor.redact`, which
   runs inside `JsonFormatter.format`, which runs inside the
   logging handler.
2. **Self-reference.** `d = {}; d["self"] = d` → **infinite**
   recursion → `RecursionError` immediately.

Either way the redactor crashes mid-scrub. Because `_walk`
mutates dicts in place, a crash partway through leaves the
payload *partially* redacted — and depending on how the logging
machinery handles the formatter exception, a partially- or
un-redacted record can still reach a sink. The redactor is
security-critical; it must not be the thing that fails.

**Concrete output:**
- A `_MAX_REDACT_DEPTH` constant (e.g. 64 — deeper than any
  sane log payload) and a depth parameter threaded through
  `_walk`. At the cap, stop recursing and replace the over-deep
  node with a sentinel (`"[REDACTION-DEPTH-EXCEEDED]"`) rather
  than raising — fail *safe*, not *open*: an un-walked subtree
  is replaced wholesale, so nothing unredacted leaks past the
  cap.
- A cycle guard: track visited container `id()`s along the
  current path; a back-edge gets the same sentinel.
- Tests: a 1000-deep nested dict redacts cleanly (sentinel at
  the cap, no `RecursionError`); a self-referential dict
  redacts cleanly; a normal 3-level payload is unaffected.

**Dependencies:** none.

**Status:** ✅ closed (commit 5c7402f, 2026-05-14). Effort: S.

---

### SH-04 Source-mutating CLI commands write non-atomically

**Why:** Two CLI commands rewrite files on the consumer's
machine, and both use a plain `Path.write_text`:

- `migrate` ([`cli/_migrate.py:336-339`](../../src/mgf/common/cli/_migrate.py#L336))
  — the codemod rewrites consumer **Python source**. The backup
  is an **opt-in `--backup` flag** ([`:260`](../../src/mgf/common/cli/_migrate.py#L260)
  — defaults to `False`), so a default `migrate` run does
  `path.write_text(change.transformed, ...)` with **no backup
  at all** and a non-atomic write. There is a `--dry-run` for
  preview, but a real run has no net.
- `catch-up --apply` ([`cli/_catch_up.py:566-568`](../../src/mgf/common/cli/_catch_up.py#L566),
  [`:835-837`](../../src/mgf/common/cli/_catch_up.py#L835),
  [`:1325`](../../src/mgf/common/cli/_catch_up.py#L1325)) —
  rewrites consumer `pyproject.toml` (pin bumps) and
  `docs/CLAUDE.md` (marker blocks). Better posture: it
  **always** writes a timestamped `.bak.<ts>` before the
  rewrite — but the target write is still a plain
  non-atomic `write_text`.

So `catch-up`'s crash-mid-write is recoverable (the `.bak` sits
next to the truncated file); `migrate`'s default-mode
crash-mid-write is **not** — the original is gone. The shared
`mgf.common._io.atomic_write` helper already exists (RA-01),
already does temp-write → fsync → atomic rename, and a tool
whose entire job is *rewriting the consumer's source code*
should not have a truncation window at all.

**Concrete output:**
- Both target writes (`_migrate.py:339`, `_catch_up.py:568` /
  `:837` / the CLAUDE.md write) migrated to
  `mgf.common._io.atomic_write(path, content, mode=...,
  fsync_dir=False)`. The `.bak` writes themselves can stay
  plain `write_text` — a half-written backup is harmless — but
  the *target* write must be atomic.
- `migrate` either makes `--backup` the default (a codemod
  with no undo path is a foot-gun), or — better, now that the
  write is atomic — relies on atomicity + `--dry-run` and
  documents that the consumer's VCS is the undo path. Pick one
  deliberately and write it into the `--help` text.
- The `migrate` codemod additionally: after computing the
  transform, parse the *result* with `ast.parse` and refuse to
  write if it doesn't parse — a transform that produces invalid
  Python should fail loud, not land a broken file. (The audit
  found no such check at the write site, `_migrate.py:339`.)
- A test: monkeypatch `atomic_write` to raise mid-call; assert
  the original file is untouched (not truncated).

**Dependencies:** none. Uses the RA-01 helper.

**Status:** ✅ closed (commit `6eefed6`, 2026-05-14). Effort: S.

---

### SH-05 – SH-08 (P2 — deferred to post-v1.0)

Each is a narrow, falsifiable item; full work-unit detail will
be written when P0/P1 clears and these are pulled forward.
Summary evidence:

- **SH-05** — No `gitleaks` / `trufflehog` step in
  `.woodpecker.yml`. The realistic exposure is low: `my_stuff/`
  is blanket-gitignored (§3), and a *library* has no runtime
  `.env` / DSN / credential the way a deployed app does. But
  mgf-common publishes to PyPI — a leaked publish token is a
  federation-wide supply-chain event — so the *impact* is high
  even though the *probability* is low. ~20 lines of YAML;
  deferred on probability, not dismissed. Pairs conceptually
  with PlasmaMapper's SH-24.
- **SH-06** — Even once SH-01 redacts the crash payload,
  `_build_report` uses `repr(a)` on each exception arg
  ([`crash_reporter.py:111`](../../src/mgf/common/observability/crash_reporter.py#L111)).
  The Redactor scrubs *string values*; it does not reach inside
  the `repr()` of an arbitrary object, which can surface object
  state a `str()` would not. Decide whether the safer shape is
  `str(a)` plus a typed-field allowlist, or whether `repr` is
  worth keeping for diagnostic fidelity (and the SH-01 redactor
  pass is "good enough"). Needs a judgement call, hence P2.
- **SH-07** — The release flow (`uv build` → `uv publish`)
  emits no SBOM (CycloneDX / SPDX) and does not sign the
  artifact. For a foundational library heading into broader
  v1.0 adoption, build provenance is worth having — but it is
  *additive* (a new capability), not a gap in existing
  behaviour, so it sits below the items that fix real
  exposure. Scope: a `cyclonedx-py` (or equivalent) step in the
  release runbook + a note in `RELEASING.md`.
- **SH-07** — `.woodpecker.yml` gained an `sbom` step that runs
  on every push (after `build`, parallel to `secret-scan`).
  Generates a CycloneDX 1.x JSON manifest via
  `cyclonedx-py environment` against a fresh
  `pip install .[observability,vault]` environment — capturing
  the runtime closure a consumer might install (dev extra
  deliberately excluded so pytest/mypy/ruff don't pollute the
  consumer-facing SBOM). Output lands at
  `dist/mgf-common-<version>.cdx.json` next to the wheel +
  sdist. `--output-reproducible` strips timestamps so the SBOM
  is byte-stable across re-runs on the same commit (supports
  REL-02's wheel-content immutability). Local dry-run on the
  dev environment with `cyclonedx-bom>=7,<8`: 66 components,
  spec-version 1.6, main `mgf-common@0.33.0`, validated by the
  in-line python assertion (`bomFormat == "CycloneDX"`, main
  component name+version, components > 0). `publish` step now
  depends on `sbom` so a release tag cannot ship without a
  valid SBOM. `RELEASING.md` §2.3 documents the artifact +
  Dependency-Track ingestion recipe;
  `SECURITY_STANDARD.md` §16 status updated to ✅. Tool
  choice (cyclonedx-py over syft / pip-licenses /
  defer): Python-native, no extra runtime, output is the
  de-facto standard ingested by all major SCA tools — user
  confirmed via 4-option fork. (commit `0c609a2`, 2026-05-14).
- **SH-06** — `_build_report` now formats `exception.args`
  through a new `_safe_repr()` helper instead of bare `repr()`.
  Strategy: an allowlist of types whose `__repr__` is
  structurally fixed (`str`, `int`, `float`, `bool`, `bytes`,
  `complex`, `None`, plus stdlib `Path`/`PurePath`, `UUID`,
  `datetime`/`date`/`time`/`timedelta`, `Decimal`) — those get
  the full `repr()`; everything else (custom classes, stdlib
  containers like `dict`/`list`/`tuple`/`set` whose reprs
  delegate to their elements) becomes `<TypeName>`. This bounds
  the leak surface for a custom class whose `__repr__` would
  otherwise surface secret state (e.g., `Connection(user=...,
  password=...)`). The diagnostic-info loss for containers is
  the accepted trade-off. 5 new tests in
  `TestSH06SafeReprOnExceptionArgs`:
  primitives-keep-full-repr; pathlib/uuid/datetime
  pass-through; custom-class-leaky-`__repr__` lands as
  `<TypeName>` in `args`; container args land as `<TypeName>`;
  mixed args partition correctly.
  **Scope note (acknowledged in test docstring + below):**
  SH-06 closes the `args` FIELD leak. Python's
  `traceback.format_exception` still bakes the leaky `repr(exc)`
  into the traceback line because `str(exc)` falls back to
  `repr(args[0])` upstream of the crash reporter; the Redactor
  does NOT pattern-match arbitrary `key=value` substrings in
  free-form strings, so a leaky `__repr__` can still surface
  via the traceback field. Closing the traceback gap would need
  either an invasive traceback rewrite OR a `key=value`
  redactor pattern (with all its false-positive risk). That is
  a separately-scoped, post-v1.0 item — explicitly NOT in
  SH-06's promise. The `args` fix is genuine: it removes a
  structured, predictable, high-signal leak path. 1409 tests
  pass (was 1404 + 5 new); mypy --strict clean. (commit
  `af07b34`, 2026-05-14).
- **SH-05** — `gitleaks` is now wired as the `secret-scan` step
  in `.woodpecker.yml`, pinned to `zricethezav/gitleaks:v8.30.1`
  (the registry doesn't ship a rolling `v8` tag — exact version
  pin per SC-18). `.gitleaks.toml` extends the bundled rule pack
  and allowlists the redactor's own test fixtures
  (`test_redaction.py`, `test_redaction_value_patterns.py`,
  `test_crash_reporter.py`), the redactor module itself
  (`src/mgf/common/observability/redaction.py` — it contains
  the regex patterns gitleaks would re-flag), and the example
  in `examples/03_logging/03_advanced.py` (pedagogical
  `sk_live_xyz789` placeholders). Dry-run against the current
  working tree + 121 commits of history: **no leaks found**.
  `publish` step gained `secret-scan` as a dependency so a
  release tag cannot ship without the gate passing.
  `SECURITY_STANDARD.md` §16 status updated to ✅. (commit
  `c77549c`, 2026-05-14).
- **SH-08** — ✅ closed (commit `ee8cb60`, 2026-05-14). Trust
  assumption is now documented in two places: a new §8.5
  "Environment variables are a trusted input" in
  `SECURITY_STANDARD.md`, and a new "Trust assumption —
  `<APP>_CRASH_SINK` (SH-08)" section in
  `docs/reference/crash_reporting.md`. The §16 "AS-IS in
  `mgf-common`" status list now references both. No code change
  — env-var trust is inherent to env-var-driven configuration;
  the fix was to state the assumption explicitly so consumers
  can make an informed choice about whether to use the env-var
  path or pass `crash.sinks` via an explicit `MgfSettings`
  instance.

---

## §5 Closed items

- **SH-04** — Both source-mutating CLI commands now use the
  shared `mgf.common._io.atomic_write` helper:
  - `migrate` (`cli/_migrate.py`): the target write at `:339`
    became `atomic_write(path, change.transformed, mode=...)`
    with a pre-write `ast.parse` validator that REFUSES to land
    if the codemod produced unparseable Python. The original
    file's permissions are preserved by stat'ing first.
  - `catch-up --apply` (`cli/_catch_up.py`): all four target
    writes (`_apply_bump_pin` pyproject, `_apply_bump_pin_in_file`
    requirements, `_apply_create_claude_md`,
    `_apply_refresh_claude_md`, `_apply_bootstrap_claude_md_markers`)
    use `atomic_write`. The `.bak` writes themselves stay
    `write_text` — a half-written backup is harmless.
  Three new tests: atomic-rewrite-no-truncation (monkeypatch
  `atomic_write` to raise; assert original file unchanged),
  refuses-invalid-python (synthesise a Change whose
  `transformed` is unparseable; assert non-zero exit + "invalid
  Python" stderr + original file untouched), and
  preserves-original-file-permissions (chmod 0o600 / 0o644 both
  survive the rewrite). 1404 total tests pass; mypy --strict
  clean. (commit `6eefed6`, 2026-05-14).
- **SH-03** — `Redactor._walk` is now bounded by
  `_MAX_REDACT_DEPTH = 64` and guards against cycles via a
  `set[int]` of visited container `id()`s along the current
  path. Over-cap or back-edge subtrees are replaced with the
  `_DEPTH_EXCEEDED_PLACEHOLDER` sentinel — fail-safe (drop the
  unwalked subtree), not fail-open. The cycle guard pops on
  the way out of each recursion frame so a dict reached by two
  SIBLING paths (shared structure, not a cycle) is walked both
  times — pinned by `test_shared_subtree_is_not_treated_as_cycle`.
  Five new tests covering: normal 3-level payload unaffected,
  1000-deep nesting redacts cleanly (no `RecursionError`),
  self-referential dict, self-referential list, shared subtree
  is not a cycle. 1401 total tests pass; mypy + ruff clean.
  (commit 5c7402f, 2026-05-14).
- **SH-02** — `emit_runtime_snapshot` now writes via
  `mgf.common._io.atomic_write` at mode `0o600` (was: plain
  `Path.write_text` at the process umask, typically world-
  readable). Known secret-bearing settings fields
  (`crash.sentry_dsn`, `crash.http_url`, `crash.otlp_endpoint`)
  are replaced with `"<set>"` / `"<unset>"` presence indicators
  before serialisation — the diagnostic value ("is the Sentry DSN
  configured?") is preserved; the raw value never reaches disk.
  The scrub operates on a shallow copy so the caller's snapshot
  dict is untouched. Five new tests covering: file mode `0o600`,
  sentry_dsn / http_url / otlp_endpoint redaction with `"<set>"`,
  empty-value mapping to `"<unset>"`, robustness to malformed
  shapes (missing settings dict, settings as non-dict, crash as
  non-dict), and caller-dict-not-mutated. 1396 total tests pass;
  mypy --strict clean. (commit 2b6c765, 2026-05-14).
- **SH-01** — `crash_reporter.report_now` now runs a Redactor
  pass on the report dict BEFORE the local write AND before
  remote shipping. The wired bootstrap Redactor (via
  `get_active_redactor()` — same lookup `process._streamer`
  uses) is preferred; `Redactor.default()` is the fallback when
  no bootstrap context is active. On redactor failure, the
  report is replaced with a fail-safe **minimal payload**
  carrying only timestamp / id / app / version / source /
  exception type / platform / `redaction_status="failed"` —
  every potentially-secret-bearing field
  (`exception.message`, `exception.args`, `traceback`, `cause`)
  is dropped and replaced with a sentinel. The redactor's own
  error message is NOT included (only the error TYPE), so a
  redactor that crashes on a secret-containing input can't
  surface the secret in its own exception. EH-05 "never raises"
  invariant preserved. 7 new tests covering: JWT redaction in
  local file, Bearer redaction at remote-sink egress,
  bootstrap-wired-redactor preferred, fallback to default when
  no context, minimal payload on redactor failure, no leak of
  the redactor's own error message, never-raises holds under
  redactor failure. 1391 total tests pass; mypy --strict clean.
  (commit ffdfeb8, 2026-05-14).

---

## §6 Notes on methodology

This tracker was seeded by a **library-scoped audit** across ten
areas: crash-report / log / span information disclosure, ReDoS +
recursion in the redactor, the `migrate` codemod, the
`catch-up --apply` path, vault crypto, the runtime snapshot,
path-traversal / env-var trust, subprocess handling, supply-chain
/ repo hygiene, and deserialization. Every finding was verified
against `file:line` evidence before landing here — six of the
ten areas came back **clean** (§3 records them as held
invariants), which for a leaf library is the expected and
healthy ratio.

The shape is deliberately stolen from
[`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md):
priority table → first wave → work units; narrow, falsifiable
items; status flipped as items close.

**What a v0.2 pass should do:**

- **Audit the siblings.** This tracker is scoped to `mgf-common`
  core. `mgf-fastapi` carries the auth surface (`BearerSession`,
  `AuthVerifier`, the webhook HMAC verifier) — that's where the
  PlasmaMapper-style auth/session findings would live, and it
  needs its own `SECURITY_HARDENING.md`. `mgf-http`'s TLS
  posture (outbound client) is similar.
- **Re-audit the crash-report path after SH-01 lands.** SH-01
  introduces a redactor pass on the hottest failure path; the
  v0.2 pass should confirm the "never raises" invariant still
  holds under every redactor-failure mode, and that the
  redactor used is genuinely the bootstrap-wired one (so
  consumer `extra_keys` apply to crash reports, not just logs).
- **Consider a threat-model sketch** — even a one-page
  attack-tree for "a secret in a consumer's process" tracing
  every path it could take to disk or off-box (log sink, crash
  file, crash sink, runtime snapshot, OTel span, traceback).
  This tracker is the *punch list*; the threat model is the
  *map* that proves the punch list is complete. For a library
  this small, the map fits on one page.
- **An `ultrareview` pass before v1.0** — security findings
  benefit most from a second independent read, and the v1.0
  surface lock is the right gate to put it behind.

When an item closes, move it to §5. When a later pass sharpens
an item, rewrite it in place and note the change at the top
of §1.
