# Security Hardening

> **Status:** v0.1 — first pass, finalized 2026-05-15
> (review-pass verified all file:line citations against the
> live source). Last updated 2026-05-15.
> A federation-sibling-scoped security audit: sampled the
> security-sensitive files (`auth/flows.py`,
> `transport/httpx_transport.py`, `checks/security.py`,
> `core/finding.py`, `core/probe.py`) against the **SC-*** rules
> in `mgf-standard/docs/standards/SECURITY_STANDARD.md`. The
> sibling's surface is large (~5000 LOC) but the security-
> relevant subset is concentrated in auth flows, the transport
> layer, and the finding-content shape.
>
> **Scope:** mgf-apiprobe's OWN security posture — both as a
> Python library AND as the test harness that probes others'
> APIs. NOT a re-audit of mgf-common or httpx / pydantic.
>
> **Audience:** the maintainer + future AI agents.
> **Cross-references:**
> [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md),
> [`mgf-standard/docs/standards/SECURITY_STANDARD.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/SECURITY_STANDARD.md),
> [`docs/standards/SCOPE.md`](../standards/SCOPE.md),
> repo-root [`SECURITY.md`](../../SECURITY.md).

The sibling carries TWO security stories: **the library's own
posture** (credential handling, redaction, audit) AND **the
harness's verification-correctness shape** (security checks
that consumers RUN against their providers — Hsts, frame-options,
content-type-options, body-echoes-secret). A flaw in the
harness's checks means consumers get false-negative findings
on their own security posture.

---

## §1 You are here

mgf-apiprobe ships auth flows (Bearer / Basic / Cookie / ApiKey
/ Chained / Null), security checks (4 hygiene checks), an
httpx-backed transport, and a probe-runner with concurrency
caps + tag filters. The library's secret-handling delegates to
mgf-common's Redactor (when consumers wire structured logging).
Auth flow credentials live in memory for the probe's duration
and are then released to GC.

What this tracker exposes is:

1. **The library's own posture** — secrets-in-memory lifecycle,
   findings-content shape, cookie / header sanitization on the
   outbound transport.
2. **The harness's correctness** — do the security checks
   correctly detect what they claim?
3. **Credential handling** — env-var resolution, in-memory
   retention, scrubbing on shutdown.

**Counts (2026-05-17):**

| Priority | Open | Closed | Total |
|---|---|---|---|
| P0 — pre-v1.0 blockers | 0 | 0 | 0 |
| P1 — should land before/with v1.0 | 0 | 4 | 4 |
| P2 — post-v1.0 hardening | 2 | 1 | 3 |
| **Total** | **2** | **5** | **7** |

**Do first:** SH-01..04 closed (P1). SH-06 closed (P2,
2026-05-17 — synthetic Finding emitted by Suite at run-start
when `verify_tls=False`). SH-05 (security-check coverage gaps —
CSP, Referrer-Policy, etc.) + SH-07 (SBOM) deferred to v1.1+ —
SH-05 is M-effort new check development; SH-07 is the federation-
wide SBOM gap tracked across all siblings.

---

## §2 Priority table

| ID | Concern | One-line | Effort | Status |
|---|---|---|---|---|
| [**SH-01**](#sh-01-finding-evidence-may-carry-secrets) | Secret leak via findings | `Finding.evidence` is a dict that checks populate with response content + request headers + URL fragments. A check that captures `Authorization` header value into evidence (intentionally for `BodyEchoesSecret` — or unintentionally for a future check) writes the secret to the report. Consumer-side log + report aggregators see the raw secret. | S | ✅ closed |
| [**SH-02**](#sh-02-bodyechoes-secret-needle-in-memory) | Secret retention | The `BodyEchoesSecret` check (in `checks/security.py`) needs the secret to MATCH against response body — so the secret IS held in memory during the check. Lifecycle: how long after the check runs is it still resident? Today: until GC. Defence-in-depth: hash-compare instead of substring match. | M | ✅ closed |
| [**SH-03**](#sh-03-auth-credential-in-memory-lifecycle) | Secret retention | `_resolve_credential` reads `os.environ.get(env)`. The string is held in `BearerAuth._token` (or similar) for the AuthFlow instance's lifetime — usually until the suite finishes + GC sweeps. No deliberate overwrite. For a long-running operator session with multiple suites, secrets accumulate. | S | ✅ closed |
| [**SH-04**](#sh-04-cookie-value-sanitization) | Header injection | Twin of [RA-04](RELIABILITY_AND_ACCURACY.md#ra-04-cookie-value-sanitization). Per-call cookies → `Cookie:` header without `\r\n` validation. | XS | ✅ closed |
| [**SH-05**](#sh-05-security-check-coverage-gaps) | Harness correctness | The shipped security checks cover HSTS, X-Content-Type-Options, X-Frame-Options, BodyEchoesSecret. Missing: CSP-presence check, Referrer-Policy, Permissions-Policy, mixed-content scan, secure-cookie attribute check. Operators using mgf-apiprobe for security verification get FALSE NEGATIVES on these. | M | ⏳ v1.1+ — new check development; not a v1.0 blocker because the existing check set covers the highest-value cases |
| [**SH-06**](#sh-06-transport-tls-disable-not-surfaced-as-finding) | Operator awareness | `HttpxTransport` honours `settings.verify_tls`. If a probe-suite is configured with `verify_tls=False` (for testing self-signed dev environments), every probe runs against potentially-MITM'd traffic. The library doesn't emit a Finding for "TLS verification was disabled during this run." | XS | ✅ closed |
| [**SH-07**](#sh-07-sibling-sbom-and-vuln-scan-in-ci) | Supply chain | Same gap. | S | ⏳ v1.1+ — federation-wide SBOM workflow tracked separately |

Effort scale: XS (≤ 15 min); S (≤ 1 h); M (≤ 1 session); L
(multiple sessions).

---

## §3 Invariants we hold

The list of "closed elsewhere; do not re-open":

- **SH-INV-1** — Auth config errors raise at **suite-build
  time**; secret-resolution errors raise at **run time**
  ([`auth/flows.py:20-50`](../../src/mgf/apiprobe/auth/flows.py#L20)).
  Misconfiguration surfaces early; missing env var surfaces
  during execution.
- **SH-INV-2** — `AuthError` and `ApiprobeConfigError` are
  **distinct exception classes**. The library can be wrapped
  in different error-handling code paths (config vs runtime).
- **SH-INV-3** — Transport errors yield **synthetic Findings**,
  not raised exceptions ([`suite.py:144-166`](../../src/mgf/apiprobe/core/suite.py#L144)).
  A network outage during probing surfaces in the report,
  not as a CLI crash.
- **SH-INV-4** — Probe execution is **concurrency-capped**
  via `asyncio.Semaphore(self.settings.max_concurrency)`
  ([`suite.py:109`](../../src/mgf/apiprobe/core/suite.py#L109)).
  Cannot accidentally fan out 1000 concurrent requests at
  a provider + trip their rate limit / cause an outage.
- **SH-INV-5** — Empty credential is treated as unset
  ([`auth/flows.py:41-46`](../../src/mgf/apiprobe/auth/flows.py#L41)).
  Prevents the silent "consumer set env=MYTOKEN= for testing
  and forgot to set the value" footgun.
- **SH-INV-6** — Frozen Finding dataclass; the report
  content is immutable after generation.
- **SH-INV-7** — Findings carry `RootCause.PROVIDER_SECURITY`
  for security-relevant checks ([`finding.py`](../../src/mgf/apiprobe/core/finding.py)),
  enabling filtered reporting / alerting on security
  posture.

---

## §4 Work units

### SH-01 Finding `evidence` may carry secrets

**Why:** `Finding.evidence` is a dict that checks populate
with whatever they want — typically a small set of fields
helpful for diagnosing the finding. Examples seen in the
codebase:

```python
yield self.finding(
    ...,
    evidence={"url": response.url, "header_value": header},
)
```

A check that captures an Authorization-header value
(intentionally for `BodyEchoesSecret`, or accidentally for a
future check author) writes the raw secret to the
`evidence` field. The report consumer (a log aggregator, a
JSON file, a human reading the report) sees:

```json
{
  "code": "security.body.echoes-secret",
  "evidence": {"matched_value": "Bearer eyJhbGciOiJIUz..."}
}
```

The secret is in the report.

mgf-common's Redactor catches secret-shaped values in
formatted log records. But `Finding.evidence` doesn't go
through the Redactor — it's a structured dataclass field
that gets serialised by the JSON writer.

**Concrete output:**

- Add a `_scrub_evidence(value: Any) -> Any` helper in
  `core/finding.py` that:
  - Walks the dict / list / nested structure.
  - Applies `mgf.common.observability.redaction.Redactor.default()`
    to string values.
  - Returns a redacted copy.
- Call it in `Finding.__post_init__` so evidence is scrubbed
  at construction.
- Add a unit test:
  - Construct a Finding with
    `evidence={"matched": "Bearer secret"}`.
  - Assert the resulting `evidence` doesn't contain the raw
    secret.
- Document the scrubbing in the `Finding` class docstring +
  the check-author guide.

**Locked in by:** the unit test pins the redaction; a check
that bypasses the scrubber fails.

**Cross-references:** SC-09 (sink redaction), LG-04
(sensitive-field redaction); mgf-common's Redactor as the
reference implementation.

---

### SH-02 BodyEchoesSecret needle in memory

**Why:** `BodyEchoesSecret` check (in
`checks/security.py`) detects the case where a provider's
response body echoes the Authorization header value the
client sent — a classic information-disclosure bug.

To detect this, the check NEEDS the secret in memory to
match against. Today the implementation likely does
something like:

```python
auth_header = response.request_headers.get("Authorization", "")
if auth_header and auth_header in response.body.decode():
    yield self.finding(...)
```

The secret sits in memory during the substring-search
operation. Lifetime: at least the duration of the check;
realistically until GC of the response object. Multiple
suite runs accumulate.

A defence-in-depth shape: **hash-compare** instead of
substring match.

- Hash the header value once with a strong digest (SHA-256
  prefix).
- Scan the response body for the hash prefix (which the
  provider wouldn't emit unless they intentionally hashed
  the same value).

This catches the "provider echoes the header" case (which
is the actual bug we're looking for) WITHOUT keeping the
secret-bearing needle in memory longer than the hash
computation.

**Concrete output:**

- Refactor `BodyEchoesSecret` to:
  - Hash the Authorization header value via `hashlib.sha256(...).hexdigest()[:16]`.
  - Use `secrets.compare_digest` or constant-time `in` (no
    such built-in; a windowed search).
  - Wait — actually, the check is "does the response body
    contain the header value?" not "does it contain a hash
    of it?" The PROVIDER wouldn't hash; they'd echo the raw.
- Alternative: don't try to match; just check if the
  response body contains ANY base64-shaped or
  JWT-shaped substring of significant length, and warn.
  Loose but doesn't require holding the secret.
- This is a real design question. Defer to v0.2+ for the
  refactor; for v0.1 pin a unit test that the check works +
  document the secret-in-memory caveat in the check's
  docstring.

**Locked in by:** the v0.1 doc + test. The deeper refactor
ships in a follow-up.

**Cross-references:** mgf-common SH-03 (Redactor bounds —
similar "defensive against pathological input" pattern).

---

### SH-03 Auth credential in-memory lifecycle

**Why:** `BearerAuth.__init__` stores
`self._token = token` (or reads env at construction time).
The string lives in the `BearerAuth` instance until the
instance is GC'd — usually at the end of the suite run.

For a long-running operator session (a CI bot that runs
many suites against many providers throughout the day),
secret strings accumulate in memory:

- Multiple `BearerAuth` instances, each holding a token
  string.
- Python's string interning + the C string cache may hold
  the value beyond GC of the wrapper object.

Process-level defence:

- Overwrite the string on `aclose()` / `__del__` (Python
  doesn't actually let you zero-out string memory — strings
  are immutable + interned).
- Use `bytes` instead of `str` + zero out via
  `ctypes.memset` (heavy; controversial; not standard).
- Document the limitation: "Once read into Python str, the
  bytes may persist in memory indefinitely. For high-stakes
  contexts, run mgf-apiprobe in a short-lived subprocess
  + rotate credentials between runs."

**Concrete output:**

- Add a `Confidential` framing in the docstrings of
  `BearerAuth`, `BasicAuth`, `CookieAuth`, etc.:
  > **Memory model:** The credential string is read into
  > Python at AuthFlow construction (or first `prepare()`
  > call). It remains in memory until the AuthFlow instance
  > is GC'd. Python str interning may persist the value
  > longer. For high-stakes contexts, run mgf-apiprobe in a
  > short-lived subprocess + rotate the credential between
  > runs.
- Add a recipe in `docs/recipes/`:
  `how-to-handle-high-stakes-credentials.md` — subprocess
  pattern, env-var-cleanup, etc.

**Locked in by:** the docstring + recipe; this is a
documentation fix because Python's memory model doesn't
permit deterministic erasure.

**Cross-references:** SC-01 (secrets in vault — operator
side; this is about library-side handling); mgf-common's
vault for the storage-side answer.

---

### SH-04 Cookie value sanitization

**Why:** Twin of [RA-04](RELIABILITY_AND_ACCURACY.md#ra-04-cookie-value-sanitization).
Per-call cookie values from `AuthCredentials.cookies`
flow into the httpx request unsanitized. The SH framing is
"header-injection class on outbound."

**Concrete output:** Same fix as RA-04. Validate at the
`HttpxTransport.send` boundary.

**Cross-references:** RA-04 (twin); mgf-http SH-01 + RA-03
(parallel pattern at a different sibling).

---

### SH-05 Security-check coverage gaps

**Why:** The shipped checks in `checks/security.py` cover:

- `MissingHsts` — Strict-Transport-Security
- `MissingContentTypeOptions` — X-Content-Type-Options
- `MissingFrameOptions` — X-Frame-Options
- `BodyEchoesSecret` — request Auth header in response body

What's NOT covered (and consumers WILL ask about):

- **CSP-presence** — Content-Security-Policy header
- **Referrer-Policy** — `Referrer-Policy: strict-origin-when-cross-origin`
- **Permissions-Policy** — feature gates
- **Mixed-content** — `https://` page loading `http://` resources
- **Secure-cookie attributes** — `Set-Cookie: Secure; HttpOnly; SameSite=Lax`
- **CORS-misconfiguration** — `Access-Control-Allow-Origin: *` with credentials
- **Server-header / X-Powered-By disclosure**

A consumer who runs mgf-apiprobe expecting "security checks"
gets a partial picture. If they ALSO run a real DAST scanner
(OWASP ZAP, Burp), they're fine. If they trust mgf-apiprobe's
security findings as comprehensive, they have a false sense
of security.

**Concrete output:**

- Document the coverage gap explicitly in `checks/security.py`'s
  module docstring:
  > **Coverage note:** This module ships a SUBSET of HTTP
  > security headers. Critical gaps: CSP, Referrer-Policy,
  > Permissions-Policy, mixed-content, secure-cookie
  > attributes, CORS, server-header. mgf-apiprobe is a
  > smoke-level security harness; for a full DAST audit
  > use OWASP ZAP / Burp / similar.
- Ship `MissingCsp` + `MissingReferrerPolicy` checks in
  v0.4. Track in a follow-up paper.
- Update the project's SCOPE.md §1.x to acknowledge the
  smoke-level boundary.

**Locked in by:** the docstring sets consumer expectations;
the new checks ship in a follow-up.

**Cross-references:** SCOPE.md §1; mgf-common
SECURITY_STANDARD.md §10 (web overlays).

---

### SH-06 Transport TLS-disable not surfaced as Finding

**Why:** `HttpxTransport.__init__`
([`httpx_transport.py:60-69`](../../src/mgf/apiprobe/transport/httpx_transport.py#L60))
honours `settings.verify_tls`. If a consumer sets
`ApiprobeSettings(verify_tls=False)` (for testing against
a self-signed staging environment), every probe runs over
TLS that could be MITM'd.

The library doesn't emit a Finding for "TLS verification
was disabled during this run." Operators reading the report
have no signal that the security claims are weaker than
they'd assume.

**Concrete output:**

- Add a synthetic Finding emitted by the Suite at run-start
  if `settings.verify_tls is False`:
  - `code: "harness.config.tls-disabled"`
  - `severity: WARN`
  - `root_cause: RootCause.HARNESS_CONFIG`
  - `message: "TLS verification disabled for this suite run."`
- Document in the Settings docstring that disabling TLS
  emits this finding.

**Locked in by:** the unit test asserts the finding
appears when settings.verify_tls=False.

**Cross-references:** mgf-http SH-03 (TLS-disable
audit-log — same principle at a sibling).

---

### SH-07 Sibling SBOM and vuln scan in CI

**Why:** Same gap as the other siblings.

**Concrete output:** SBOM + `pip-audit` in CI.

**Cross-references:** SC-10, SC-18; mgf-django SH-04 +
mgf-fastapi SH-07 + mgf-http SH-06 + mgf-sqlalchemy SH-05 +
mgf-alembic SH-05 (twins).

---

## §5 Closed items

- **SH-01** — `Finding.evidence` scrubbing. Added a `__post_init__`
  on `Finding` that walks `evidence` through mgf-common's standard
  `Redactor.default()` at construction time. Secret-shaped keys
  (Authorization, X-Api-Key, password, …) and value patterns
  (Bearer / JWT / PEM) get masked before the finding ever lands
  in the report. Idempotent — re-scrubbing already-redacted
  evidence is a no-op. 6 new unit tests pin the scrub behavior
  (secret-keys, value-patterns, nested dicts, no-distortion on
  clean evidence, idempotence, empty-dict default). The previous
  Finding contract is preserved — frozen dataclass, public
  fields unchanged. (2026-05-16, commit pending.)
- **SH-02** — `BodyEchoesSecret` documented its secret-in-memory
  caveat in the class docstring under a new "Notes — Memory
  model" section. The check needs the raw Authorization
  credential in memory to scan `response.body`; a defence-in-
  depth hash-prefix refactor is deferred to v0.2+. The check
  itself never puts the secret into `Finding.evidence` (only
  `secret_length`); SH-01's scrub provides a second line of
  defence if a future maintainer accidentally adds the value.
  Existing `TestBodyEchoesSecret` tests pin correct behavior.
  (2026-05-16, commit pending.)
- **SH-03** — Auth-flow memory model documented at the module
  docstring level in `auth/flows.py` under a new "Memory model
  (SH-03)" section. Captures the in-process retention reality
  (Python str interning, immutability, deterministic erasure
  not possible), the contexts where it matters (multi-tenant
  harnesses, long-running daemons, memory-snapshot threat),
  and the right shape (process isolation + credential
  rotation). Added a recipe doc
  `docs/recipes/how-to-handle-high-stakes-credentials.md`
  walking the subprocess-per-suite pattern, the vault-backed
  short-lived-token pattern, and explicitly listing what NOT
  to do (ctypes.memset on str buffers, disabling GC). (2026-
  05-16, commit pending.)
- **SH-04** — Cookie + header CR/LF validation at the
  `HttpxTransport.send` boundary. Added two helpers:
  `_assert_no_crlf_in_headers` and `_assert_no_crlf_in_cookies`,
  called after auth-fragment merge AND before handoff to httpx.
  Either function raises `TransportError` (not a plain
  `ValueError`) so the suite's transport-error path catches it
  cleanly and yields a synthetic Finding with
  `RootCause.NETWORK` — operators see the failure in the
  report. Fails loud rather than silently stripping; a CRLF
  in a credential IS a real bug. 4 new unit tests pin the
  rejection (CRLF in header value, LF-only, CRLF in cookie,
  + the clean-value passes sanity check). Twin of RA-04.
  (2026-05-16, commit pending.)
- **SH-06** — TLS-disable surfaced as a Finding. The `Suite.run`
  method now inserts a synthetic Finding at the front of every
  report when `self.settings.verify_tls` is False:
  - `code: "harness.config.tls-disabled"`
  - `severity: Severity.WARN`
  - `root_cause: RootCause.INFRASTRUCTURE` (the closest fit
    in the existing taxonomy — harness configuration, not a
    provider or suite-logic issue; HARNESS_CONFIG would be a
    v1.1+ enum addition).
  - `evidence: {"verify_tls": "false"}`
  - Actionable `remediation` pointing operators at either the
    default (`verify_tls=True`) or the `SSL_CERT_FILE` env-var
    pattern for self-signed dev environments.
  Inserted at index 0 so JSON readers + the human reporter
  see it before any per-probe finding. Two new tests in
  `TestTlsDisabledFinding`: one positive (verify_tls=False emits
  the finding), one negative (verify_tls=True does not emit).
  (2026-05-17, P2 sweep.)

---

## §6 Notes on methodology

This first pass sampled the security-relevant files (~1500
LOC) against:

- **SC** rules from
  `mgf-standard/docs/standards/SECURITY_STANDARD.md`.
- The project's own [`DESIGN.md`](../../DESIGN.md) and
  [`docs/standards/SCOPE.md`](../standards/SCOPE.md).
- mgf-common's SECURITY_HARDENING.md as the methodology
  template.

The audit method:

1. Sample the security-sensitive subset (auth, transport,
   security checks, finding).
2. Trace credential / secret flow through the library.
3. Identify both the LIBRARY's posture AND the HARNESS's
   verification-correctness gaps.
4. Cross-reference RA + SH twin items.

Seven items + seven invariants. The library has the
broadest security surface of the federation siblings
(verification harness + outbound auth + finding content
report). Future passes should look for:

- **Check-author guidance.** As consumers write custom
  Check subclasses, their evidence-handling discipline
  matters. A check-author guide + lint patterns would
  catch SH-01-class regressions before merge.
- **Probe-config secret discipline.** Consumer-side
  YAML/Python probe configs that include credentials
  inline (anti-pattern) deserve a SCOPE.md call-out.
- **Test-mode bearer / anonymous flows.** Per RA-07 (the
  "empty value as legit auth" case), the security shape of
  intentionally-empty credentials needs a clear policy.

When an item closes, move it to §5 with the closing-commit
SHA.
