safe-dict: make the "never a spread" NB a gate that can go red - #43
safe-dict: make the "never a spread" NB a gate that can go red#43mdheller wants to merge 1 commit into
Conversation
safe-dict.ts closed js/remote-property-injection across the query surfaces and
ended its docstring with a rule:
NB: object spread ({ ...dict }) and object literals re-attach
Object.prototype. Use cloneDict / mergeDicts, never a spread.
That rule was a comment, and it was already violated in the same PR. #34
converted the interior of patternMatcher.ts and left the OUTPUT row as
const row: Record<string, string> = {}
for (const v of variables) row[v] = ...
keyed by pattern variable names from query text, so row['__proto__'] = v hit
Object.prototype's inherited SETTER and the write was swallowed: a column
DECLARED in `variables` and absent from every row. tsc accepted it. 430 tests
passed. It was caught in 454a2c8 by a human reading the diff, which is not a
control.
scripts/check-safe-dict.mjs is the control. It parses the guarded surfaces —
sparql.ts, cypher.ts, gremlin.ts, patternMatcher.ts, plus anything that imports
safe-dict — with the TypeScript AST (typescript is already a devDependency; no
new deps, no ESLint config introduced for one rule) and fails on:
R1 a computed-key assignment onto a normal-prototype object: `o = {}`,
Object.assign({}, ...), Object.fromEntries(...), toPlainRow(...) then
`o[expr] = v`. SYNTACTIC — it reads how the value was CONSTRUCTED, not
how it was declared. That is the point: the real regression was typed
`Record<string, string>`, so an ESLint no-restricted-syntax rule keyed on
Binding/Grounding/RRow/SafeDict would have shipped GREEN against it.
R2 any object spread (array and call spread untouched).
R3 an object literal in a Binding/Grounding/RRow/SafeDict position —
declaration, parameter default, `as` cast, `satisfies`, annotated return.
R4 coverage: the surfaces still exist, the type names still resolve, the
guarded set is non-empty.
The gate proves its own teeth. FIXTURES is a table of 13 sources that MUST be
flagged — including the verbatim pre-fix row loop, plus five evasions: dropped
annotation, `as` cast on the initializer, Object.assign({}, src), an alias
variable, and a deferred `row = {}` — and 8 that MUST stay clean, including
`constructor() {}` and a `/[(){}.,;]/` regex (this is an AST check, not a grep),
`params = {}` parameter defaults, array/call spread, and literal-key writes.
The self-test runs before every scan; gutting R1 makes the script exit 1 with
the real files clean. ts/src/safe-dict-gate.test.ts drives the same table from
`npm test`, so the gate sits on the required build-and-verify-dist check
without touching ts-ci.yml (two other lanes are editing it).
Evidence, verified locally:
· pre-fix row restored -> npm test exits 1; the gate reports
R1 ts/src/patternMatcher.ts:88 while `npm run typecheck` stays GREEN
· scanning origin/main's pre-hardening files reproduces #34's remediation
independently: sparql.ts 18, cypher.ts 9, patternMatcher.ts 4 violations
· gremlin.ts scores 0 before AND after — its bug was a READ
(properties[key]), which this gate does not cover, and says so
Known holes are listed at the top of the script rather than implied away: the
READ mode, the `in` mode, laundering across a function boundary, files outside
the guarded set, runtime setPrototypeOf/structuredClone. There is deliberately
no magic-comment escape hatch, so relaxing the rule has to show up as a change
to the rule, in the diff, under review.
safe-dict.ts's NB now names what enforces it.
|
Coordination note for #40 — a real dependency, not a nit.
#40 widens that filter to match the push filter, which already covers |
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds an enforced “safe-dict” security gate to prevent prototype-related hazards (object spread, plain object literals, and computed-key writes onto prototype-bearing objects) in query-surface code, and wires it into npm test.
Changes:
- Add an AST-based checker (
scripts/check-safe-dict.mjs) with self-test fixtures and guarded-file discovery. - Add a Node test (
ts/src/safe-dict-gate.test.ts) that asserts the checker goes red on known-bad patterns and green on current surfaces. - Document the enforcement mechanism in
ts/src/safe-dict.tsand addnpm run check:safe-dict.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.
| File | Description |
|---|---|
| ts/src/safe-dict.ts | Expands the docstring NB to explain the new enforcement gate and its scope/limitations. |
| ts/src/safe-dict-gate.test.ts | Adds test-suite enforcement for the gate (red/green + coverage assertions). |
| scripts/check-safe-dict.mjs | Implements the AST gate (rules R1–R4), guarded-file discovery, and self-test fixtures. |
| package.json | Adds check:safe-dict script to run the gate. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Resolved through a variable so this stays a runtime import of a plain .mjs script rather | ||
| // than something `tsc` tries to find types for. | ||
| const GATE_URL = pathToFileURL(join(__dirname, '..', '..', 'scripts', 'check-safe-dict.mjs')).href |
|
|
||
| test('GATE: the shipped patternMatcher output row is ACCEPTED', async () => { | ||
| const { scanSource } = await loadGate() | ||
| const src = readFileSync(join(__dirname, 'patternMatcher.ts'), 'utf8') |
| assert.ok(guarded.includes(f), `${f} is no longer in the guarded set — coverage was silently dropped`) | ||
| } | ||
|
|
||
| const violations = guarded.flatMap((f) => scanSource(`ts/src/${f}`, readFileSync(join(__dirname, f), 'utf8'))) |
| `${FIXTURES.filter((f) => f.expect === 'clean').length} must-pass fixtures all behaved.`) | ||
| } | ||
|
|
||
| if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main() |
| // Guarded-file discovery | ||
| // ───────────────────────────────────────────────────────────────────────────────────── | ||
|
|
||
| const SRC = fileURLToPath(new URL('../ts/src/', import.meta.url)) |
| export function guardedFiles(srcDir = SRC) { | ||
| const found = new Set() | ||
| for (const f of readdirSync(srcDir)) { | ||
| if (!f.endsWith('.ts') || f.endsWith('.test.ts') || f === 'safe-dict.ts') continue | ||
| if (CANONICAL_SURFACES.includes(f)) { found.add(f); continue } | ||
| const text = readFileSync(srcDir + f, 'utf8') |
|
|
||
| // ── R4: coverage assertions ── | ||
| for (const f of CANONICAL_SURFACES) { | ||
| if (!existsSync(SRC + f)) { |
| } | ||
| } | ||
| for (const [type, home] of Object.entries(TRACKED_TYPE_HOMES)) { | ||
| const path = SRC + home |
| // ── scan ── | ||
| let nodes = 0 | ||
| for (const f of files) { | ||
| const found = scanSource(`ts/src/${f}`, readFileSync(SRC + f, 'utf8')) |
| * NB: object spread (`{ ...dict }`) and object literals re-attach Object.prototype. Use | ||
| * cloneDict / mergeDicts, never a spread, to carry one of these forward. |
The rule, and the fact that it was already broken
ts/src/safe-dict.ts(this PR's base, #34) closes CodeQLjs/remote-property-injectionacross the query surfaces and ends its docstring with a rule:That rule was a comment. In the same PR it was already violated:
patternMatcher.tshad its interior converted to null-prototype groundings and its output row left askeyed by pattern variable names that arrive from query text.
row['__proto__'] = vis an ordinary assignment: it walks the prototype chain, findsObject.prototype's inherited__proto__setter, and the write is swallowed — the column stays listed invariablesand is absent from every row.tscaccepted it. The whole suite passed. It was caught in 454a2c8 by a human reading the diff, which is not a control.This PR is the control.
Which option, and why
Option 2 — a narrow AST check (
scripts/check-safe-dict.mjs), wired intonpm test.Not option 1 (ESLint
no-restricted-syntax), for two reasons, the second decisive:devDependencies(verified). Introducing eslint + typescript-eslint + a parser project + a new CI lane for a single rule is disproportionate, and it opens "so what's our style baseline?" as a side effect of a security fix.no-restricted-syntaxis a selector over syntax. It cannot know that a value is aGrounding; it can only match the annotation text. The actual regression was annotatedRecord<string, string>— notBinding,Grounding,RRoworSafeDict. A rule keyed on those four names would have shipped green against the very bug that motivated this task. That is the failure mode being guarded against, so the gate must key on the shape, not the type name.Not option 3 alone — a reflective HOSTILE_KEYS test is good and #34 already has one for the pattern-matcher row, but it only covers paths a test happens to drive and says nothing about a newly added surface. Both layers now fire (see below).
Option 2 also matches an existing, proven pattern in this repo:
scripts/check-kko-provenance.mjs+npm run check:kko+ a CI job. It usestypescript, already a devDependency — no new dependencies.What it enforces
Guarded set =
sparql.ts,cypher.ts,gremlin.ts,patternMatcher.ts, plus any file that importssafe-dict— so a new query surface is covered the moment it adopts the convention, not when someone remembers to edit a list.o = {}/Object.assign({}, …)/Object.fromEntries(…)/toPlainRow(…)followed byo[expr] = v. Literal-key writes (o['a'] = 1) are fine. Syntactic — it reads how the value was constructed, not how it was declared.[...xs]and call spreadf(...xs)untouched.Binding/Grounding/RRow/SafeDictposition — declaration, parameter default,ascast,satisfies, annotated return.Red-then-green evidence
1. Red against the pre-fix
patternMatcher.ts. Restoring the exact construction from the task:…while
npm run typecheckstays green (exit 0) on the same tree. The compiler cannot see this; the gate can.2. Red through the actual CI command.
npm testis whatts-ci.yml'sTeststep runs:3. Green on the shipped code.
4. It independently reproduces #34's whole remediation. Scanning
origin/main's pre-hardening files:sparql.tscypher.tspatternMatcher.tsgremlin.ts5. The gate's own teeth are tested.
FIXTURESis a table of 13 sources that must be flagged and 8 that must stay clean;runSelfTest()runs before every scan andts/src/safe-dict-gate.test.tsdrives the same table fromnpm test. Gutting R1 (const origin = null) produces:…with the real files clean. That is the specific defect this estate keeps hitting — a checker that prints its own success line while validating nothing — and it is closed by construction.
Evasion: what was tried, and what still works
Each of these is a must-fail fixture, so it stays closed:
const row = {}; row[k] = v→ R1. (R1 never reads the type.)ascast on the initializer —const row = {} as Record<string,string>→ R1.as Bindingon the literal — → R3 (a cast does not change the prototype).Object.assign({}, src)—cloneDictminus the null prototype → R1.toPlainRow(...)then write to the result — correct materialization, but the result carriesObject.prototype, so a later computed write is unsafe again → R1.const alias = row; alias[k] = v→ R1 follows identifier aliases.let row; row = {}; row[k] = v→ R1.What it does not catch
Listed at the top of the script rather than implied away:
props[key]off a normal-prototype object returns inherited functions (values("constructor")really does hand back the Object constructor). That wasgremlin.ts's half of the CodeQL alert, and this gate scores gremlin.ts 0 both before and after that fix (table above).ownValue()there is still only a convention. This is the largest honest hole.inmode.'__proto__' in bindingon a prototype-bearing object is not detected.{}to a helper that performs the computed write and neither end is flagged alone. In practice such a helper's parameter is typed as a tracked dictionary and R3 fires at the call site — but aRecord<string, …>parameter slips through.Object.setPrototypeOf,structuredClone,JSON.parse(JSON.stringify(d)).Coordination
fix/codeql-injection-alerts), becausesafe-dict.tsdoes not exist onmain. Merge engine 0.4.47: close two live CodeQL alerts on main — remote property injection + log forgery #34 first; this retargets tomaincleanly.ts-ci.ymlis untouched. engine 0.4.47: close two live CodeQL alerts on main — remote property injection + log forgery #34 and fix: widen the ts-ci PR filter to match its own push filter; consume contractViolations #40 are both live in that file.ts-ci.ymlalready runsnpm test, andnpm testglobsts/src/*.test.ts, sots/src/safe-dict-gate.test.tsputs the gate on the requiredbuild-and-verify-distcheck with zero workflow conflict.package.jsongains one additivecheck:safe-dictline; nlq: close the declared-unenforced gap inside the declared-unenforced guard (format) #37 and fix: widen the ts-ci PR filter to match its own push filter; consume contractViolations #40 do not touch that file.ts/distunchanged —safe-dict.tsis not re-exported fromindex.ts, and the change is a docstring;npm run buildproduces no drift.package-lock.jsonstill says0.4.46whilepackage.jsonsays0.4.47.npm installre-syncs it. Left alone so it does not ride in on an unrelated PR — worth a line in engine 0.4.47: close two live CodeQL alerts on main — remote property injection + log forgery #34.safe-dict.ts's NB now names what enforces it, and what does not.