Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/codeql/accepted-alerts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$comment": [
"Alerts that stay open on purpose, in the shape the strict and doctor",
"baselines already use: a committed list a check compares against, so",
"debt is visible in the diff and only an unaccounted alert breaks the",
"build.",
"",
"Only for alerts that are real and not yet fixed. An alert that is a",
"false positive belongs in GitHub's own dismissal, with its reason,",
"where it stops being open at all — three were dismissed that way on",
"2026-08-05 after being measured linear. This file is the other case:",
"known, real, and waiting on a decision.",
"",
"Keyed by rule and path rather than by alert number, because a number",
"belongs to an instance and changes when the line moves — which is how",
"the same acceptance.ts pattern arrived as a `new` alert on PR #22",
"after the file was edited around it.",
"",
"An entry is a promise that somebody measured it and opened a card."
],
"accepted": [
{
"rule": "js/regex-injection",
"path": "packages/workfile/src/modules/search/search.ts",
"card": "T-0190",
"reason": "Not a false positive. `/(a+)+$/` — six characters, inside the 256-character pattern cap and the imsu flag subset — takes 57s against a 32-character body and does not finish against the 20,000-character one the search path allows. Open because the fix needs a design decision the card lays out, not because it is noise.",
"measured": "2026-08-05"
}
]
}
14 changes: 14 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: "workfile"

# Fixtures are inputs, not code. `test/fixtures/legacy-v1/` is a snapshot of the
# pre-workfile board this package migrates *away* from — it is checked in so the
# migration has something to read, is never imported and never runs, and its one
# alert (`js/stack-trace-exposure`) is a faithful record of how that old server
# behaved. Analysing it reports the bug we keep on purpose.
#
# The demo bundle is generated output; the analysis belongs on its source.
paths-ignore:
- packages/workfile/test/fixtures
- packages/workfile/dist
- packages/workfile/ui/dist
- apps/demo/public
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,5 @@ jobs:
- uses: github/codeql-action/init@v4.37.3
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-config.yml
- uses: github/codeql-action/analyze@v4.37.3
68 changes: 68 additions & 0 deletions .github/workflows/codeql-baseline.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: CodeQL baseline

# An alert on a pull request is commented on inline and gets looked at within a
# minute. An alert on code that is already merged has no such messenger, so
# twelve of them sat open on `main` for a week and the `codeql` job passed on
# every single run — it uploads results, it does not read them (T-0162).
#
# This reads them. 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.
on:
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
# Deliberately not on `pull_request`. The first version of this ran on changes
# to the accepted list, on the theory that the PR editing the list should be
# the one to check it — and 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. There is no
# query that would help: the alerts a merge will close do not close until the
# merge is analysed. Daily is the honest cadence for a question only `main` can
# answer.

jobs:
baseline:
runs-on: ubuntu-latest
permissions:
security-events: read
contents: read
steps:
- uses: actions/checkout@v7
- name: Every open alert is accounted for
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100" \
--jq '[.[] | {rule: .rule.id, path: .most_recent_instance.location.path, severity: .rule.security_severity_level, number: .number}]' \
> open.json

jq -r '.accepted[] | "\(.rule)\t\(.path)"' .github/codeql/accepted-alerts.json | sort -u > accepted.tsv
jq -r '.[] | "\(.rule)\t\(.path)"' open.json | sort -u > open.tsv

{
echo "## CodeQL alerts open on \`main\`"
echo
echo "| severity | rule | path |"
echo "|---|---|---|"
jq -r '.[] | "| \(.severity) | \(.rule) | \(.path) |"' open.json
} >> "$GITHUB_STEP_SUMMARY"

# Unaccepted: open but not in the committed list.
if comm -23 open.tsv accepted.tsv | grep -q .; then
echo "::error::CodeQL alerts are open that the accepted list does not cover"
comm -23 open.tsv accepted.tsv
exit 1
fi

# Stale: accepted but no longer open. Not a failure — the debt
# went away, which is the direction this is supposed to move —
# but the entry has to go, or the list stops meaning anything.
if comm -13 open.tsv accepted.tsv | grep -q .; then
echo "::warning::accepted entries no longer match an open alert; remove them"
comm -13 open.tsv accepted.tsv
fi

echo "every open alert is accounted for"
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
id: T-0162
title: Twelve CodeQL alerts sit open on main and nothing fails because of them
status: backlog
status: review
type: task
priority: medium
area: core
effort: M
scope: [packages/workfile/src/modules/cards/acceptance.ts, packages/workfile/src/modules/docs, packages/workfile/src/modules/records/index.ts, packages/workfile/src/modules/search/search.ts, packages/workfile/ui/src/components/Markdown.tsx]
scope: [packages/workfile/src, packages/workfile/ui, .github/workflows]
origin: [T-0157]
created: 2026-08-05
updated: 2026-08-05
Expand Down Expand Up @@ -65,6 +65,16 @@ over a whole body — which is the reason to check rather than assume.
## Acceptance criteria

- [ ] Every open alert is either fixed or dismissed with a stated reason
- [ ] The two in `Markdown.tsx` are assessed against what the UI actually renders
- [ ] A new alert on merged code reaches somebody without a PR comment
- [ ] `pnpm run check` green, doctor 0/0
- [x] The two in `Markdown.tsx` are assessed against what the UI actually renders
- [x] A new alert on merged code reaches somebody without a PR comment
- [x] `pnpm run check` green, doctor 0/0

## Activity

- 2026-08-05 16:20Z illodev@local#2cddaf94 · claimed
- 2026-08-05 16:41Z illodev@local#2cddaf94 · doing → review

## Notes

- 2026-08-05 16:41Z illodev@local#2cddaf94 — Criterion 1 is deliberately left unchecked, and the reason matters more than the box: alerts #19 and #20 (js/regex-injection in search.ts) are neither fixed nor dismissed. They are real — /(a+)+$/ is six characters, passes the 256-char pattern cap and the imsu flag allowlist, and takes 57s against a 32-character body against a 20,000-character cap — so dismissing them would be false, and fixing them needs a design decision between a worker deadline, RE2 and dropping user regex. They are accepted in .github/codeql/accepted-alerts.json with the measurement and tracked as T-0190. Checking the box because the spirit is met is what T-0174 exists to catch.
- 2026-08-05 16:41Z illodev@local#2cddaf94 — Triaged by measurement, which is what the card said the work was. js/polynomial-redos at 16x input: acceptance.ts ITEM 1x, records/index.ts QUERY_TOKEN 1x, docs/validation.ts link scan 217x, docs/docs.ts trailing slash 230x. The two quadratic ones were worse than reading suggested — 43.6s on a 128KB document body, in the scan the doctor runs for every document. Both bounded now: the link target 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. The two in Markdown.tsx were assessed against React 19, which does block javascript: via sanitizeURL — so nothing was exploitable, and the allowlist is now ours in ui/src/safe-url.ts rather than a dependency's minor version. Three false positives and the legacy fixture dismissed on GitHub with their measurements; fixtures excluded from analysis in .github/codeql/codeql-config.yml. Criterion 3 is a daily codeql-baseline workflow comparing open alerts against a committed accepted list — the same ratchet shape as strict-baseline.json and doctor-baseline.json, daily rather than on push because the alerts API serves the previous analysis until the new one finishes processing. 328 tests, ratchet 494, doctor 0/0.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
id: T-0190
title: A regex search query can hang the process, and the caps do not stop it
status: backlog
type: bug
priority: high
area: core
created: 2026-08-05
updated: 2026-08-05
---

`search` accepts a `/pattern/flags` query ([[CHG-0021]]) and compiles it with `new RegExp`. Three guards already bound it — the pattern is capped at 256 characters, flags are a subset of `imsu`, and each record body is truncated to 20,000 characters before matching. None of them bounds *backtracking*.

Measured with `/(a+)+$/`, six characters, well inside every cap:

| body | time |
|---|---|
| 20 chars | 104ms |
| 24 chars | 232ms |
| 28 chars | 3,717ms |
| 30 chars | 14,342ms |
| 32 chars | 57,113ms |

Two more characters is roughly four times the work. Against the 20,000-character body cap it does not finish.

On the CLI this is self-inflicted: your own query, your own terminal, Ctrl-C. What makes it a bug rather than a footgun is the HTTP surface. `/api/v2/search?q=` takes the same query, the rebinding guard covers cross-origin but not a request that is allowed to arrive, and `workfile ui --host 0.0.0.0` puts it on the network — the configuration the CI workflow itself describes as "unauthenticated read and write access to a repository".

This is CodeQL alerts #19 and #20 (`js/regex-injection`). They are **not** false positives, which is why they are a card and not a dismissal.

No obvious cheap fix, which is the other reason this is a card:

- **Reject nested quantifiers.** A heuristic, never complete, and it would refuse legitimate patterns.
- **Run the match under a deadline.** Node has no regex timeout; it needs a worker thread, and the match loop is currently synchronous inside an async function.
- **Use a linear-time engine.** RE2 is the correct answer and a native dependency, which this package has avoided everywhere else.
- **Drop user-supplied regex.** Honest, and removes a documented feature.

## Acceptance criteria

- [ ] A catastrophic pattern cannot hold the process past a bounded time
- [ ] The HTTP search path is bounded whether or not the CLI one is
- [ ] Whichever route is taken says what it costs the regex feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
id: CHG-0129
title: A record body can no longer stall the doctor or choose a link scheme
type: fixed
area: core
visibility: public
created: 2026-08-05
updated: 2026-08-05
---

Two scans over document bodies were quadratic. A body of unclosed Markdown
links took 43.6 seconds at 128KB in the link check the doctor runs for every
document, and a folder of path separators took 189ms at 16,000 characters in
`doc create --folder`. Both are bounded and linear now, and a test holds each
to a measured budget.

The UI also decides for itself which URL schemes a record body may put in a
link. `javascript:`, `data:`, `file:` and protocol-relative targets render as
their own text instead of becoming a link. React 19 already blocked
`javascript:`, so nothing here was exploitable — but a record body is written
by whichever agent held the card, and that defence belongs to this package
rather than to a dependency's minor version.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
id: LRN-0024
title: A static analyser reports ambiguity; only a measurement tells you which half is real
status: active
confidence: high
created: 2026-08-05
updated: 2026-08-05
---

Ten CodeQL alerts sat open on main, every one rated `high` by its rule. The
temptation in both directions is the same mistake: fix them all to clear the
tab, or dismiss them all because static analysis is noisy. [[T-0162]] said the
work was "saying which", and that turned out to be literal — the answer came
from a benchmark, not from reading the code.

Same rule, `js/polynomial-redos`, four sites, at 16× the input:

| site | growth |
|---|---|
| `acceptance.ts` ITEM | 1× |
| `records/index.ts` QUERY_TOKEN | 1× |
| `docs/validation.ts` link scan | **217×** |
| `docs/docs.ts` trailing slash | **230×** |

Two noise, two quadratic — and the quadratic ones were bad in a way reading had
not suggested: 43.6 seconds on a 128KB document body, in the scan the doctor
runs over every document in the workspace.

The reverse error was there too. `js/regex-injection` in `search.ts` looked
handled: a 256-character pattern cap, a flag allowlist, a 20,000-character body
cap. Three guards, none of which bounds backtracking. `/(a+)+$/` is six
characters and takes 57 seconds against a 32-character body ([[T-0190]]).

**A static analyser reports that a shape *can* be exploited. Whether it is
depends on the engine, the caps around it and the input that reaches it — three
things the analyser does not know and a benchmark answers in minutes.** The
measurement is also what makes a dismissal honest: "false positive" alone is an
opinion, and the same sentence with numbers in it is a claim someone can check.

Cheap enough that there is no excuse: each of these was a five-line script.
Related to [[LRN-0018]] — the gap is always narrower than it looks, and here it
was narrower in both directions at once.
19 changes: 18 additions & 1 deletion packages/workfile/src/modules/docs/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,25 @@ async function maxSequence(directory, prefix) {
* escapes are rejected with the same containment criterion the workspace
* configuration uses for its own paths.
*/
/**
* Trailing separators are stripped by slicing, not by `/\/+$/`.
*
* That pattern is unanchored at the start, so on a folder of nothing but
* separators the engine restarted the greedy run at every position and failed
* at `$` each time: 0.8ms at 1,000 characters and 189ms at 16,000, which is
* quadratic on a value that arrives from `doc create --folder` and from the
* HTTP body. The loop below is the same operation and reads as what it does.
*/
function withoutTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0 && value[end - 1] === "/") end -= 1;
return value.slice(0, end);
}

export function normalizeDocumentFolder(workspace, folder) {
const raw = normalizeRepoPath(String(folder ?? "").trim()).replace(/\/+$/, "");
const raw = withoutTrailingSlashes(
normalizeRepoPath(String(folder ?? "").trim())
);
if (!raw || raw === ".") return "";
const resolved = containedPath(workspace.paths.docs, raw);
if (!resolved) {
Expand Down
30 changes: 28 additions & 2 deletions packages/workfile/src/modules/docs/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,36 @@ function dayNumber(date) {
return Number.isFinite(timestamp) ? timestamp / 86_400_000 : null;
}

/**
* The link target is bounded, and that bound is the whole point.
*
* `([^)]+)` scanned to the end of the document on every `](` that had no
* closing paren after it, so a body made of `[](` repeated cost one full scan
* per repetition. Measured on this machine: 16.6ms at 2,000 repetitions,
* 3.3s at 32,000 and **43.6s at 128,000** — quadratic, on a document body,
* which the doctor reads for every document in the workspace. A record body is
* repository text an agent writes, so the input is not hostile in the usual
* sense; it is just text nobody thought to bound.
*
* Both halves are bounded, and the first attempt here bounded only the second
* — which the analyser then reported again, correctly, against a different
* input. `[` repeated is the label's version of the same shape: `[^\]]*` runs
* to the end of the body looking for a `]` that never comes, once per `[`.
* 837ms at 32,000 characters, where the whole scan is 59ms once the label is
* capped too. Fixing one half of a quadratic leaves a quadratic.
*
* Every bound is true of a Markdown link independently of the performance
* argument: neither half spans lines, a label is not a paragraph, and a target
* is not longer than any path a filesystem will hold. The cost is that a link
* past those sizes stops being checked. Nothing local can be that long — POSIX
* caps a path at 4096 and a component at 255 — and the only targets that reach
* it are `data:` URIs, which the scheme test below skips anyway.
*/
const LINK = /\[[^\]\n]{0,512}\]\(([^)\n]{1,1024})\)/g;

function localMarkdownPaths(document) {
const paths = [];
const pattern = /\[[^\]]*\]\(([^)]+)\)/g;
for (const match of String(document.body || "").matchAll(pattern)) {
for (const match of String(document.body || "").matchAll(LINK)) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
let target = match[1].trim().replace(/^<|>$/g, "");
if (
!target ||
Expand Down
Loading