safe-dict: make the "never a spread" NB a gate that can go red - #43
Conversation
|
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 |
There was a problem hiding this comment.
Rejecting — __dirname is defined here. package.json is "type": "commonjs", and the suite runs as node --import tsx --test ts/src/*.test.ts, so tsx transpiles this .ts file to CommonJS where __dirname exists. It is used in this exact file (three sites) and the gate test passes 4/4 (7/7 at #45). The ESM concern applies to .mjs files, and scripts/check-safe-dict.mjs — which IS ESM — already uses fileURLToPath(new URL(..., import.meta.url)) rather than __dirname. No runtime break.
|
|
||
| test('GATE: the shipped patternMatcher output row is ACCEPTED', async () => { | ||
| const { scanSource } = await loadGate() | ||
| const src = readFileSync(join(__dirname, 'patternMatcher.ts'), 'utf8') |
There was a problem hiding this comment.
Rejecting — __dirname is defined here. package.json is "type": "commonjs", and the suite runs as node --import tsx --test ts/src/*.test.ts, so tsx transpiles this .ts file to CommonJS where __dirname exists. It is used in this exact file (three sites) and the gate test passes 4/4 (7/7 at #45). The ESM concern applies to .mjs files, and scripts/check-safe-dict.mjs — which IS ESM — already uses fileURLToPath(new URL(..., import.meta.url)) rather than __dirname. No runtime break.
| 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'))) |
There was a problem hiding this comment.
Rejecting — __dirname is defined here. package.json is "type": "commonjs", and the suite runs as node --import tsx --test ts/src/*.test.ts, so tsx transpiles this .ts file to CommonJS where __dirname exists. It is used in this exact file (three sites) and the gate test passes 4/4 (7/7 at #45). The ESM concern applies to .mjs files, and scripts/check-safe-dict.mjs — which IS ESM — already uses fileURLToPath(new URL(..., import.meta.url)) rather than __dirname. No runtime break.
| `${FIXTURES.filter((f) => f.expect === 'clean').length} must-pass fixtures all behaved.`) | ||
| } | ||
|
|
||
| if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main() |
There was a problem hiding this comment.
Not a live risk, so keeping it — though the hardening is fair. The gate's CI enforcement is ts/src/safe-dict-gate.test.ts, which imports scanSource/guardedFiles directly via loadGate() and runs under npm test; it does not depend on main() firing. Separately, npm run check:safe-dict (node scripts/check-safe-dict.mjs) empirically does run main() and scans — Node resolves process.argv[1] to the same absolute path as fileURLToPath(import.meta.url) for a directly-invoked script, so the guard matches. Comparing pathToFileURL(process.argv[1]).href to import.meta.url is a reasonable belt-and-suspenders; it would not change today's behavior.
| // Guarded-file discovery | ||
| // ───────────────────────────────────────────────────────────────────────────────────── | ||
|
|
||
| const SRC = fileURLToPath(new URL('../ts/src/', import.meta.url)) |
There was a problem hiding this comment.
Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.
| 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') |
There was a problem hiding this comment.
Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.
|
|
||
| // ── R4: coverage assertions ── | ||
| for (const f of CANONICAL_SURFACES) { | ||
| if (!existsSync(SRC + f)) { |
There was a problem hiding this comment.
Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.
| } | ||
| } | ||
| for (const [type, home] of Object.entries(TRACKED_TYPE_HOMES)) { | ||
| const path = SRC + home |
There was a problem hiding this comment.
Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.
| // ── scan ── | ||
| let nodes = 0 | ||
| for (const f of files) { | ||
| const found = scanSource(`ts/src/${f}`, readFileSync(SRC + f, 'utf8')) |
There was a problem hiding this comment.
Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.
| * NB: object spread (`{ ...dict }`) and object literals re-attach Object.prototype. Use | ||
| * cloneDict / mergeDicts, never a spread, to carry one of these forward. |
There was a problem hiding this comment.
Fair — the wording over-claims. { __proto__: null } is exactly the counterexample, and the convention here is specifically about plain {} / object literals used as untrusted-key dictionaries re-attaching Object.prototype. I'll tighten the phrasing to "plain object literals ({}) re-attach Object.prototype" so it no longer reads as a universal claim. Behavior of the gate is unchanged; this is doc accuracy.
454a2c8 to
3e386fe
Compare
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.
70a1241 to
3b8efb5
Compare
PR #43 shipped R1-R4 with an explicit scope statement, and the first hole on it was the READ mode: `props[key]` off a normal-prototype object returns inherited FUNCTIONS, which was gremlin.ts's half of the original CodeQL alert (js/remote-property-injection). Measured on the real files, the gate scored gremlin.ts ZERO both before and after 0.4.47 fixed it — `ownValue()` there was a convention with nothing holding it. R5 flags a computed-key READ off an object that carries Object.prototype. It is ORIGIN-DIRECTED, not "any computed read": the four guarded surfaces hold 57 computed element accesses and almost all are array indexing (`tokens[pos]`, `row[i]`) or reads off dictionaries R3 already guarantees are null-prototype (`binding[term.name]`). Flagging those would be 57 false positives and one switched-off gate. Two base forms, both with known provenance: (a) `<expr>.properties[k]` — the graph's own bag. GraphNode.properties and GraphEdge.properties are Record<string, PropertyValue> built by store.addNode() from a caller object literal, so they carry Object.prototype, and their keys arrive in query text. (b) an identifier whose construction R1 already tracks as plain-prototype, read with a computed key. Same scope machinery, so aliases and casts launder a read no more than they launder a write. Literal keys stay trusted. Assignment targets stay R1's — `isAssignmentTarget()` covers `=`, compound assignment, `++/--` and `delete`, and a new `exclusive` fixture flag asserts the two rules never both report one node. The fix is `ownValue(bag, key)`, a CALL rather than an element access: corrected code stops matching the rule instead of needing an exemption from it. No allowlist, no magic comment, consistent with #43. R4 grows a matching coverage assertion. R5 keys on a member NAME, so a rename would neuter it the way a type rename would neuter R3; the gate now fails loudly if `properties` stops being declared on GraphNode/GraphEdge. RED PROOF, against the real files rather than a paraphrase: gremlin.ts PRE-#34: R5=4 (has, values, both order arms) POST: R5=0 sparql.ts PRE-#34: R5=1 (`next[term.name]`) POST: R5=0 cypher.ts PRE-#34: R5=0 POST: R5=0 patternMatcher.ts PRE-#34: R5=0 POST: R5=0 Teeth proven both ways: emptying PROTO_BEARING_BAGS makes the self-test go red, and renaming GraphNode.properties makes R4 go red. WHAT R5 STILL DOES NOT CATCH, stated to #43's standard: · the bag set is one member name, `.properties`; a new prototype-bearing bag under another name is uncovered until it is added (R4 catches a rename, not an addition) · a computed read off a bag reached through a `Record<string, …>` PARAMETER — provenance is unknown there and guessing is worse than the stated gap; R3 is the compensating control for the tracked dictionary types · a bag aliased through a local (`const p = node.properties; p[key]`); the direct form the alert was about is caught · the `in` mode, cross-function laundering, files outside the guarded set, and runtime prototype re-attachment — all unchanged from #43 433/433 green, typecheck clean, no ts/dist drift (the safe-dict.ts change is comment only).
PR #43 shipped R1-R4 with an explicit scope statement, and the first hole on it was the READ mode: `props[key]` off a normal-prototype object returns inherited FUNCTIONS, which was gremlin.ts's half of the original CodeQL alert (js/remote-property-injection). Measured on the real files, the gate scored gremlin.ts ZERO both before and after 0.4.47 fixed it — `ownValue()` there was a convention with nothing holding it. R5 flags a computed-key READ off an object that carries Object.prototype. It is ORIGIN-DIRECTED, not "any computed read": the four guarded surfaces hold 57 computed element accesses and almost all are array indexing (`tokens[pos]`, `row[i]`) or reads off dictionaries R3 already guarantees are null-prototype (`binding[term.name]`). Flagging those would be 57 false positives and one switched-off gate. Two base forms, both with known provenance: (a) `<expr>.properties[k]` — the graph's own bag. GraphNode.properties and GraphEdge.properties are Record<string, PropertyValue> built by store.addNode() from a caller object literal, so they carry Object.prototype, and their keys arrive in query text. (b) an identifier whose construction R1 already tracks as plain-prototype, read with a computed key. Same scope machinery, so aliases and casts launder a read no more than they launder a write. Literal keys stay trusted. Assignment targets stay R1's — `isAssignmentTarget()` covers `=`, compound assignment, `++/--` and `delete`, and a new `exclusive` fixture flag asserts the two rules never both report one node. The fix is `ownValue(bag, key)`, a CALL rather than an element access: corrected code stops matching the rule instead of needing an exemption from it. No allowlist, no magic comment, consistent with #43. R4 grows a matching coverage assertion. R5 keys on a member NAME, so a rename would neuter it the way a type rename would neuter R3; the gate now fails loudly if `properties` stops being declared on GraphNode/GraphEdge. RED PROOF, against the real files rather than a paraphrase: gremlin.ts PRE-#34: R5=4 (has, values, both order arms) POST: R5=0 sparql.ts PRE-#34: R5=1 (`next[term.name]`) POST: R5=0 cypher.ts PRE-#34: R5=0 POST: R5=0 patternMatcher.ts PRE-#34: R5=0 POST: R5=0 Teeth proven both ways: emptying PROTO_BEARING_BAGS makes the self-test go red, and renaming GraphNode.properties makes R4 go red. WHAT R5 STILL DOES NOT CATCH, stated to #43's standard: · the bag set is one member name, `.properties`; a new prototype-bearing bag under another name is uncovered until it is added (R4 catches a rename, not an addition) · a computed read off a bag reached through a `Record<string, …>` PARAMETER — provenance is unknown there and guessing is worse than the stated gap; R3 is the compensating control for the tracked dictionary types · a bag aliased through a local (`const p = node.properties; p[key]`); the direct form the alert was about is caught · the `in` mode, cross-function laundering, files outside the guarded set, and runtime prototype re-attachment — all unchanged from #43 433/433 green, typecheck clean, no ts/dist drift (the safe-dict.ts change is comment only).
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.