Skip to content

fix(gates): harden markup_mask's delimiter scope, then close the string-literal-as-evidence axis in 16 gates (#424) - #438

Merged
rubenvdlinde merged 4 commits into
mainfrom
fix/424-string-literal-evidence-library
Aug 13, 2026
Merged

fix(gates): harden markup_mask's delimiter scope, then close the string-literal-as-evidence axis in 16 gates (#424)#438
rubenvdlinde merged 4 commits into
mainfrom
fix/424-string-literal-evidence-library

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Closes #424.

Merged with main@73d496a and everything re-measured. main moved four
times under this branch (#434, #436, #435, #437). A finding count is a
reading of (tree × package) and the package half moved, so every number below
was taken again against 73d496a; the earlier c26f9a3 figures are not
comparable and are not reported. The #437 resolution is in §7.

Order of operations — harden FIRST, consolidate SECOND

The issue is explicit that the obvious consolidation is currently the wrong
move, and the agent who found it said why: "Routing the six private
<!--.*?--> copies into markup_mask today would fix their comment half and
leave the delimiter half armed."

So, in two separate commits, in this order:

  1. 699b14d — harden source_scope's comment scope, prove gates 35 and 36
    flip on the issue's fixture, then redirect the seven private copies onto
    the hardened one.
  2. 73f1110 — close the remaining string-literal axis (gates 10, 11, 13,
    18, 50, 53).

Within commit 1 the hardening lands above the redirection in the file, so
there is no state in which a checker calls mask_html_comments while
mask_html_comments still has the hole.


1. The delimiter hole, reproduced before it was fixed

Measured on origin/main@c26f9a3, the issue's fixture verbatim:

markup_mask("<p>{{ '<!--' }}</p>\n<img alt=\"\" src=\"/a/avatarUrl.png\">\n<p>{{ '-->' }}</p>")
  -> "<p>{{ '            \n                                   \n          ' }}</p>"

Gates 35 and 36 over a .vue carrying that shape around a live
<img alt=""> and a live tabindex="3":

pre-fix library post-fix library
gate-35 img-alt-empty-only 0 findings — green over live markup 1
gate-36 tabindex-positive 0 findings 1

_HTML_COMMENT is replaced by html_comment_spans(), a scanner that knows a
quoted attribute value, a Vue {{ … }} interpolation and a
<script>/<style> raw-text body are not comment scope.

Deliberately not relaxed, and stated in the code so the next reader does
not mistake them for holes:

  • <!-- in a text node still opens a comment even between quotes — quotes
    carry no meaning in text and <p>'<!--'</p> really is a comment to a browser.
  • An unterminated {{, quote or tag falls back to "this was ordinary text"
    rather than swallowing the file. Over-blanking is the failure mode this
    module exists to remove; source_scope's header already records one instance
    (the <template>-nesting bug that dropped a real <NcSelect>).

_HTML_COMMENT stays in the module as the mutation control and nothing
masks with it.


2. Every arm labelled EVIDENCE or CONTROL, by measurement

I ran each suite a second time against a mutated library. The labels below are
what came back, not what I intended:

suite mutation EVIDENCE (red) CONTROL (green both ways)
test_source_scope.py::TestHtmlCommentDelimiterScope html_comment_spans = _HTML_COMMENT.finditer 5 8
gate-8 test_check_unsafe_auth_resolver.py php_mask(src) without blank_strings 2 1
gate-59 test_check_unclosable_gate.py anchor keeps string contents 3 1
gate-64 test_check_apphost_autoload_prelude.py has_prelude/has_load_app with no anchor 3 2
gate-10 test_check_initial_state.py starts_in_code -> True 3 2
gate-11 test_check_admin_router.py starts_in_code -> True 3 1
gate-11 (second mutation) brace walk back on the string-preserving mask 1 4
gate-53 test_check_manifest_crossref.js js_scope restored to the two-line regex 2 rest
gate-18 test_check_notification_dialect.py the json.dumps blob scan, kept in-file 2 8

Three arms I had labelled CONTROL and had to relabel after measuring — the
measurement is the point of doing it:

  • gate-59 test_a_read_quoted_inside_a_string_is_not_a_read_either went red.
    The pre-fix gate read the sentence as a real read, found no write, and
    reported an unclosable gate the app has no code to close.
  • gates 10/11 "the real finding beside that prose is still reported" — the
    count assertion is evidence, only the survival assertion is a
    control. Split into two arms so each label matches its measurement.
  • gate-53 'GlobPage' resolves — a control, not evidence: that entry sits
    before the phantom opener and resolved either way.

3. Fleet before/after — and why a table of zeroes is not the answer

Six repos, shallow at development: procest, opencatalogi, openregister,
softwarecatalog, docudesk, larpingapp.

Package half: 73d496a vs this branch.

Real-world delta: zero on every gate. Two rows are non-zero and unchanged —
gate-11 (5/1/1/3/1/2) and gate-7 (27/10/8/1/17/1, which I do not touch).

⚠️ A table where every cell agrees is what a dead rig looks like, so:

  • gate-7's 64 findings across the six repos are the loudest proof the
    harness is reading real trees rather than empty file lists.
  • markup_mask over procest's 231 real .vue files yields 5,294 open tags
    and is byte-identical before and after.
  • gate-50 is alive: opencatalogi 4 findings, softwarecatalog 47
    demotion notes
    , docudesk 1, across 753 (Controller|Service)*.php
    files, zero stderr on both arms — and unchanged against a base that now
    includes fix(gate-50): the guard window crossed method boundaries (#429) #437's method-span clip.
  • The heredoc change has a subject: 5 of 2,481 fleet PHP files contain a
    heredoc, 8 openers in total. A 0 -> 0 over files containing none would have
    been a check with no subject, not a check that passed.

Two-directional control on a reconstructed pre-fix tree. Inject the issue's
delimiter pair into the template of every real component, then count the
elements each mask still lets a gate see:

repo files tags (untouched) tags PRE tags POST PRE lost POST lost
procest 231 5294 2306 5756 2988 -462
opencatalogi 93 3296 2174 3482 1122 -186
openregister 205 9524 7455 9934 2069 -410
softwarecatalog 73 2884 2266 3030 618 -146
docudesk 72 1610 1094 1754 516 -144
larpingapp 5 73 65 83 8 -10
7321

7,321 real elements would have been invisible to gates 31/32/35/36/37/39/
40/42/43/44 on live fleet markup. The post-fix column loses none: every
negative number is exactly 2 × files — the two injected <p> elements
themselves (231×2=462, 93×2=186, 205×2=410, 73×2=146, 72×2=144, 5×2=10). That
arithmetic identity is itself a check that the harness measured what it claims.

Where the heredoc port bites. php_mask compared over all 2,481 fleet PHP
files, old vs new: 2 files differ in the default mask, 5 in the
structural one. Both default-mode differences are the same real bug being
fixed — CSS inside a heredoc, where # was read as a PHP comment and blanked
the rest of the line
:

openregister/lib/Controller/GraphQLController.php:231     #graphiql { height: 100vh; }
openregister/lib/Service/Reporting/HtmlReportWriter.php:217   color: #1f1f1f;

Note the { in the first: previously swallowed by a comment that never opened;
now visible in the default mask and correctly blanked in the structural one, so
the brace walk is right either way.


3b. Three QUADRATIC scans, found only because I timed at three sizes

A sibling PR in this batch shipped a HIGH py/redos, so I timed every pattern
I added or grew. A single measurement would have shown nothing — all three
look instant at one size:

scan before (N = 200 / 600 / 1800) ratio after ratio
_skip_tag on '<a b="' * N 12.6 / 134 / 1190 ms ×94 0.45 / 1.30 / 3.20 ms ×7.1
html_comment_spans on '{{ ' * N 0.16 / 0.60 / 3.47 ms ×22 0.09 / 0.35 / 0.97 ms ×10.6
has_prelude on 'registerAutoloading( ' * N 41.6 / 388 / 3038 ms ×73 0.34 / 1.23 / 5.55 ms ×16.5

A 9× input should cost about 9×. The first extrapolates to roughly three
hours on a 1 MB file
, and a gate that times out is a gate that did not run —
this programme's own failure mode arriving by a new route.

None is exponential backtracking, so py/redos would not have flagged any of
them; two are my own new code and one ([^)]*) I copied from a shape already
on main. All three fixes are "stop rescanning": a tag cannot contain an
unquoted < so _skip_tag aborts there; one missed }} settles the question
for the whole remainder; and the parenthesis walk is a single-pass table
instead of a fresh walk per call site.

CodeQL: 0 open alerts on this branch. Positive control on the query — the
same call returns 2 alerts repo-wide on main, so the empty list is a real
clean rather than a broken query.


3c. Default paging really does hide checks

Verified on this PR: /commits/<sha>/check-runs reports total_count=34 and
returns 30 without ?per_page=100. The four in the dropped tail:

Analyze (actions)
Analyze (javascript-typescript)
Analyze (python)
The spec-coverage threshold can fail a run

Three of the four are the CodeQL analyses — the exact check that caught the
sibling ReDoS. Read properly: 16 success, 17 skipped, 0 failed.


4. What each gate now does

gate axis fix
8 unsafe-auth-resolver FP php_mask(blank_strings=True). Nothing it matches is ever a string, and blanking also repairs the brace walker, which previously counted a { written inside a literal as a block delimiter.
10 initial-state FP Two masks, one coordinate system + source_scope.starts_in_code(). The mask cannot blank contents — the name in getAttribute('data-x') and the data-requesttoken exemption are read out of that literal — so the ANCHOR answers "is the expression code?" instead.
11 admin-router FP Same. The route-object brace walk moves to the anchor too, so a } inside a literal no longer truncates the object the anti-widening guard is read out of.
13 modal-isolation FP The runner's inline PYMI carried a fourth private <!--.*?--> and no string awareness. Now scopes to vue_markup_mask — the SFC's rendered template — which is the question the rule actually asks. Not a weakening: an inline modal is by definition written in the template.
18 notification-dialect FP Tokens were matched against json.dumps(rule), so a rule whose own description warned against the legacy dialect was reported as it, three times. Now walks the structure: three KEY tokens against keys, @self. against machine values, per-locale/documentation fields excluded.
35, 36 FN the delimiter hole
37, 39, 40, 41, 42, 43, 44 FN private <!--.*?--> copies → mask_html_comments
50 security-config-fail-mode drift #420's hand-rolled _strip_php_comments deleted; the block calls php_mask.
53 effective-manifest-crossref FP and silent FN stripJsComments was two regexes with no string awareness, so the /* in glob: '/*.vue' opened a block comment that deleted later registry entries — and when the swallowed span was brace-unbalanced, parsed: false made the caller skip the cross-reference check entirely, a false GREEN. New scripts/lib/js_scope.js is the node port of source_scope.js_comment_mask.
59 unclosable-gate FN Anchored call site; the config KEY is still read out of the literal, because that is the evidence.
64 apphost-autoload-prelude FN Same split. strip_comments()#184's own state machine — now delegates to php_mask, removing a second PHP comment dialect.

Also consolidated, same class, one line: check_orphaned_write_capability's
<!--.*?--> over info.xml.

On the new js_scope.js

The node checkers had no shared scope module, so gate-53's fix would have been
a third private copy. Instead it is a port with a drift test:
test_js_scope.js shells out to source_scope.py --mask js-comments and
asserts the two are byte-identical over a corpus and over this package's
own .js sources. That is the trade
test_source_scope.py::TestSharedWithGate19 already makes — two copies proven
equal are a maintenance cost, two copies that might differ are a defect.

check_notification_dialect had no helper suite at all; this adds one with
14 arms, including the pre-#424 blob scan kept in-file as the mutation control.


5. Residuals — stated rather than left looking closed

  1. gate-64's class_exists_targets / openregister_probes are not
    anchored.
    openregister_probes builds its text by header + body string
    concatenation, which destroys offsets, so threading an anchor through it is
    a restructure, not a parameter. The axis String literals count as evidence in 16 gates — and 8 of them are ONE hole in source_scope.markup_mask #424 names for gate-64 (a quoted
    prelude closing the gate) is closed; a quoted
    class_exists('OCA\OpenRegister\AppHost\X') inside prose can still produce
    a non-blocking NOTE.
  2. gate-64's BOOTSTRAP_REF still matches inside strings, on purpose. The
    quoted FQCN form is documented evidence, so anchoring it would lose real
    findings. That is 12 gates where a comment MANUFACTURES a finding (#415 class, false-positive half) #423's axis, not this one.
  3. check_no_admin_idor._strip_strings_and_comments (gate-7) is NOT
    consolidated, and this is a trade, not an omission.
    It handles
    heredocs, which php_mask does not. Routing gate-7 onto php_mask
    today would drop heredoc handling from the fleet's highest-yield security
    gate to remove a duplicate — the wrong side of that trade. The right fix is
    to teach php_mask heredocs and then consolidate, which widens the blast
    radius to gates 5, 8, 50, 59 and 64. fix(gates 7, 25, 48, 49): a comment must not be enough to get past a gate — 47 of 65 were (#415 class) #425 is also live on gate-7.
  4. gate-53's kind: lookup uses a fixed 400-character window. Comments are
    now blanked in place rather than collapsed to one space, so a long comment
    between a registry key and its kind: could push kind out of that window.
    No instance in the fleet fixtures; the window was already a heuristic in
    both directions.

6. Suites — including a false baseline I caught and corrected

🔴 My first baseline was contaminated by my own edits and I had written it up
as a pre-existing failure.
I started run-helper-suites.sh in the background
on a fresh main clone and then began editing source_scope.py in that same
working tree
. The run takes ~40 minutes, so
test_gate_license_triangle_scope.sh executed after my half-finished edits
landed and reported:

FAIL — control: --require-full-coverage did NOT fail a run whose only gap is
       gate-28 (status 98) — the coverage requirement is inert here

Re-run on a pristine, untouched main clone: passed: 11, failed: 0. Not
pre-existing. Mine.

The tell was in the text and I nearly walked past it: the passing and failing
messages carry the same status number (98) and differ only in the verb —
"DID fail" vs "did NOT fail". A matching number is not agreement.

A background suite run and an edit session cannot share a working tree. The
run reads each file at the moment it reaches it, not at the moment it starts,
so "I started it before I edited" is no defence.

So the numbers below are CI's, not my machine's — the
hydra-gates package / Package invariants job runs the same
run-helper-suites.sh in a pristine checkout nothing else is writing to, which
is exactly the property my local run lacked:

ref run passed quarantined failed
main@c26f9a3 31673742837 83 2 0
main@5543c2d 31682298199 84 2 0
main@73d496a 31683550349 CANCELLED — no verdict
this PR, merged head ebfa5d3 31684671148 86 2 0

Every digit accounted for. 73d496a adds no new suite file — it only
modifies test_gate_45_to_55_acceptance.sh — so its tally is 5543c2d's 84.
This PR adds exactly two files (test_check_notification_dialect.py,
test_js_scope.js), which the runner auto-discovers. 84 + 2 = 86, which is
what CI measured. Nothing disappeared in either direction.

⚠️ main@73d496a's own run was CANCELLED, so it is not usable as the
baseline
— a cancelled job is no verdict. The comparison therefore goes
through 5543c2d plus a file-level diff rather than through a run that never
finished.

test_gate_45_to_55_acceptance.sh (which covers gate-50) is ALL GREEN on
the merged head, including #437's own new arms.

Working notes and every number:
/home/rubenlinde/fleet-board/findings/gates-424-library.md

🤖 Generated with Claude Code


7. The #437 merge — what I kept and what I moved

#437 reworked gate-50's window inside the runner while this branch was
converting the same block off its private _strip_php_comments. Both changes
are right, so both survive:

  • #437's _method_spans stays. The guard window is still clipped at the
    enclosing method's brace, and its acceptance arms pass on the merged head —
    including "a } in a string or heredoc does not truncate the method span",
    which is the arm that proves the capability moved rather than vanished.
  • _strip_php_comments is gone. The structural copy it fed is now
    php_mask(src, blank_strings=True); the guard-search copy is php_mask(src).

php_mask had to learn heredocs first. #437 had added heredoc handling
to the private copy, and the library did not have it. Deleting the copy without
moving that capability would have been a capability loss dressed as a
cleanup
— precisely the shape this programme exists to stop. So php_mask
now understands heredocs and nowdocs, which is a strict correctness gain for
all five of its gates (5, 8, 50, 59, 64): until now a <<<SQL body was parsed
as code, so a // or # inside it blanked a line, an apostrophe opened a
literal that ran to the next stray quote, and a { mis-balanced every brace
walk built on the mask. Nine new arms, four of them mutation-checked EVIDENCE.

A pleasant side effect: the reason I had recorded for not consolidating
gate-7 was that its private stripper handles heredocs and php_mask did not.
That objection is now gone, so gate-7 is a clean follow-up rather than a trade.

test_gate_45_to_55_acceptance.sh — which covers gate-50 — is ALL GREEN on
the merged head.

Conduction Release Bot added 4 commits August 13, 2026 09:47
…seven private copies (#424)

STEP 1 — HARDEN. source_scope treated `<!--` as a comment opener wherever it
appeared. `<p>{{ '<!--' }}</p>` … `<img alt="">` … `{{ '-->' }}` blanked the
markup between them and gates 35/36 went green over live markup. Replaced the
`<!--.*?-->` regex with html_comment_spans(), a scanner that knows a quoted
attribute value and a Vue {{ }} interpolation are not comment scope, and that
<script>/<style> bodies are raw text. Unterminated openers fall back to 'this
was text' rather than swallowing the file — over-blanking is the failure mode.

STEP 2 — CONSOLIDATE, only after step 1. Six a11y checkers (gates 37, 39, 40,
42, 43, 44), php_template_scope (gate 41) and check_orphaned_write_capability
each carried their own copy of the same regex with the same hole. All eight now
call source_scope.mask_html_comments.

Also closes the string-literal-as-evidence axis in three PHP gates:
  gate-8  php_mask(blank_strings=True) — nothing it matches is ever a string,
          and blanking also repairs the brace walker.
  gate-59 two masks, one coordinate system: the CALL from a strings-blanked
          anchor, the config KEY from the string-preserving text.
  gate-64 same split; strip_comments() (the #184 state machine) now delegates
          to php_mask, removing a second PHP comment dialect.

Every arm labelled EVIDENCE or CONTROL by measurement, not by assertion.
…, 13, 18, 50, 53 (#424)

gate-10 / gate-11  TWO MASKS, ONE COORDINATE SYSTEM. Both read evidence out of
  a literal — the attribute name in getAttribute('data-x'), the '/settings'
  path, the import specifier — so blanking string contents would delete the
  evidence. Instead the pattern runs on the string-preserving mask and
  source_scope.starts_in_code() asks the ANCHOR whether the match START is
  code. gate-11's route-object brace walk moves to the anchor too, so a '}'
  inside a literal stops truncating the object the anti-widening guard reads.

gate-13  the runner's inline PYMI masked comments with a FOURTH private
  <!--.*?--> and never masked literals, so 'use <NcDialog> …' in <script>
  reported an inline modal. Now scopes to vue_markup_mask — the SFC's rendered
  template — which is the question the rule actually asks. Not a weakening: an
  inline modal is by definition written in the template.

gate-18  tokens were matched against json.dumps(rule), so a rule whose own
  description WARNED AGAINST the legacy dialect was reported as it, three
  times. Now walks the structure: three KEY tokens against keys, '@self.'
  against machine values, with per-locale/documentation fields excluded. Gate
  had NO helper suite; test_check_notification_dialect.py adds 14 arms.

gate-50  #420 hand-rolled _strip_php_comments inside run-hydra-gates.sh, a
  fourth PHP comment dialect. Deleted; the block calls php_mask. Verified
  behaviour-preserving over 2481 fleet PHP files (one difference, and it is
  php_mask being right: a CSS /* inside a string is not a comment).

gate-53  stripJsComments was two regexes with no string awareness, so the /*
  in glob: '/*.vue' opened a block comment that deleted later registry entries
  — and, when the swallowed span was brace-unbalanced, made parsed:false skip
  the whole cross-reference check silently. New scripts/lib/js_scope.js is the
  node port of source_scope.js_comment_mask, asserted BYTE-IDENTICAL to it
  over a corpus plus this package's own .js sources.
…move its heredoc handling into php_mask

#429 (#437) reworked gate-50's window inside the runner while this branch was
converting the same block off its private _strip_php_comments. Resolved so
BOTH survive:

  * #429's _method_spans stays, and the runner still clips the guard window at
    the enclosing method's brace.
  * _strip_php_comments is gone. The structural copy it fed is now
    php_mask(src, blank_strings=True); the guard-search copy is php_mask(src).

#429 had added HEREDOC handling to the private copy, which php_mask lacked.
Deleting the copy without moving that first would have been a capability loss
dressed as a cleanup, so php_mask learned heredocs and nowdocs — a strict
correctness gain for gates 5, 8, 50, 59 and 64, which until now parsed a
<<<SQL body as code (a // inside it blanked a line, an apostrophe opened a
literal that ran to the next stray quote, a { mis-balanced every brace walk).
Nine arms, four of them mutation-checked EVIDENCE.

Also fixes three QUADRATIC scans this branch introduced or inherited, found by
timing at three input sizes rather than one:

  _skip_tag              '<a b="' x N  ->  12.6 / 134 / 1190 ms  (x94)
  html_comment_spans     '{{ ' x N
  has_prelude/loadApp    'registerAutoloading( ' x N -> 41 / 388 / 3038 ms

A tag cannot contain an unquoted '<', so the scan now aborts there; one missed
'}}' settles the question for the whole remainder; and the paren walk is a
single-pass table instead of a rescan per call site. All paths now measure
x5-x17 for a 9x input.
@rubenvdlinde
rubenvdlinde merged commit 52146a9 into main Aug 13, 2026
34 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/424-string-literal-evidence-library branch August 13, 2026 09:15
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.

String literals count as evidence in 16 gates — and 8 of them are ONE hole in source_scope.markup_mask

1 participant