diff --git a/CHANGELOG.md b/CHANGELOG.md index 5390899a..ccbf284c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **A route belongs to the handler it is declared on.** SARIF used to attach routes per file, first declaration wins. In a file that declares `GET /orders/` on one handler and `POST /pay` on another, an injection on the `POST /pay` handler was exported with `codegraph_reachability` set to `GET /orders/`, and a consumer probed the wrong endpoint. Each exposure and confirmed result now takes the route declared on its own handler, else its file's route, else its asset's inbound route. `properties.route_attribution` (`handler`, `file` or `asset`) says which step found it. When the model does not tie a finding to one route, the result carries `route_attribution: "ambiguous"` and `route_candidates`, and no `codegraph_reachability`. **Expect** some results that used to carry a route to carry candidates instead. Those routes were guesses. - **A `@mitigates` no longer clears the same asset and threat on a sibling handler.** Coverage was keyed by (asset, threat) for inline annotations. One `@mitigates #orders against #idor` on `update_order` therefore removed the `@exposes #orders to #idor` on `get_order` and on `delete_order` from `validate`, `ci` and `sarif`. When an exposure and a mitigation in the same file are both attached to handlers, the mitigation now covers the exposure only if its handler encloses the exposure's handler: the same function, or a class around the method. A mitigation in a module header, or in another file, covers as before. **Expect** an inline repository with per-handler mitigations to report more open exposures. Each one was already declared and was being hidden. Measured: no exposure changes state on this repository or on the external-mode test fixture. +- **Every hop of a multi-hop `@flows` gets the whole line.** In a `.gal` sidecar, only the first hop of `A -> B -> C` was sited at its `@source` block; the others were sited at the `.gal` file itself, so they had no handler and no anchor and pointed readers at the sidecar rather than the code. And a `-- "…"` continuation line extended the description of the last hop only. Both now apply to every hop, as SPEC §3.2 `@flows` (Chains) states, and `conformance/flows.json` pins them. - **`@flows A -> B via -- "d"` is reported as malformed.** It used to record `-- "d"` as the mechanism and lose the description. `via` must be followed by a mechanism, the same rule that already rejected a trailing bare `via`. - **The annotation guides no longer forbid chains.** The agent prompt and `docs/GUARDLINK_REFERENCE.md` said "ONE source → ONE target per line", which the parser has not enforced since chains were added. They now describe one path per line, with chains, and show the route form. diff --git a/conformance/flows.json b/conformance/flows.json index 41109191..7da8fa6e 100644 --- a/conformance/flows.json +++ b/conformance/flows.json @@ -120,6 +120,123 @@ "malformed": [] } }, + { + "id": "chain/four-endpoints-via-description", + "rule": "Chains", + "about": "Four endpoints are three hops, in order, each carrying the whole mechanism and description.", + "files": { + "app/flows.py": "import os\n# @flows #client -> #gateway -> #orders -> #db via order_id -- \"Order write path\"\n" + }, + "expect": { + "flows": [ + { + "source": "#client", + "target": "#gateway", + "mechanism": "order_id", + "route": null, + "description": "Order write path", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "#gateway", + "target": "#orders", + "mechanism": "order_id", + "route": null, + "description": "Order write path", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "#orders", + "target": "#db", + "mechanism": "order_id", + "route": null, + "description": "Order write path", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "chain/five-endpoints-no-via", + "rule": "Chains; Mechanism", + "about": "Five endpoints are four hops; with no via every hop has a null mechanism. All four endpoint forms appear in one chain.", + "files": { + "app/flows.py": "import os\n# @flows User -> #edge -> Svc.Api -> #billing.charge -> \"Ledger DB\"\n" + }, + "expect": { + "flows": [ + { + "source": "User", + "target": "#edge", + "mechanism": null, + "route": null, + "description": null, + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "#edge", + "target": "Svc.Api", + "mechanism": null, + "route": null, + "description": null, + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "Svc.Api", + "target": "#billing.charge", + "mechanism": null, + "route": null, + "description": null, + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "#billing.charge", + "target": "Ledger DB", + "mechanism": null, + "route": null, + "description": null, + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, { "id": "chain/quoted-endpoints", "rule": "Chains; Endpoints", @@ -470,6 +587,67 @@ "malformed": [] } }, + { + "id": "route/four-endpoint-chain", + "rule": "Chains; Route channels", + "about": "A route channel on a longer chain is carried, with its full mechanism and parsed route, by every hop.", + "files": { + "app/flows.py": "import os\n# @flows #client -> #gateway -> #orders -> #db via PATCH./orders/{id}?fields (json) -- \"Partial update\"\n" + }, + "expect": { + "flows": [ + { + "source": "#client", + "target": "#gateway", + "mechanism": "PATCH./orders/{id}?fields (json)", + "route": { + "method": "PATCH", + "path": "/orders/{id}" + }, + "description": "Partial update", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "#gateway", + "target": "#orders", + "mechanism": "PATCH./orders/{id}?fields (json)", + "route": { + "method": "PATCH", + "path": "/orders/{id}" + }, + "description": "Partial update", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "#orders", + "target": "#db", + "mechanism": "PATCH./orders/{id}?fields (json)", + "route": { + "method": "PATCH", + "path": "/orders/{id}" + }, + "description": "Partial update", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, { "id": "comment/jsdoc-block", "rule": "Endpoints; Mechanism", @@ -551,6 +729,85 @@ "malformed": [] } }, + { + "id": "chain/continuation-every-hop", + "rule": "Chains", + "about": "A continuation line extends the description of every hop the line above declared, not only the last.", + "files": { + "app/flows.py": "import os\n# @flows #orders -> #billing -> #legacy via ledger_id -- \"Ledger sync\"\n# -- \"runs nightly\"\n" + }, + "expect": { + "flows": [ + { + "source": "#orders", + "target": "#billing", + "mechanism": "ledger_id", + "route": null, + "description": "Ledger sync runs nightly", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + }, + { + "source": "#billing", + "target": "#legacy", + "mechanism": "ledger_id", + "route": null, + "description": "Ledger sync runs nightly", + "location": { + "file": "app/flows.py", + "line": 2, + "origin_file": null, + "origin_line": null + } + } + ], + "malformed": [] + } + }, + { + "id": "sidecar/chain-every-hop-sited", + "rule": "Sidecar files; Chains", + "about": "Every hop of a sidecar chain is sited at its @source block, and a continuation reaches every hop.", + "files": { + "app/pay.py": "import db\n\n\ndef charge(card):\n return db.charge(card)\n", + ".guardlink/annotations/app/pay.py.gal": "@source file:app/pay.py line:4 symbol:charge\n@flows #client -> #orders -> #billing via card_token -- \"Charge\"\n-- \"then settle\"\n" + }, + "expect": { + "flows": [ + { + "source": "#client", + "target": "#orders", + "mechanism": "card_token", + "route": null, + "description": "Charge then settle", + "location": { + "file": "app/pay.py", + "line": 4, + "origin_file": ".guardlink/annotations/app/pay.py.gal", + "origin_line": 2 + } + }, + { + "source": "#orders", + "target": "#billing", + "mechanism": "card_token", + "route": null, + "description": "Charge then settle", + "location": { + "file": "app/pay.py", + "line": 4, + "origin_file": ".guardlink/annotations/app/pay.py.gal", + "origin_line": 2 + } + } + ], + "malformed": [] + } + }, { "id": "malformed/comma-fan-out", "rule": "Chains", diff --git a/docs/SPEC.md b/docs/SPEC.md index 5a50a3b4..0a8a1dba 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -640,7 +640,7 @@ This section is the definition of `@flows`. Every form below is accepted by the A reader MUST NOT shorten, re-case or resolve an endpoint when recording it: `#billing.charge` is recorded as `#billing.charge`, and `api.auth` as `api.auth`. -**Chains.** A line with N endpoints declares N−1 **hops**, one flow record per arrow, in order: `#orders -> #billing -> #legacy via ledger_id` is exactly the two records `#orders -> #billing` and `#billing -> #legacy`. Every hop carries the line's whole `via` mechanism and description, and the line's location. There is no chain record; a chain is only a way of writing several hops on one line. Commas do not separate flows — `A -> B, C -> D` is malformed. +**Chains.** A line with N endpoints declares N−1 **hops**, one flow record per arrow, in order: `#orders -> #billing -> #legacy via ledger_id` is exactly the two records `#orders -> #billing` and `#billing -> #legacy`. Every hop carries the line's whole `via` mechanism and description — including any `-- "…"` continuation lines that follow it (§2.7) — and the line's location, which for a sidecar chain is the `@source` block for every hop. There is no chain record; a chain is only a way of writing several hops on one line. Commas do not separate flows — `A -> B, C -> D` is malformed. **Mechanism.** `via` is optional. When present, the mechanism is all the text after `via ` up to the ` -- "` that opens the description, or to the end of the line, with surrounding whitespace trimmed. It may contain spaces, dots, slashes and punctuation: `via TLS 1.3`, `via TypeORM.findOne`, `via POST./restore (multipart)` are all single mechanisms. A flow **without** `via` is a complete flow whose channel is unstated; its mechanism is `null`, and it takes part in the data-flow graph, the diagrams and undefended-path analysis exactly as a flow with one does. A reader MUST NOT drop it. diff --git a/src/parser/parse-file.ts b/src/parser/parse-file.ts index 9806f058..16ba1c09 100644 --- a/src/parser/parse-file.ts +++ b/src/parser/parse-file.ts @@ -35,7 +35,10 @@ export function parseString(content: string, filePath: string = ''): Pars const lines = content.split('\n'); const annotations: Annotation[] = []; const diagnostics: ParseDiagnostic[] = []; - let lastAnnotation: Annotation | null = null; + // Every record the last annotation line produced — one per hop for a + // multi-hop @flows — so a continuation line reaches all of them, not only + // the last hop (SPEC §3.2 `@flows`, Chains). + let lastAnnotations: Annotation[] = []; let inShield = false; const allowRawAnnotationLines = isStandaloneAnnotationFile(filePath); let currentSource: SourceLocation | null = null; @@ -70,7 +73,7 @@ export function parseString(content: string, filePath: string = ''): Pars }); } } - lastAnnotation = null; + lastAnnotations = []; continue; } const text = inner.trimStart(); @@ -82,7 +85,7 @@ export function parseString(content: string, filePath: string = ''): Pars const result = parseLine(text, location); if (result.annotation) annotations.push(result.annotation); inShield = false; - lastAnnotation = null; + lastAnnotations = []; continue; } if (trimmed.startsWith('@shield:begin')) { @@ -90,7 +93,7 @@ export function parseString(content: string, filePath: string = ''): Pars const result = parseLine(text, location); if (result.annotation) annotations.push(result.annotation); inShield = true; - lastAnnotation = null; + lastAnnotations = []; continue; } @@ -99,13 +102,11 @@ export function parseString(content: string, filePath: string = ''): Pars // Check for continuation line: -- "..." const contMatch = text.match(/^--\s*"((?:[^"\\]|\\.)*)"/); - if (contMatch && lastAnnotation) { - // Append to last annotation's description + if (contMatch && lastAnnotations.length > 0) { + // Append to the last annotation line's description, on every record it produced const contDesc = unescapeDescription(contMatch[1]); - if (lastAnnotation.description) { - lastAnnotation.description += ' ' + contDesc; - } else { - lastAnnotation.description = contDesc; + for (const last of lastAnnotations) { + last.description = last.description ? `${last.description} ${contDesc}` : contDesc; } continue; } @@ -120,23 +121,26 @@ export function parseString(content: string, filePath: string = ''): Pars line: result.sourceDirective.line, parent_symbol: result.sourceDirective.symbol ?? null, }; - lastAnnotation = null; + lastAnnotations = []; continue; } if (result.annotation) { + const produced = [result.annotation, ...(result.extraAnnotations ?? [])]; if (allowRawAnnotationLines && currentSource) { - result.annotation.location = { + // Every hop of a multi-hop @flows is sited at the @source block, not + // only the first; they share one location, as inline hops do. + const sited: SourceLocation = { file: currentSource.file, line: currentSource.line, parent_symbol: currentSource.parent_symbol ?? null, origin_file: filePath, origin_line: lineNum, }; + for (const a of produced) a.location = sited; } - annotations.push(result.annotation); - if (result.extraAnnotations) annotations.push(...result.extraAnnotations); - lastAnnotation = annotations[annotations.length - 1]; + annotations.push(...produced); + lastAnnotations = produced; } else { if (result.diagnostic) { diagnostics.push(result.diagnostic); @@ -162,7 +166,7 @@ export function parseString(content: string, filePath: string = ''): Pars } } if (!result.isContinuation) { - lastAnnotation = null; + lastAnnotations = []; } } }