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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` on one handler and `POST /pay` on another, an injection on the `POST /pay` handler was exported with `codegraph_reachability` set to `GET /orders/<id>`, 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.

Expand Down
257 changes: 257 additions & 0 deletions conformance/flows.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
36 changes: 20 additions & 16 deletions src/parser/parse-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export function parseString(content: string, filePath: string = '<input>'): 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;
Expand Down Expand Up @@ -70,7 +73,7 @@ export function parseString(content: string, filePath: string = '<input>'): Pars
});
}
}
lastAnnotation = null;
lastAnnotations = [];
continue;
}
const text = inner.trimStart();
Expand All @@ -82,15 +85,15 @@ export function parseString(content: string, filePath: string = '<input>'): Pars
const result = parseLine(text, location);
if (result.annotation) annotations.push(result.annotation);
inShield = false;
lastAnnotation = null;
lastAnnotations = [];
continue;
}
if (trimmed.startsWith('@shield:begin')) {
const location = { file: filePath, line: lineNum };
const result = parseLine(text, location);
if (result.annotation) annotations.push(result.annotation);
inShield = true;
lastAnnotation = null;
lastAnnotations = [];
continue;
}

Expand All @@ -99,13 +102,11 @@ export function parseString(content: string, filePath: string = '<input>'): 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;
}
Expand All @@ -120,23 +121,26 @@ export function parseString(content: string, filePath: string = '<input>'): 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);
Expand All @@ -162,7 +166,7 @@ export function parseString(content: string, filePath: string = '<input>'): Pars
}
}
if (!result.isContinuation) {
lastAnnotation = null;
lastAnnotations = [];
}
}
}
Expand Down
Loading