Skip to content

fix(gates 7, 25, 48, 49): a comment must not be enough to get past a gate — 47 of 65 were (#415 class) - #425

Merged
rubenvdlinde merged 5 commits into
mainfrom
fix/gates-comment-class
Aug 13, 2026
Merged

fix(gates 7, 25, 48, 49): a comment must not be enough to get past a gate — 47 of 65 were (#415 class)#425
rubenvdlinde merged 5 commits into
mainfrom
fix/gates-comment-class

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Follows #420, which fixed gate-50. This surveys all 65 gate checkers for the same defect class and fixes the four most serious instances.

What the survey found

47 of 65 gates are affected. 18 are clean.

Two instances were known — gate 19, then gate 50 in #420. This found 45 more. It was never two bugs; it is the default behaviour of every checker written before somebody thought about it, and about a third of the ones written after.

🔑 The comment that switches a gate off is the comment that admits the debt. gate-50's was a TODO naming the guard it had not written. gate-49's names the catch. gate-7's names the exception. gate-25's says "we still owe a contract test". gate-65's says "we still need to require the autoloader". Every one is the sentence a diligent author writes. A gate that reads prose can be switched off by apologising to it.

Full table, all 65 gates with per-gate evidence: fleet-board/findings/gates-comment-class.md.

The 43 affected gates this PR does not fix are filed with fixtures and verdicts, grouped by root cause: #421 (gate-45, repo-wide blast radius), #422 (false-negative half), #423 (false-positive half), #424 (string literals — 8 of which are one hole in source_scope.markup_mask).

What this PR fixes

Four gates, chosen by severity and by whether the fix could be measured and controlled rather than pattern-matched at scale.

gate-7 — no-admin-idor 🔴

gsrc — the text every guard lookup in scan_file runs against — was the raw source with only authentication-only spans blanked. Prose in a method body therefore answered "is this endpoint guarded?". One fixture, one added line:

verdict
#[NoAdminRequired] index(int $id) { return $this->service->find($id); } FAIL — 1 method(s) with NoAdminRequired + no guard
the same method with // TODO: throw new OCSForbiddenException when the caller does not own $id. PASS ← the defect

This gate's known failure mode has always been false POSITIVES, which is exactly why its silences get believed. It has already reported 0 findings across 18 apps with 167 real IDORs behind it. A false negative here is the most expensive kind in this package.

_strip_strings_and_comments gains keep_strings=True and now also handles # comments — it never did, so the repair was one alternative spelling away from being bypassed. #[ is excluded, because #[NoAdminRequired] is the single token that makes this gate look at a method at all: blanking it would not widen the gate, it would switch it off.

String literals are kept, narrowly and deliberately. A guard's evidence is often an argument that IS a string, and blanking literals in a 2,800-line checker whose known failure mode is over-reporting would trade a measured false negative for an unmeasured wave of false positives on a gate the fleet already learned to distrust. That variant is reported, not smuggled in.

gate-25 — contract-coverage 🔴

gate-19's original defect, verbatim, in the gate written as gate-19's API-layer companion. It had no suite of its own, so nothing had ever asked it to fail.

verdict
an untested #[NoAdminRequired] endpoint FAIL — 1 new public endpoint
+ a *Test.php whose only mention is // TODO: we still owe a contract test that calls $this->controller->destroy($id) ... Not written yet. PASS — all covered ←
+ a .postman_collection.json with zero items whose description reads "NOTE: we do NOT yet cover /api/things — the DELETE endpoint is untested." PASS — all covered ←

Both greens bought by a sentence admitting the debt — and an author who writes neither note gets the red. That is the gate rewarding silence.

PHPUnit evidence is now php_mask'd. Newman evidence is built from the fields that DECLARE a request (raw/path/host/url/name/method) instead of the file's bytes: JSON has no comments, and a description is a comment in JSON syntax. An unparseable collection falls back to raw bytes rather than to "not covered" — converting a malformed fixture into findings would be this change inventing failures in repos it was never measured against.

gate-48 — csrf-cochange 🔴

Path A. This file's own note already named the hole: "the cheapest way to green would have been a cosmetic edit under src/ containing the word requesttoken: exactly the prose-satisfaction #191 warns against." It was written as a hypothetical. It was not one.

Worse than one missed finding: a non-zero signal count short-circuits the caller-state check built as the mitigation for this exact shape. One comment skips the guard and its backup.

Path B. check_csrf_callers.py read raw — and it makes an affirmative claim the runner prints as a NOTE. So prose did not merely hide a finding, it manufactured a green with a sentence attached saying the code is safe. A commented-out @nextcloud/axios import granted whole-file amnesty to every axios.post in it.

Both now anchor on source_scope.script_mask — comments blanked, string literals kept, because 'OCS-APIRequest': 'true' is a header name that IS a string and method: 'DELETE' is how a mutating call is recognised at all.

gate-49 — controller-exception-translation

Both of its questions were asked of the raw method body, so it failed in both directions from one cause:

verdict
an unhandled $this->objectService->deleteObject() FAIL — 1 method(s)
+ // TODO: we should catch (DoesNotExistException $e) here ... Not done yet. PASS
a method calling only $this->renderer->toArray(), + "used to call $this->objectService->deleteObject($id) directly. It no longer does." FAIL, names index()

The removal note scored as the removed call: the better the documentation, the redder the repo.

The docblock is still read from the original text. @throws lives in a comment BY DESIGN, and masking it would trade one false negative for a fleet of false positives.

Proof

Every fix has acceptance arms, and every arm was re-run with the change reverted:

gate arms reverted, what flips what does not (labelled controls)
7 7 3 (//, /* */, #) + 1 signature error positive control, 2 anti-widening pairs
25 8 (new suite) 3 positive control, 4 "the real artefact still counts" pairs
48 2 H1 H2
49 5 G2, G3 G1, G4, G5

…plus 7 arms in check_csrf_callers' own suite, of which 2 flip and 5 are labelled controls — including the deliberate near-miss: the same TODO sentence three characters outside the paren-balanced span the gate actually searches never reached the question, and passes either way.

An arm that passes both before and after is a control, not evidence, and each suite says which of its arms is which.

🔑 One rig note worth carrying: git checkout -- <file> is not a revert once you have committed — it restores from HEAD, fix included. The first run of that last revert had all seven arms passing, which reads like seven controls and would have shipped a suite proving nothing. Re-run against origin/main, two flip. The tell was that arms I knew should flip did not. When every arm agrees, suspect the rig.

Existing suites still green: gate-7's 138 → 145 tests, test_gate7_verb_object_guards.sh 10/10, and the 45–55 acceptance matrix.

Two things the survey turned up that are not fixed here

1. The shared helper already exists — and #420 wrote a fourth dialect. lib/source_scope.py was extracted in #184/#249 for exactly this reason: offset-preserving, knows #[ is a PHP 8 attribute, knows // inside 'https://x' opens nothing, and exposes the blank_strings axis the two kinds of caller need. Three of the four fixes here use it and add no new stripper. #420's _strip_php_comments should become a call to php_mask once it lands — that is the drift the exclusion_reason.py extraction was done to prevent.

2. source_scope.markup_mask is not currently a safe destination. It has its own hole — <p>{{ '<!--' }}</p><img alt="">{{ '-->' }} blanks the whole span — and that is what makes gates 35 and 36 affected. Six checkers (37, 39, 40, 42, 43, 44) each carry their own copy of <!--.*?-->; routing them into markup_mask today would fix their comment half and leave this half armed. Harden source_scope first, then consolidate at one site instead of eight.

Deliberately not done

  • gate-7's string-literal variant — see above.
  • gate-48's redundant short-circuit. The runner's own note argues the caller-STATE question subsumes the diff question, which would make the diff-signal count deletable entirely. That changes verdicts on real PRs and belongs in its own change.
  • The other 43 affected gates. A wrong recipe repeated at scale is a defect multiplied at scale. Each needs its own anti-widening pair, because for several of them the mask that closes the false negative would delete the evidence.

Fleet consequence

Every gate-7, gate-25, gate-48 and gate-49 count in the fleet is suspect until a rerun on the fixed package — and for gate-45 the suspicion is repo-wide, because one comment in one stylesheet sets HYDRA_RM_GLOBAL_GUARD=1 and silences the gate for every file. [gate-N] PASS from before this survey is not necessarily a measurement of the code; it may be a measurement of the comments.

Also found while building the fixtures

gate-48's src/**/*.vue is a plain git pathspec, where * matches / too — so the glob requires a second slash and never matches src/Del.vue. Recorded in the suite comment; not fixed here.

… a finding

gate-50 was fixed for this in #415/#420. gate-49 has the same defect, one
gate over, and nobody looked: it asked BOTH of its questions of the RAW
method body.

  positive control  an unhandled $this->objectService->deleteObject()
                      -> FAIL — 1 method(s)                    (correct)
  false NEGATIVE    the same method, plus a TODO reading "we should catch
                    (DoesNotExistException $e) here ... Not done yet."
                      -> PASS                                  <- the defect
  false POSITIVE    a method calling only $this->renderer->toArray(), plus
                    a note "used to call $this->objectService->deleteObject
                    ($id) directly. It no longer does."
                      -> FAIL, names index()                   <- the defect

The comment that switches the gate off is the comment that admits the debt.
The comment that reddens it is the one explaining what was removed.

Body questions now anchor on source_scope.php_mask (comments AND string
contents blanked, offsets preserved), and the brace walk runs on the mask so
a `{` in a string cannot hand a method a span it does not contain. The
docblock is still read from the ORIGINAL text: @throws lives in a comment BY
DESIGN, and masking it would trade one false negative for a fleet of false
positives.

NOT a fourth hand-rolled stripper. php_mask already exists in
lib/source_scope.py and already knows `#[` opens a PHP 8 attribute and that
`//` inside 'https://x' opens nothing. Import failure exits 3, so the gate
says SKIPPED (wiring) rather than silently grading raw text again.

Arms G1-G5 in test_gate_45_to_55_acceptance.sh. With the runner change
reverted, G2 and G3 FLIP and G1/G4/G5 do not — so G2/G3 are the evidence and
the other three are labelled controls.
gate-25 is written as gate-19's API-layer companion and it inherited gate
19's ORIGINAL defect along with its shape. It had no suite of its own, so
nothing had ever asked it to fail.

  positive control   an untested #[NoAdminRequired] endpoint
                       -> FAIL — 1 new public endpoint         (correct)
  false NEGATIVE     + a *Test.php whose only mention of the method is
                     "// TODO: we still owe a contract test that calls
                      $this->controller->destroy($id) ... Not written yet."
                       -> PASS — 1 endpoint, all covered       <- the defect
  false NEGATIVE     + a .postman_collection.json with ZERO items whose
                     description reads "NOTE: we do NOT yet cover
                     /api/things — the DELETE endpoint is untested."
                       -> PASS — 1 endpoint, all covered       <- the defect

Both greens were bought by a sentence admitting the debt, and an author who
writes neither note gets the red — the gate rewarding silence.

PHPUnit evidence is now source_scope.php_mask'd (comments and string contents
blanked). Newman evidence is now built from the fields that DECLARE a request
— raw/path/host/url/name/method — instead of the file's bytes, so a
`description` is no longer a request. JSON has no comments; a description is
a comment in JSON syntax, and the discriminator there is WHICH string, not
whether it is one.

An unparseable collection falls back to the raw bytes rather than to "not
covered": converting a malformed fixture into findings would be this change
inventing failures in repos it was never measured against.

New suite test_check_contract_coverage.py, 8 arms. Reverted, three FLIP
(TODO / description / string-literal) and five do not — the positive control
and the four "the real artefact still counts" pairs, which are controls.
tests/run-helper-suites.sh discovers it with no workflow edit.
The fleet's most expensive gate has gate-50's defect. `gsrc` — the text
every guard lookup in scan_file runs against — was the RAW source with only
authentication-only spans removed, so prose in a method body answered "is
this endpoint guarded?".

Measured through the runner, one fixture, ONE ADDED LINE:

  #[NoAdminRequired] index(int $id) { return $this->service->find($id); }
    -> FAIL — 1 method(s) with NoAdminRequired + no guard      (correct)
  the same method with
    `// TODO: throw new OCSForbiddenException when the caller does not own $id.`
    -> PASS                                                    <- the defect

This gate's known failure mode has always been false POSITIVES, which is
exactly why its silences get believed — and it has already reported 0
findings across 18 apps while 167 real IDORs sat behind it. A false negative
here is the most expensive kind in the package.

`_strip_strings_and_comments` gains `keep_strings=True` and now also handles
`#` comments — which it never did, so the repair would otherwise have been
one alternative spelling away from being bypassed. `#[` is excluded, because
`#[NoAdminRequired]` is the single token that makes this gate look at a
method at all: blanking it would not widen the gate, it would switch it off.

STRING LITERALS ARE KEPT, narrowly and deliberately. A guard's evidence is
often an argument that IS a string, and blanking literals in a 2,800-line
checker whose known failure mode is over-reporting would trade a measured
false negative for an unmeasured wave of false positives on a gate the fleet
already learned to distrust. That variant is reported, not smuggled in.

7 new arms. Reverted, three FLIP (`//`, `/* */`, `#`) and one errors on the
new signature; the positive control and both anti-widening pairs pass either
way and are labelled controls. All 145 existing tests still green, and
test_gate7_verb_object_guards.sh still 10/10.

fix(gate-48): a comment is not a CSRF token, on both paths

Path A, the runner's diff-signal grep: this file's OWN note already named
the hole — "the cheapest way to green would have been a cosmetic edit under
src/ containing the word requesttoken: exactly the prose-satisfaction #191
warns against." It was written as a hypothetical. It was not one.

  a diff dropping #[NoCSRFRequired], no frontend change
    -> FAIL — @NoCSRFRequired dropped without frontend co-change
  the same diff plus ONE added line reading
    `// TODO: this call still needs a requesttoken header. Not done yet.`
    -> PASS                                                    <- the defect

Worse than one missed finding: a non-zero count SHORT-CIRCUITS the
caller-state check built as the mitigation for this exact shape, so one
comment skips the guard and its backup. The count is now taken over the
script scope of each changed file at HEAD, restricted to the lines the diff
added.

Path B, check_csrf_callers.py: every question was asked of the raw file, and
this helper makes an AFFIRMATIVE claim the runner prints as a NOTE. So prose
did not merely hide a finding, it manufactured a green with a sentence
attached saying the code is safe. A commented-out `@nextcloud/axios` import
granted whole-file amnesty to every axios.post in it.

Both now anchor on source_scope.script_mask — comments blanked, string
literals KEPT, because `'OCS-APIRequest': 'true'` is a header name that IS a
string and `method: 'DELETE'` is how a mutating call is recognised at all.

Arms H1/H2. Reverted, H1 flips PASS->FAIL; H2 (a real added header is still
a signal) passes either way and is a control.

⚠️ Found while building the fixture: the gate's `src/**/*.vue` pathspec is a
PLAIN git pathspec, where `*` matches `/` too — so it requires a SECOND
slash and never matches `src/Del.vue`. Recorded in the suite, not fixed here.
7 arms. Reverted against origin/main (NOT against HEAD — the first revert
used `git checkout --`, which restores the COMMITTED file and therefore
still contained the fix; all seven arms passed and looked like seven
controls) TWO flip: the TODO sitting INSIDE the paren-balanced call span,
and the commented-out @nextcloud/axios import that grants whole-file amnesty.

The remaining five are labelled controls in their own docstrings, including
the near-miss pair: the same sentence three characters outside the searched
span never reached the question and passes either way. Saying so is the
point — an arm that cannot fail is not proof that the gate cannot.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Local verification on this branch: tests/run-helper-suites.sh83 passed, 0 failed, 2 quarantined (both pre-existing and documented — test_gate_glob_recursion_fixtures.sh, test_gate_orphaned_capability_fixtures.sh, fixtures never authored). Plus gate-7's suite 138 → 145 tests, test_gate7_verb_object_guards.sh 10/10, and the 45–55 acceptance matrix ALL GREEN.

@rubenvdlinde
rubenvdlinde merged commit d33cd36 into main Aug 13, 2026
34 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/gates-comment-class branch August 13, 2026 01:25
rubenvdlinde added a commit that referenced this pull request Aug 13, 2026
…on removed (#430) (#431)

#425 rebuilt gate-25's Newman haystack from the raw bytes of each collection
into a newline-joined list of extracted VALUES. That correctly stopped a
Postman `description` standing in for a request. It did not update
`is_covered`, whose second Newman arm still matches JSON key syntax —

    re.search(rf'"name"\s*:\s*"[^"]*\b{method}\b', newman)

— against a haystack that is no longer JSON. The arm stopped seeing anything a
collection declares. Confirmed rather than read, on docudesk's own collections:

    AFTER  name-arm fires for method 'versions'?  False
    BEFORE name-arm fires for method 'versions'?  True

MEASURED, eighteen core apps, identical trees, only the package moving
(fa555a2 vs a316aa5, `bin/hydra-gates --full`): gate-25 went 239 -> 280, nine
apps PASS -> FAIL, and 36 of the 41 new findings had been carried by this arm
and nothing else. 25 of the 41 could not be closed by any correct app change.

Three causes, each with its own arm and its own control:

1. THE NAME ARM. Both arms now ask their question of the field that answers
   it: `_newman_evidence` tags every extracted value `<field>:<value>`, the
   name arm reads `^name:` lines, the url arm reads url-bearing lines. Its
   control is a collection where the word appears in a url and in a payload
   and in no request name — still a finding.

2. `_url_signature` DELETED PLACEHOLDERS AND JOINED THE SURVIVORS.
   `/api/pos-transactions/{id}/confirm` became `api/pos-transactions/confirm`,
   a string no correct url can contain. 16 endpoints were reported uncovered
   while the app's own collection held a request for exactly that route. Now a
   regex with placeholders as wildcard segments. Its control is a sibling
   operation on the same id — `…/{id}/confirm` covers `confirm` and leaves
   `cancel` reported, which "truncate at the first placeholder" cannot do.

3. `_ROUTE_ENTRY_RE` COULD NOT READ A NESTED ARRAY. `\[[^\[\]]*?\]` fails on
   every route declaring `'requirements' => [...]`, so 9 endpoints reached
   `is_covered` with an EMPTY url — an arm nothing can satisfy. The entry is
   now recovered by balancing brackets over a string-masked copy, so a bracket
   inside a requirement regex is not counted as structure.

TWO TIGHTENINGS COME WITH IT, BOTH MEASURED, NEITHER OPTIONAL:

  - A trailing placeholder is now a REQUIRED segment. Keeping it optional was
    unshippable next to (3): four openregister SPA page routes acquired a url
    for the first time and were immediately answered by `…/api/registers/…`.
    A parser repair that converts findings into silence is the wrong trade
    whichever way the count moves.
  - The left edge is anchored on the app base (`/apps/<id>`, a `}`, a quote,
    or the start of the path). `/registers/{id}` and `/api/registers/{id}` are
    different endpoints and the second contains the first.

And one correctness fix with zero fleet effect, pinned so it stays: a route
name registered under several urls (a `'postfix'` entry — six of the eighteen
apps do it, 13 times in zaakafhandelapp) is covered by a test for ANY of them,
not for whichever entry the parser happened to keep last.

RE-MEASURED, same protocol, third column:

    before(fa555a2)  after(a316aa5)  now
    239              280             251

vs `before`: 13 findings appear, 1 disappears. The one that disappears is
shillinq `periodClose#aiFlags` — a medial-placeholder false positive that
pre-dates #425 and is answered by `…/api/period-close/{{period_id}}/ai-flags`.
All 13 that appear were verified by hand to have no request, no matching
request name and no PHPUnit call: softwarecatalog's four `settings#get*Groups`
(a docblock route table was the only mention), openregister `ui#reports`,
`ui#configurations`, `ui#endpoints`, `ui#entities`, `ui#tables`,
zaakafhandelapp `users#me` and `resultaten#pages`, doriath `publicShell#page`,
openconnector `synchronizations#deleteLog`.

Acceptance is the two measured lists, not the count: all 16 false positives
gone, all 5 true positives still firing.

The suite goes 8 -> 21 tests. Reverting check_contract_coverage.py flips 10 of
them and leaves 11 green, so each repair has an arm that fails without it and a
control that does not.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants