Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,50 @@ All notable changes to MessageFoundry are documented here. The format follows

## [Unreleased]

### Added
- **`messagefoundry audit-anchor`, and `audit-verify --expected-anchor` / `--expected-anchor-file` to
check one back.** The audit hash chain links each row to its predecessor, so deleting the *newest*
rows leaves a shorter chain that still walks cleanly — `audit-verify` on its own reports OK after a
tail-truncation, which is the shape an attacker hiding what they just did leaves behind. The store
could always compare against an external anchor; nothing exposed it, so the capability was
unreachable. `audit-anchor` prints `COUNT:HEAD` (a row count plus a digest — no PHI, no secret, safe
to hold in a ticket or an object store); passing it back reports `truncated or rewritten` when the
live chain differs.
**Know what it is before you build a job on it: an EXACT point-in-time seal**, comparing the count
*and* the head hash. The head half is not redundant — an attacker who cuts the newest rows and forges
the same number of replacements restores the count and leaves a chain that walks cleanly, so the head
is the only thing that differs. The cost of that detection is that a chain which merely **grew** also
reports `truncated or rewritten`. So it seals a chain **at rest across a gap in custody**: quiesce the
engine, anchor, hold the value off-box, re-verify while the chain is still quiesced — around a
maintenance window, a database move, a backup/restore, a hand-off. Anchoring and immediately
re-verifying compares a value to itself; re-checking a held anchor against a **running** engine alarms
on every ordinary boot. For continuous coverage of a live engine the off-box log forward / tee remains
the control, and `[integrity].audit_verify_on_start` is unchanged — it is a bare walk and stays blind
to a truncated tail. ([BACKLOG #328](docs/BACKLOG.md))

### Changed
- **A PHI instance reached through a declared reverse proxy with `[security].require_mfa` explicitly
off would refuse to start on first deployment, where it previously would not have.** The
MFA-at-exposure gate derived "is this instance exposed?" from `[api].serve_ui`, a field the ADR 0143
console degrade arms rewrite **in place** earlier in the same startup. On the topology the runbooks
recommend — a loopback bind behind a declared TLS terminator, with the web console left at its
default — the auto-degrade cleared that flag first, so the gate evaluated "not exposed" and the
refusal was unreachable, while the ASVS 11.7.1 arm in the same startup classified the identical boot
as exposed. The gate now reads a single console-independent predicate (an off-loopback bind **or**
`[api].tls_terminated_upstream`), so it also fires when the console is auto-degraded, when
`serve_web_console = false` disables it outright, and when the console package is simply not
installed: the surface authenticating with one factor is the JSON operator API, which the proxy
serves either way. The `#189` dual-control advisory reads the same predicate and gains the same reach
(still warn-only).
**Who this would bite:** a deploying site that has explicitly set `require_mfa = false` on a
PHI-carrying environment behind a declared TLS terminator, under `enforcement = enforce`. **Two
remedies, both existing:** set `[security].require_mfa = true`, or set the already-shipped
acknowledgment `[security].allow_single_factor_admin_when_exposed = true`, which downgrades the
refusal to a loud audited warning. A plain loopback bind with nothing declared is **not** exposed and
is byte-identical. An **undeclared** proxy (`web_console_public_address` set, no
`tls_terminated_upstream`) deliberately still does not refuse — exposure there would be an inference —
but it no longer passes in silence: a new warning names single-factor admin directly on a PHI instance
with `require_mfa` off. ([BACKLOG #326](docs/BACKLOG.md), [ADR 0140](docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment)
- **BREAKING — an `[[alerts.rules]]` block that routes to an unconfigured transport now refuses at
startup instead of being silently ignored.** `notifier_from_settings` returned early when **no**
transport was configured, *before* the loop that cross-checks each rule's `transports` against the
Expand Down
4 changes: 2 additions & 2 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2866,7 +2866,7 @@ No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_ho

## 326. MFA-at-exposure refusal reads `serve_ui` after it is flipped off

> 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **3/10** · _quick win_. ASVS 6.3.3's admin-MFA refusal and #189's dual-control warning are both inert in the topology the runbook recommends — the ADR 0143 auto-degrade sets `settings.api.serve_ui = False` in place before `ui_exposed` and `admin_exposed` are derived from it (`messagefoundry/__main__.py`, the flip and the two derivations in one ladder), so the engine calls one instance exposed for 11.7.1 and not exposed for 6.3.3 in a single boot — but `require_mfa` defaults on and `security_loosenings()` still names the explicit opt-out on every boot; re-key `admin_exposed` on the `instance_exposed` predicate already present in the file, fix two `exposure_desc` else-branches, and settle the refuse-on-upgrade fork against `docs/CONFIGURATION.md:1439`.
> ✅ **SHIPPED 2026-08-04.** `admin_exposed` is now `instance_exposed` — an off-loopback bind **or** `[api].tls_terminated_upstream` — defined ONCE above its first consumer, from two fields no earlier arm reassigns, and shared with the ASVS 11.7.1 arm that already used it. It reads no console flag, so the ADR 0143 in-place `serve_ui = False` degrades can no longer clear an exposure refusal: the MFA-at-exposure refusal and the #189 dual-control advisory now reach a declared-proxy instance whose console is auto-degraded, explicitly disabled, or absent (arms C/D in `tests/test_cli.py`, a real-gate row in `tests/test_checks_gate_parity.py`, a shape guard in `tests/test_security_doc_drift.py`). Both `exposure_desc` else-branches name the proxy instead of `[api].serve_ui`. **Built to REFUSE, per the owner ruling of 2026-08-04 — the WARN-FIRST blockquote below is SUPERSEDED** and is being amended by a separate session, so do not read it as the shipped behaviour: there is no warning-first phase, no dated flip and no new opt-in, the refusal rides the existing `[security].enforcement` split, and the pre-existing `allow_single_factor_admin_when_exposed` acknowledgment is unchanged (with more postures to act on). A plain loopback bind with nothing declared is byte-identical. The UNDECLARED-proxy residual is deliberately still not refused — nothing was declared, so exposure would be an inference — but it is no longer silent: the ADR 0068 §8 heuristic was **measured** not to cover it (it is about the /ui cookie, and the ADR 0143 auto-degrade suppresses it in the same posture), so a dedicated arm now warns, naming single-factor admin. **Two stale claims in the body below are corrected here rather than rewritten:** the arm table's arm-A string is now `admin interface reached through a declared reverse proxy ([api].tls_terminated_upstream)`, and the `docs/CONFIGURATION.md:1437`/`:1439` citations are wrong anchors — the opt-in scoping rule lives on the `require_memory_encryption_declaration` row and the `enforcement` refuse/warn split at `:88`/`:1020`. **Two residuals are left OPEN for the owner**, recorded in the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment: the `[auth] enabled = false` startup arm still keys on the bind alone (same two-answers-in-one-startup shape, one arm over, and it needs its own hoist plus its own adjudication), and the vault-only `OFF-LOOPBACK-DEPLOYMENT.md` runbook still carries blind-spot wording this fix invalidates.

> **OWNER RULING 2026-08-04 — REFUSE OUTRIGHT. Supersedes an earlier ruling on this item that said
> WARN-FIRST with a dated flip.** No warn-first, no dated flip, no opt-in flag. The corrected
Expand Down Expand Up @@ -2993,7 +2993,7 @@ The two nearest-looking guards are neither: `tests/test_scaffold.py:51-52` asser

## 328. `audit-verify` cannot detect a truncated audit tail

> 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **3/10** · _quick win_. Both shipped verification surfaces call `verify_audit_chain()` bare (`messagefoundry/__main__.py:3596`, `pipeline/engine.py:860`) and the `audit-verify` subparser declares only `--service-config` and `--db` (`__main__.py:571-578`), so a truncated keyed chain — the residue the anchor exists to catch — reports CLEAN with no way for an operator to supply one; the remainder is a new `audit-anchor` subcommand, an `--expected-anchor` flag into the already-present `expected_anchor=` keyword, and an `[integrity]` key for the startup path, with no change to the comparison logic and no store migration. _(was 5/10 · 3/10.)_
> 🚧 **Status OPEN — Proposed 1-2 SHIPPED 2026-08-04, Proposed 3 DEFERRED.** `messagefoundry audit-anchor` (`--service-config` / `--db` / `--json`, with the same SQLite missing-DB refusal as its verify twin, so a typo'd path cannot mint an empty database and print an anchor OF NOTHING) prints `COUNT:HEAD`, and `audit-verify --expected-anchor COUNT:HEAD` / `--expected-anchor-file PATH` feeds it into the already-present `expected_anchor=` keyword — no comparison-logic change and no store migration, as filed. `docs/FEATURE-MAP.md`'s hand-maintained CLI count moved 30 to 31 with it. **Proposed 3 — the `[integrity]` startup-anchor key — is NOT built, which is why this stays OPEN.** The reason is measured, and pinned by `test_an_anchor_goes_stale_on_the_next_appended_row`: the shipped comparator is an EXACT point-in-time seal (row count *and* head hash), so a stored anchor consumed by the startup auto-verify would fire a false `integrity_drift` on essentially every restart, because any running instance writes audit rows. It needs a seal-on-stop / check-on-start design (or a monotonic-prefix comparator) before it is worth wiring, and the plumbing is a THREE-file edit — `config/settings.py`, `pipeline/engine.py`, and `api/app.py`'s `create_managed_app`, which is the only route an `[integrity]` key reaches the Engine by, and which the multi-session plan had scope-dropped. `[integrity].audit_verify_on_start` therefore remains a bare walk and still cannot see a truncated tail; that limit is now stated on its own `docs/CONFIGURATION.md` row and in ADR 0014 §16.4.2. The SQL Server and Postgres `audit_anchor` CLI tests are written and collect cleanly but have **never executed locally** (no Docker daemon) — they are CI-verified only. _(was 5/10 · 3/10.)_

> ⚠️ **AMENDED 2026-08-04 — `api/app.py` is IN scope; the multisession plan was wrong to drop it.**
> `SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md` scoped this item as "CLI + settings" and said **DROP
Expand Down
Loading
Loading