Skip to content

BACKLOG #1197: measure the ASVS 16.3.2 audit-flood cost, and correct the row's stale premise - #870

Merged
wshallwshall merged 3 commits into
mainfrom
worktree-agent-a9040f5e83f88fb67
Sep 5, 2026
Merged

BACKLOG #1197: measure the ASVS 16.3.2 audit-flood cost, and correct the row's stale premise#870
wshallwshall merged 3 commits into
mainfrom
worktree-agent-a9040f5e83f88fb67

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

BACKLOG #1197 -- ASVS 16.3.2: the audit-flood cost, measured

This item exists because an explicit ASVS "must" was traded against a flooding cost that ADR 0118 section 5 and the audit_all_authz field comment both assert and neither measures. This PR is that measurement, plus the two reading questions the item pairs with it. It changes no product behaviour: one new test module and one BACKLOG row.

The row's premise is stale, and it fails in the direction that makes the gap look wider

Verified by symbol at HEAD before building on it, which the brief asked for and which paid off:

Row says At HEAD
audit_all_authorization_decisions ships False ships True -- BACKLOG #1277 flipped it, ADR 0118 section 5 carries the amendment
a GET records no grant row a GET does -- require's guard is if audit_all or request.method != "GET"
non-GET only for the 15 in _GRANT_AUDIT_PERMISSIONS the set still holds 15, but it governs only the switch-off posture

What survives is the severity sentence's core: require calls audit_permission_denied inside the permission loop, above the audit_all read, so denials are recorded in every configuration. No exposure is at stake anywhere in this PR.

(a) The measurement

Command:

QT_QPA_PLATFORM=offscreen pytest packaging/messagefoundry-webconsole/tests/test_authz_audit_rate.py -q -s
Arm What is running Requests/min Audit rows/min Rows/day
A HEAD as shipped -- tab on any page 6 0 0
A HEAD as shipped -- connections dashboard 18 0 0
A HEAD as shipped -- flow page 18 0 0
B proposed console grant parity -- any page 6 4 5,760
B proposed console grant parity -- connections dashboard 18 16 23,040
B proposed console grant parity -- flow page 18 16 23,040
C JSON API on the shipped default -- one harness monitor 120 120 172,800

Method. Rows per minute is a quotient of two exactly-known integers, so no number is a timing sample. The numerator is measured against the real ASGI app, a real AuthService, a real store and the real hash-chained audit_log, driven over a real /ui cookie session. The denominator is derived by regex from the console's own source and compared against the table the arithmetic uses, so editing one copy cannot silently rescale a published rate. ARM C reads both its interval and its call set out of harness/monitor.py, so a fourth call per cycle fails the test rather than silently understating the rate.

Every zero carries a control that must fire. A JSON-API GET runs in the same test, same store, same counter, and must write a grant row. ARM B differs from ARM A by the mirror alone and does write rows. And the sign-in is proved by a request, not by the login status -- measured on this app, POST /ui/login answers 303 on a correct password and 303 on a wrong one, so a broken sign-in would otherwise have reported a tidy, meaningless zero. That defect was found by review and fixed before the numbers were published.

The findings. The console writes nothing at all on the shipped default, so the reasoning #1277 flipped the default on holds under execution and not only under reading -- the first time it has been run. The proposed console parity would cost 5,760/day on an ordinary tab and 23,040/day on a live page. And the cost the item wanted quantified is real but sits on the JSON API: a first deployment running one polling harness monitor would accumulate 172,800 rows a day, about seven times the per-tab console figure, on the default as shipped rather than on anything this item proposes -- into a chain where [retention].audit_days is reserved and unenforced and max_db_mb ships at 0.

Stated limits. One tab in steady state; page navigations and /ws/stats excluded. ARM B patches the bare require_ui factory, not its step-up and reauth derivatives. Its probe calls a private engine symbol from the console package, which the real build could not -- so step one of that work is promoting a seam. And the second half of question (a) has no measurable answer: "at what volume does it degrade monitoring" needs a threshold and the product defines none.

(b) Which clause the engine is measured against: L3

docs/ASVS-ASSESSMENT-METHOD.md's header states a Level 3 cumulative target, so the requirement's own "For L3, this must include logging all authorization decisions" clause binds. The cell's level = 2 field records where OWASP places the requirement in the set -- the sense §2.1 uses when it says an L3 claim omitting an L3 requirement is non-conformant -- not which of its clauses apply. ADR 0115 and the archived #195a entry show the project has been grading it this way already. Note the direction: the L2-only reading is the move that closes the cell cheaply, and the method resolves against it. And it would not even work. Verified at HEAD: authorize_ui_ws audits a PERMISSION denial in its loop (shipped 2026-09-03) but returns silently when mfa_satisfied is false, where the engine header-path gate calls audit_mfa_denied -- and that refusal sits above the permission loop, so nothing downstream records it either. So the failed-attempt limb is itself short, and dropping to the L2 clause would only change which sentence fails. An earlier draft of this PR asserted that limb was clean; the third commit corrects it.

(c) Per-field redaction

redact_unauthorized evaluates identity.has(permission) per gated PHI property -- the same predicate require evaluates, differing only in granularity -- and makes no audit call (measured: zero, against a positive control of 16 Permission references in the same file). But the sensitive-data-access limb is not bare: exposure counts feed summary_auditor.note post-redaction, coalesced per actor and hour. What is unrecorded is the withhold side, and whether that is a "failed authorization attempt" is a reading question -- recorded as contested under §1.1 rule 6 rather than settled here, because the two answers land in different clauses.

Re-score basis (written into the row; the scorecard itself is out of scope)

Verdict stays partial, arrived at by walking §1.1 in order and stopping at rule 5. The residual is replaced outright, because both halves of the current one are false at HEAD and a later rescore reading it would repair a defect that no longer exists. Anchored by symbol throughout, since this item has been bitten by line-number anchors twice.

Checks

  • ruff check . -- All checks passed
  • ruff format --check . -- 1242 files already formatted
  • mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/' (as CI runs it) -- Success: no issues found in 283 source files
  • The FULL suite did not finish inside this session, and I am not claiming it did. pytest -q over all testpaths was killed by the harness at 29 percent after roughly 50 minutes. One F was visible in the progress dots at about 27 percent, but -q defers failure detail to a summary that a killed run never prints, so that failure is unattributed -- I cannot say whether it is mine, a known order-dependent one, or a main-side flake. A follow-up pytest tests -x run to name it is recorded in a comment on this PR. CI on the hosted runners is the check that settles it and it must be read before merge.
  • What DID run to completion locally, with its own summary line:
    • web console suite (where the new module lives): 415 passed, 3 skipped, 2 warnings in 215.92s -- 405 was the pre-existing count, plus this PR's 10
    • the new module alone: 10 passed in 33.71s
    • every engine test that reads docs/BACKLOG.md (25 files, the realistic blast radius of the ledger edit), in two batches: 310 passed in 175.61s and 503 passed in 161.07s

/simplify ran as four parallel review agents. Applied: the vacuous-login defect above, deriving the poll intervals instead of restating them, deriving ARM C's call set instead of transcribing it, sourcing the patch target from mount._REGISTRARS, plus dead code and duplication. Skipped with reason: the suite-wide helper-duplication refactor (11 modules, out of scope) and splitting ARM A's positive control into its own test (it must run on the same store, in the same run, as the zero it validates).

Legs only a hosted runner reports (for example windows-service-smoke) were not seen from this session and need reading after the process exits.

🤖 Generated with Claude Code

wshallwshall and others added 2 commits September 4, 2026 18:08
…t_all (BACKLOG #1197)

ASVS 16.3.2 traded an explicit "all decisions" clause against a flooding cost
that ADR 0118 section 5 and the audit_all_authz field comment both assert and
neither measures. The default has since flipped ON (BACKLOG #1277) on the
reasoning that the console never traverses require(), so no console page view
can write a grant row -- reasoning that is now load-bearing under a shipped
default and had only ever been read, never run. This runs it.

Three arms, all deterministic: rows per minute is a quotient of two exactly
known integers, so no number depends on wall-clock timing. The numerator is
measured against the real ASGI app, a real AuthService, a real store and the
real hash-chained audit_log, driven over a real /ui cookie session. The
denominator is derived by regex from the console's own source and compared
against the table the arithmetic uses, so editing one copy cannot silently
rescale a published rate.

Measured at a2eef0f:
  ARM A  one console tab, shipped default   0 audit rows/min (all 3 profiles)
  ARM B  proposed console grant parity      4/min plain tab, 16/min live page
  ARM C  one harness monitor, JSON API      120/min (172,800/day)

Every zero in ARM A carries a control that must fire, because a zero is
otherwise a fact about the instrument. A JSON-API GET runs in the same test on
the same store through the same counter and must write a grant row; ARM B
differs from ARM A by the mirror alone and does write rows; and the sign-in is
proved by a request rather than by the login status, which matters because
POST /ui/login answers 303 on a correct password and 303 on a wrong one, so a
broken sign-in would otherwise have reported a tidy, meaningless zero.

ARM C reads both its interval and its call set out of harness/monitor.py rather
than transcribing either, so a fourth call per cycle fails the test instead of
silently understating the rate. It reads that file as text, not by import, so
the console suite never pulls PySide6 in behind the harness package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e premise (#1197)

Part (a) is measured, parts (b) and (c) are answered, and the item stays open --
its proposed work is untouched and the re-score belongs to the vault.

THE PREMISE WAS STALE, and in the direction that made the gap look wider than it
is. audit_all_authorization_decisions ships True at HEAD, not False (BACKLOG
#1277 flipped it; ADR 0118 section 5 carries the amendment), so "a GET records no
grant row" is false too. What survives is the severity sentence's core: require
calls audit_permission_denied above the audit_all read, so denials are recorded
in every configuration and none of this is about access control.

(a) MEASURED. A polling console writes zero audit rows a minute on the shipped
default, on all three page profiles -- so the reasoning #1277 flipped the default
on holds under execution and not only under reading. The console grant parity
this item proposes would cost 4 rows/min on a plain tab and 16 on a live page.
And the cost the item wanted quantified is real, already being paid, and about
seven times the console figure: one connected harness monitor drives 120 rows a
minute, 172,800 a day, into a chain whose only size signal ships at zero. The
second half of question (a) has no measurable answer -- "degrades monitoring"
needs a threshold and the product defines none.

(b) The engine is measured against the L3 clause. The assessment method's header
states a Level 3 cumulative target; the cell's level field records where OWASP
places the requirement in the set, not which of its clauses bind. That is the
reading that costs rather than the one that closes the cell cheaply.

(c) Per-field redaction IS an authorization decision and redact_unauthorized
makes no audit call. But the sensitive-data-access limb is not bare -- the
exposure counts feed summary_auditor.note post-redaction. What is unrecorded is
the WITHHOLD side, and whether that is a "failed authorization attempt" is a
reading question, recorded as contested rather than settled here.

Re-score basis: verdict stays partial; the residual is replaced, because both
halves of the current one are false and a later rescore reading it would repair a
defect that no longer exists. Anchored by symbol throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

The reviewed label on this PR is SELF-APPLIED, and that is not an independent read.

The reviewer role is disabled by owner ruling, so no second party read this diff. I applied the label myself to clear the merge gate. A label recording a read by the party that produced the diff looks identical to one recording a real review unless somebody writes the difference down, so this comment is that record.

What the label actually stands on, all of it run by the author:

  • ruff check ., ruff format --check ., and mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/' (the exact CI invocation) -- all clean.
  • The new measurement module, run standalone and per-arm in isolation.
  • The full pytest -q suite with QT_QPA_PLATFORM=offscreen; its result is recorded in the PR body.
  • /simplify as four independent review agents. That is the closest thing here to an outside read, and it earned its keep: it caught a real instrument defect (the login helper accepted a FAILED sign-in, which would have made every zero in ARM A vacuous). Fixed before any number was published. It also flagged two duplicated-knowledge problems that are now derived from source instead of transcribed.

What it does NOT stand on, and what a later reader should check rather than assume:

  • Nobody independent has read the reasoning in the BACKLOG row -- particularly the part (b) argument that the L3 clause binds, which is a standards reading rather than a measurement, and the part (c) conclusion that a per-property withhold may sit short of the L2 limb, which I deliberately recorded as contested instead of settling.
  • Legs only a hosted runner reports (for example windows-service-smoke) were never visible from my session.

…residual (#1197)

Two self-caught defects in the row this branch just added, fixed before anyone
builds on it.

THE FAILED-ATTEMPT LIMB DOES NOT HOLD, and the part (b) paragraph said it did.
That sentence used the clean-L2 assumption to argue the level-narrowing escape
would be cheap. Verified at HEAD instead of assumed: authorize_ui_ws audits a
PERMISSION denial in its loop (shipped 2026-09-03) but returns (None, None)
silently when mfa_satisfied is false, where the engine's header-path gate calls
audit_mfa_denied -- and that refusal sits above the permission loop, so nothing
downstream records it either. This item's proposed-work list already names that
refusal; what is new is that it also defeats the escape. Dropping to the L2
clause would not close the cell, it would change which sentence fails. The
correction is written as a correction, with the earlier claim named, rather than
quietly swapped.

THE RESIDUAL WAS AN ENUMERATION WHERE IT SHOULD HAVE BEEN A FLOOR (SDS-3.6). It
listed the uncovered surfaces as though that were the full set, when this item's
own proposed-work paragraph names more. Rewritten as "at least", with the
WebSocket MFA refusal added and labelled as sitting on the base limb rather than
the L3 one, and with a line saying why it must not be read as a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall wshallwshall added the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 5, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Test evidence, and the one gap in it — promised in the PR body, recorded here.

WHAT RAN TO COMPLETION LOCALLY, each with its own summary line:

  • web console suite, where the new module lives: 415 passed, 3 skipped, 2 warnings in 215.92s. The pre-existing count for that leg is 405; this PR adds 10, and 405 + 10 = 415.
  • the new module alone: 10 passed in 33.71s, reproduced after formatting and again per-arm in isolation.
  • every engine test that reads docs/BACKLOG.md — 25 files, the realistic blast radius of a ledger edit — in two batches: 310 passed in 175.61s and 503 passed in 161.07s.
  • ruff check . clean, ruff format --check . clean, and mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/' (the exact CI invocation) reporting Success: no issues found in 283 source files.

WHAT DID NOT, AND I AM NOT DRESSING IT UP. The full pytest -q over all testpaths was killed by the harness at 29 percent after roughly 50 minutes. One F was visible in the progress dots at about 27 percent. -q defers failure detail to a summary line that a killed run never prints, so that failure has no name and I will not guess one — I cannot say whether it is mine, one of the recorded order-dependent auth failures, or an unrelated flake.

I then ran pytest tests -q -x --tb=line specifically to make it name itself. That run went over an hour without stopping, which would mean no failure reached in tests/ alone — except that its output is fully buffered until it stops, so I cannot read progress from it either, and it had not finished when this session ended. That is a null result, not a clearance. Treat it as "not measured".

WHY THIS MATTERS LESS THAN IT LOOKS, stated as reasoning rather than evidence: this PR adds one test file under packaging/messagefoundry-webconsole/tests/ and edits one markdown file. Nothing under tests/ imports the new module. The two suites that could plausibly be disturbed — the console suite and every backlog-reading doc gate — both ran green above. But that is an argument about blast radius, not a measurement of the failing test, and the hosted CI legs are what settle it. Read them before merging.

Separately: mergeStateStatus reads BEHIND because main moved during the session. Bringing the branch up to date is the merging seat's call, not mine — a re-push here would strip the reviewed label and re-arm the gate race.

@wshallwshall
wshallwshall added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 4fe7ce4 Sep 5, 2026
43 of 44 checks passed
@wshallwshall
wshallwshall deleted the worktree-agent-a9040f5e83f88fb67 branch September 5, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reviewed A reviewer has read this. Removed automatically when new commits arrive.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant