BACKLOG #1197: measure the ASVS 16.3.2 audit-flood cost, and correct the row's stale premise - #870
Conversation
…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>
|
The 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:
What it does NOT stand on, and what a later reader should check rather than assume:
|
…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>
|
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:
WHAT DID NOT, AND I AM NOT DRESSING IT UP. The full I then ran WHY THIS MATTERS LESS THAN IT LOOKS, stated as reasoning rather than evidence: this PR adds one test file under Separately: |
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_authzfield 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:
audit_all_authorization_decisionsshipsFalseTrue-- BACKLOG #1277 flipped it, ADR 0118 section 5 carries the amendmentrequire's guard isif audit_all or request.method != "GET"_GRANT_AUDIT_PERMISSIONSWhat survives is the severity sentence's core:
requirecallsaudit_permission_deniedinside the permission loop, above theaudit_allread, so denials are recorded in every configuration. No exposure is at stake anywhere in this PR.(a) The measurement
Command:
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-chainedaudit_log, driven over a real/uicookie 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 ofharness/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/loginanswers 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_daysis reserved and unenforced andmax_db_mbships at0.Stated limits. One tab in steady state; page navigations and
/ws/statsexcluded. ARM B patches the barerequire_uifactory, 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'slevel = 2field 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_wsaudits a PERMISSION denial in its loop (shipped 2026-09-03) but returns silently whenmfa_satisfiedis false, where the engine header-path gate callsaudit_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_unauthorizedevaluatesidentity.has(permission)per gated PHI property -- the same predicaterequireevaluates, differing only in granularity -- and makes no audit call (measured: zero, against a positive control of 16Permissionreferences in the same file). But the sensitive-data-access limb is not bare: exposure counts feedsummary_auditor.notepost-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 passedruff format --check .-- 1242 files already formattedmypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/'(as CI runs it) -- Success: no issues found in 283 source filespytest -qover all testpaths was killed by the harness at 29 percent after roughly 50 minutes. OneFwas visible in the progress dots at about 27 percent, but-qdefers 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-uppytest tests -xrun 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.415 passed, 3 skipped, 2 warnings in 215.92s-- 405 was the pre-existing count, plus this PR's 1010 passed in 33.71sdocs/BACKLOG.md(25 files, the realistic blast radius of the ledger edit), in two batches:310 passed in 175.61sand503 passed in 161.07s/simplifyran 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 frommount._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