Skip to content

T-0162: triage the CodeQL alerts by measuring them - #23

Merged
illodev merged 3 commits into
mainfrom
fix/codeql-alert-triage
Aug 5, 2026
Merged

T-0162: triage the CodeQL alerts by measuring them#23
illodev merged 3 commits into
mainfrom
fix/codeql-alert-triage

Conversation

@illodev

@illodev illodev commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes T-0162. Ten CodeQL alerts sat open on main, every one rated high by
its rule, and the codeql job passed on every run — it uploads results, it
does not read them.

The card said the work was "saying which are real". That turned out to be
literal: the answer came from a benchmark, not from reading the code, and it
was wrong in both directions.

js/polynomial-redos — two of four are real

Growth at 16× the input:

site growth verdict
cards/acceptance.ts ITEM dismissed, measured
records/index.ts QUERY_TOKEN dismissed, measured
docs/validation.ts link scan 217× fixed
docs/docs.ts trailing slash 230× fixed

The two real ones were worse than reading had suggested. A document body of
unclosed Markdown links took 43.6 seconds at 128KB, in the link check the
doctor runs for every document in the workspace.

  • The link target now excludes newlines and caps at 1024 characters. Both are
    true of a Markdown link independently of the performance argument. The cost
    is that a target past 1024 stops being checked — nothing local can be that
    long (POSIX caps a path at 4096), and the only things that reach it are
    data: URIs, which the scheme test already skips.
  • The trailing-slash strip is a slice loop instead of /\/+$/.

js/xss-through-dom — not exploitable, fixed anyway

React 19 replaces a javascript: URL with a throwing stub, so href={link[2]}
was never live. That is exactly the reason to move the rule: the UI renders
record bodies, a record body is written by whichever agent held the card, and a
defence that lives in a dependency's minor version is one you find out about by
losing it.

ui/src/safe-url.ts refuses any scheme outside https, http and mailto,
plus protocol-relative targets. A refused link renders as its own text — the
words a human wrote survive, the link does not.

js/regex-injection — the reverse error, and it stays open

This one looked handled: a 256-character pattern cap, an imsu flag
allowlist, a 20,000-character body cap. Three guards, none of which bounds
backtracking.

/(a+)+$/ is six characters and passes all three:

body time
24 chars 232ms
28 chars 3,717ms
32 chars 57,113ms

Two more characters is four times the work; against the 20,000-character cap it
does not finish. On the CLI that is self-inflicted. /api/v2/search?q= takes
the same query, and workfile ui --host 0.0.0.0 puts it on the network — the
configuration the codeql job's own comment calls "unauthenticated read and
write access to a repository".

Not dismissed, because it is not false. Not fixed here, because the fix is a
choice between a worker deadline, RE2 and dropping user-supplied regex.
Accepted with the measurement and tracked as T-0190.

The gate — criterion 3

An alert on a PR gets commented inline and looked at within a minute. An alert
on merged code has no messenger, which is how ten of these sat open for a week.

codeql-baseline.yml compares open alerts against a committed accepted list —
the same ratchet shape strict-baseline.json and doctor-baseline.json
already use, so debt is visible in the diff and only an unaccounted alert
breaks the build. It runs daily rather than on push: the alerts API serves the
previous analysis until the new one finishes processing, so a check that races
the upload reports the state it was called to replace. A day is the honest
latency and it is bounded, which "nothing ever looks" was not.

Keyed by rule and path, not by alert number — a number belongs to an instance
and moves with the line, which is how the same acceptance.ts pattern arrived
as a new alert on #22 after the file was edited around it.

codeql-config.yml also excludes test/fixtures/. legacy-v1/ is a snapshot
of the pre-workfile board this package migrates away from: checked in as
input, never imported, never run. Analysing it reports the bug we keep on
purpose.

Criterion 1 is left unchecked

"Every open alert is either fixed or dismissed with a stated reason." The two
search.ts ones are neither — they are accepted in a committed file with a
card. The intent is met and the letter is not, and checking the box because the
spirit is met is what T-0174 exists to catch.

Verification

pnpm run check green: 328 tests + 7 search-local, strict ratchet held at 494,
doctor 0 errors / 0 warnings.

Each fix was vacuity-checked by restoring the broken form in the built dist:
the link budget fails at 9,663ms against its 2,000ms ceiling, the folder budget
at 2,846ms against 500ms, and the scheme test on javascript:alert(1).

The two budgets are wall-clock, which T-0166 and T-0179 are both open scars
about, so the test carries the margin table rather than a bare number — and
reports the measured time on every run. The ceilings differ because the margins
do: a shared one would have left the folder case 1.4× above what it must
exceed. The "in suite" column exists because the link scan nearly doubles under
the full run, so the headroom a solo measurement would have claimed is not the
headroom there is.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SSP9wGYGj6SU4e5Nqy9McA

Ten sat open on main, every one rated `high` by its rule. The card said the
work was "saying which", and that was literal: the answer came from a
benchmark, not from reading the code.

`js/polynomial-redos`, four sites, growth at 16x the input — acceptance.ts 1x,
records/index.ts 1x, docs/validation.ts 217x, docs/docs.ts 230x. Two noise,
two quadratic, and the quadratic ones worse than reading suggested: 43.6s on a
128KB document body, in the scan the doctor runs for every document. The link
target now excludes newlines and caps at 1024 — longer than any POSIX path,
and `data:` URIs are skipped by the scheme test anyway — and the trailing
slash strip is a slice loop.

`js/xss-through-dom` in Markdown.tsx: React 19 does block `javascript:` via
sanitizeURL, so nothing was exploitable. The allowlist is ours now. A record
body is written by whichever agent held the card, and a defence that lives in
a dependency's minor version is one you find out about by losing it.

`js/regex-injection` in search.ts is the reverse error and stays open. Three
caps — 256-char pattern, imsu flags, 20,000-char body — and none of them
bounds backtracking: `/(a+)+$/` takes 57s against a 32-character body.
Accepted with the measurement, tracked as T-0190.

Criterion 3 — a new alert on merged code reaching somebody without a PR
comment — is a daily baseline job comparing open alerts against a committed
accepted list, the shape strict-baseline.json and doctor-baseline.json already
use. Daily rather than on push because the alerts API serves the previous
analysis until the new one finishes processing.

Criterion 1 is left unchecked on purpose; the card says why.

328 tests, ratchet held at 494, doctor 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSP9wGYGj6SU4e5Nqy9McA
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workfile Ready Ready Preview Aug 5, 2026 5:00pm
workfile-site Ready Ready Preview Aug 5, 2026 5:00pm

Comment thread packages/workfile/src/modules/docs/validation.ts Fixed
Comment thread packages/workfile/ui/src/components/Markdown.tsx Dismissed
…nd it

CodeQL reported `docs/validation.ts` again on the branch that fixed it, against
a different input, and it was right. The first pass bounded the link *target*
and left `\[[^\]]*\]` — the label — with the identical shape one bracket
earlier: 837ms at 32,000 `[` characters, 33.6s at 192,000. Fixing one half of
a quadratic leaves a quadratic.

The budget test now runs both shapes, repeated to a common *length* rather
than a common count: `[` is one character and `[](` is three, so counting
repetitions fed the label a body a third the size, and the ceiling that gave
5× detection on one shape gave 1.8× on the other.

The `js/xss-through-dom` alert is accepted, with the flow read out of the
SARIF instead of guessed. It is BodyEditor `event.target.value` → draft →
Inspector `savedBody` → Markdown `source` → `href`. Real, and closed by the
scheme allowlist, which CodeQL does not recognise as a sanitizer. My first
reading of it — a `document` local shadowing the DOM global in Docs.tsx — was
wrong, which is the argument for downloading the SARIF rather than grepping
for a plausible source.

The baseline job loses its `pull_request` trigger. It failed on its own first
run, correctly: the alerts API answers for `main`, so a pull request that
fixes three alerts sees them still open and reports the state it exists to
change. Daily is the honest cadence for a question only `main` can answer.

328 tests, ratchet held at 494, doctor 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSP9wGYGj6SU4e5Nqy9McA
…re we do

`js/xss-through-dom` on the Markdown `href` is a false positive: the flow is
real and the sink is closed. Dismissal is the mechanism for that, and it is
what makes the PR check honest rather than permanently red — so it moves out
of the accepted list, which is for alerts that are open on purpose.

But a reason that lives only in the Security tab is the exact failure T-0162
was opened about, so the flow goes in `safe-url.ts` next to the guard it
explains, along with the fact that it will return at a new line number the
next time that file is edited around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSP9wGYGj6SU4e5Nqy9McA
@illodev
illodev merged commit 5de47c7 into main Aug 5, 2026
13 checks passed
illodev added a commit that referenced this pull request Aug 5, 2026
PR #22 and #23 merged green across ubuntu, macos and windows on node 22 and
24, plus smoke, doctor and codeql. That is the evidence these were waiting
for, and two of them were waiting for it specifically: T-0166 and T-0179 exist
because Windows runners failed, and Windows has now passed twice.

T-0178 stays in review, because checking rather than assuming turned up the
gap it shipped admitting to. Its reachability test sets PATH to the empty
string, which every platform reaches identically — the directory loop does
nothing, so PATHEXT is split and never used. The positive lookup had no test.

The new one writes `workfile-hooks.cmd` on Windows and `workfile-hooks`
elsewhere, puts that directory on PATH, and asserts the runtime resolves;
on Windows it also asserts a bare extensionless file does not count, which is
exactly what npm does not install. Forcing the win32 branch on Linux fails it,
so the extension candidates are load-bearing.

It closes when a Windows runner has executed it, not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSP9wGYGj6SU4e5Nqy9McA
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