safe-dict: close the gate's known hole — R5, the READ mode - #45
Open
mdheller wants to merge 1 commit into
Open
Conversation
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).
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 a new “R5” safe-dict gate rule to detect computed-key reads from prototype-bearing objects (notably node.properties[key]), closing a previously unguarded half of the js/remote-property-injection risk.
Changes:
- Introduces R5 logic to flag computed-key READs from known prototype-bearing origins while avoiding array-indexing / null-prototype dict false positives.
- Extends R4 “coverage” checks to ensure the graph bag member name (
properties) can’t silently drift. - Adds fixtures and new tests proving R5 is red on pre-fix Gremlin patterns and green on the shipped
ownValue()-based fix.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| ts/src/safe-dict.ts | Updates module-level documentation to include R5 and clarify remaining known holes. |
| ts/src/safe-dict-gate.test.ts | Adds explicit R5 regression tests and expands fixture expectations to include R5. |
| scripts/check-safe-dict.mjs | Implements R5 detection, adds assignment-target discrimination, and strengthens R4 coverage checks for .properties. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+362
to
+364
| if (ts.isElementAccessExpression(node) && !isLiteralKey(node.argumentExpression) && | ||
| !isAssignmentTarget(node)) { | ||
| const base = unwrap(node.expression) |
Comment on lines
+810
to
+811
| const decl = new RegExp(`\\binterface\\s+${owner}\\b[\\s\\S]*?\\n}`).exec(text) | ||
| return !decl || !new RegExp(`^\\s*${bag}\\??\\s*:`, 'm').test(decl[0]) |
Comment on lines
+263
to
+266
| if ((ts.isPostfixUnaryExpression(p) || ts.isPrefixUnaryExpression(p)) && p.operand === node) { | ||
| return p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken | ||
| } | ||
| return ts.isDeleteExpression(p) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #43 (
guard/safe-dict-spread-gate), which is itself stacked on #34. Merge order: #34 → #43 → this.The hole
#43 shipped R1–R4 with an explicit scope statement, and the first item on it was the READ mode:
Measured, on the real files rather than a paraphrase:
Zero both sides. The other half of the same
js/remote-property-injectionalert had nothing holding it.R5
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 of them are array indexing (
this.tokens[this.pos],row[i],refs[i+1]) or reads off dictionaries R3 already guarantees are null-prototype (binding[term.name],g[v],rr[v]). A blanket rule would be 57 false positives and one switched-off gate. Two base forms, both with known provenance:<expr>.properties[k]— the graph's own bag.GraphNode.properties/GraphEdge.propertiesareRecord<string, PropertyValue>(types.ts), materialized bystore.addNode()from a caller-supplied object literal, so they carryObject.prototype, and their keys arrive in query text:values(k),has(k, v),order(k). This is the gremlin.ts pattern verbatim.{},Object.assign({}, …),Object.fromEntries(…),toPlainRow(…)), read with a computed key. Same scope machinery as R1, so aliases and casts launder a read no more than they launder a write.Literal keys stay trusted, as in R1. Assignment targets stay R1's — a new
isAssignmentTarget()covers=, compound assignment,++/--anddelete, and a newexclusivefixture flag asserts the two rules never both report one node (a line appearing twice under two diagnoses is a real defect: whichever is read first is the one acted on).The fix is
ownValue(bag, key)— a CALL, not an element access, so corrected code stops matching the rule rather than needing an exemption from it. No allowlist, no magic comment; same posture as #43.R4 grows to match. 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
propertiesstops being declared onGraphNode/GraphEdge.Red proof
Against the real files at
origin/main(pre-#34) and on this branch (post):gremlin.tshas(),values(), both arms oforder()sparql.tsnext[term.name]at line 419cypher.tspatternMatcher.tsThose are exactly the four call sites 0.4.47 routed through
ownValue().Teeth proven both ways, per the gate's own doctrine:
PROTO_BEARING_BAGS→ self-test goes red (prefix-gremlin-values: MUST be flagged (R5) and was NOT), with the real files still cleanGraphNode.properties→ R4 goes red with the instruction to updatePROTO_BEARING_BAGS/BAG_HOMESWhat R5 still does NOT catch
To #43's standard, since that scope statement is what made this hole findable:
.properties. It is the only untrusted-keyed prototype-bearing bag in this engine's type surface, and R4 fails loudly if it is renamed — but a new such bag under a different name is uncovered until it is added here. R4 catches a rename, not an addition.atom.values[…]is deliberately excluded: it is an Atom's Value map, read with source-fixed constants (LABELS_KEY), not with names off the wire.Record<string, …>PARAMETER. Provenance is unknown at that point — the caller may well have passed a null-prototype dictionary — and guessing in either direction is worse than the stated gap. R3 is the compensating control: a parameter typed as a tracked dictionary is guaranteed null-prototype at every construction site, so reading it computed is safe.const p = node.properties; p[key]is form (b) with an origin R1 does not record, becausenode.propertiesis not one of the tracked constructors. The directnode.properties[key]— the shape the alert was about, and the shape fluent traversal code naturally writes — is caught.inmode ('__proto__' in binding), cross-function laundering, files outside the guarded set, runtime prototype re-attachment, and someone editing the rules themselves.Verification
npm test433/433,npm run typecheckclean,npm run buildproduces nots/distdrift (thesafe-dict.tschange is comment-only). Gate summary now reads:R5 rides the same enforcement path #43 chose —
ts/src/safe-dict-gate.test.tsundernpm test, i.e. the requiredbuild-and-verify-distcheck — so no workflow edit and no conflict with the lanes editingts-ci.yml.Lane note
No source overlap with #40 / #42 / #44 (
ts/src/vendor-graph.ts).git merge-treeagainst all three conflicts only in the four rebuiltts/distbinaries — whoever merges second runsnpm run build && git add ts/dist.