From 3b3a1b1093b00d5d0e7e2496ed6ba1e3ed4b1c46 Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 00:49:24 +0530 Subject: [PATCH 1/4] feat(sarif): carry each finding's declared context, additively Each @exposes and @confirmed result now also carries, as SARIF members existing consumers ignore: logicalLocations and the anchor of the code it is attached to; taxa for its cwe:/owasp: refs, with run.taxonomies; relatedLocations for the @boundary, @assumes, @handles, @transfers and @audit annotations on its asset; and codeFlows with the declared @flows chain into it. The run gains graphs[0] (every @flows and @boundary, with claim keys, crossings and the inferable outer side), automationDetails, versionControlProvenance when the repository has a web-hosted origin, and properties.sarif_profile_version. Rules gain help, tags and a GitHub security-severity band. No result is added, dropped or reordered, and no rule id, message.text, fingerprint or existing property changes. tests/sarif-enrichment.test.ts strips every new member and compares the rest byte for byte with exports cut before this change (tests/fixtures/sarif-baseline), and validates the output against the official SARIF 2.1.0 schema. A chain is emitted only when its last hop is declared on the claim's own handler or file; nothing is derived from @entitles or @actor, which actor-entitlement.test.ts now also pins for the new members. Also fixes two front-end defects: the MCP guardlink_sarif tool now computes dangling refs as the CLI does, and --min-severity is documented and tested as filtering exposures only, never a @confirmed result. The provenance read gives Workspace.Metadata its first exposure (a git remote can embed credentials; only host and path are kept), which adds a node to this repository's whole-model threat graph: re-measured in Chrome with mermaid@11 at 1440x900 as 48 nodes and 179 links, matching the counter. --- CHANGELOG.md | 8 + docs/GUARDLINK_REFERENCE.md | 2 +- package-lock.json | 25 + package.json | 3 + src/analyzer/index.ts | 2 +- src/analyzer/sarif-context.ts | 496 +++ src/analyzer/sarif.ts | 167 +- src/cli/index.ts | 5 +- src/index.ts | 2 +- src/mcp/server.ts | 10 +- src/tui/commands.ts | 3 +- src/workspace/index.ts | 1 + src/workspace/metadata.ts | 25 + tests/actor-entitlement.test.ts | 40 + tests/fixtures/sarif-baseline/README.md | 21 + .../fixtures/sarif-baseline/expense-api.sarif | 458 +++ .../sarif-baseline/sarif-schema-2.1.0.json | 3389 +++++++++++++++++ .../fixtures/sarif-baseline/sarif-shop.sarif | 378 ++ .../sarif-shop/.guardlink/config.json | 15 + .../sarif-shop/.guardlink/definitions.ts | 15 + tests/fixtures/sarif-shop/FIXTURE.md | 24 + tests/fixtures/sarif-shop/src/export.ts | 11 + tests/fixtures/sarif-shop/src/notify.ts | 16 + tests/fixtures/sarif-shop/src/web.ts | 43 + tests/query-views.test.ts | 13 +- tests/sarif-enrichment.test.ts | 462 +++ 26 files changed, 5602 insertions(+), 32 deletions(-) create mode 100644 src/analyzer/sarif-context.ts create mode 100644 tests/fixtures/sarif-baseline/README.md create mode 100644 tests/fixtures/sarif-baseline/expense-api.sarif create mode 100644 tests/fixtures/sarif-baseline/sarif-schema-2.1.0.json create mode 100644 tests/fixtures/sarif-baseline/sarif-shop.sarif create mode 100644 tests/fixtures/sarif-shop/.guardlink/config.json create mode 100644 tests/fixtures/sarif-shop/.guardlink/definitions.ts create mode 100644 tests/fixtures/sarif-shop/FIXTURE.md create mode 100644 tests/fixtures/sarif-shop/src/export.ts create mode 100644 tests/fixtures/sarif-shop/src/notify.ts create mode 100644 tests/fixtures/sarif-shop/src/web.ts create mode 100644 tests/sarif-enrichment.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbf284c..d67fb8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Every flow record in the model carries `route: { method, path }` when its mechanism is a route channel, and `null` otherwise. The field is additive and derived from `mechanism`, so the annotation hash and claim keys do not move. - SPEC §3.6, **Handler Scope**, defines the code each claim is attached to and the two answers that depend on it, described under *Fixed* below. +- **The SARIF export carries the model around each finding, without changing a single existing result.** Each `@exposes` and `@confirmed` result now also carries, as SARIF members existing consumers ignore: `logicalLocations` naming the handler it is attached to, and its anchor; `taxa` for its `cwe:` and `owasp:` references, with `run.taxonomies`; `relatedLocations` for the `@boundary`, `@assumes`, `@handles`, `@transfers` and `@audit` annotations on its asset; and `codeFlows` with the declared `@flows` chain into it, each hop at its own line and boundary crossings marked. The run gains `graphs[0]` — every `@flows` and `@boundary` as an edge with its claim key, each boundary with the flows that cross it and, when exactly one side is undeclared, which side is outer — plus `automationDetails`, `versionControlProvenance` when the repository has a web-hosted origin, and `properties.sarif_profile_version: 1`. Rules gain `help`, `tags` and a GitHub `security-severity` band (confirmed 9.0, critical/high 8.9, other exposures 5.0; diagnostics are not tagged as security). See SPEC §6.6 and §6.7. + - **Nothing existing moves.** No result is added, dropped or reordered, and no rule id, `message.text`, fingerprint or existing property changes. `tests/sarif-enrichment.test.ts` deletes every new member and compares the rest, byte for byte, with exports cut before this change, and validates every export against the official SARIF 2.1.0 schema. No new member is derived from `@entitles` or `@actor`. + - **A chain is emitted only when its last hop is the claim's own.** `@flows` hops are not linked by call site, so the hop into the asset must be declared on the claim's handler or file (`guardlink/flowAttribution`); otherwise the result has no `codeFlows`. Upstream hops joined only by graph adjacency say so (`guardlink/hopAttribution: "graph"`). + - **Expect** a GitHub upload without an explicit `category` to file GuardLink alerts under `guardlink/threat-model/`. + ### Fixed +- **The MCP `guardlink_sarif` tool reports dangling references.** It passed an empty list where `guardlink sarif` computes them, so the same model gave two different SARIF documents depending on the front end. Both now compute them the same way. +- **`--min-severity` says what it filters.** It has only ever filtered unmitigated exposures; a `@confirmed` result is a reproduced exploit and is always exported. The help text, the option's documentation and SPEC §6.1 now say so, and a test pins it. +- **SPEC §6 describes the SARIF GuardLink exports.** It promised results for `@accepts`, `@assumes`, `@audit`, `@mitigates` and `@handles`, low severity as `note`, a `cwe` result property and `TS200x` rule ids, none of which the exporter produced. §6.1–§6.4 now state the five rules, their order and levels, what each other annotation becomes and why it is not a result; §6.6 and §6.7 are new. - **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. diff --git a/docs/GUARDLINK_REFERENCE.md b/docs/GUARDLINK_REFERENCE.md index 13fefba6..ee0747f6 100644 --- a/docs/GUARDLINK_REFERENCE.md +++ b/docs/GUARDLINK_REFERENCE.md @@ -134,7 +134,7 @@ guardlink ci . --expiring-within 30 # Days of notice before an @a # Reports & Export guardlink report [dir] # Generate threat-model.md + optional JSON guardlink dashboard [dir] # Interactive HTML dashboard with Mermaid diagrams -guardlink sarif [dir] [-o file] # SARIF 2.1.0 for GitHub Advanced Security / VS Code; @exposes and @confirmed results carry guardlink/threatId, and @exposes results also carry guardlink/claimKey +guardlink sarif [dir] [-o file] # SARIF 2.1.0 for GitHub Advanced Security / VS Code; @exposes and @confirmed results carry guardlink/threatId, and @exposes results also carry guardlink/claimKey; each also carries its declared context — CWE/OWASP taxa, boundaries and assumptions on its asset, the @flows chain into it — and the run carries the flow/boundary graph (SPEC §6.6) guardlink diff [ref] # Compare threat model against a git ref (default: HEAD~1) guardlink paths [dir] [--all] # Undefended source-to-sink routes, derived from @flows (no LLM) diff --git a/package-lock.json b/package-lock.json index d3a82b52..bf23a144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "devDependencies": { "@types/gradient-string": "^1.1.6", "@types/node": "^22.0.0", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", "eslint": "^9.39.5", "tsx": "^4.0.0", "typescript": "^5.7.0", @@ -1593,6 +1596,21 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ajv-formats": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", @@ -5175,6 +5193,13 @@ "require-from-string": "^2.0.2" } }, + "ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "requires": {} + }, "ajv-formats": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", diff --git a/package.json b/package.json index 9732ceb1..edd91e7c 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,9 @@ "devDependencies": { "@types/gradient-string": "^1.1.6", "@types/node": "^22.0.0", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", "eslint": "^9.39.5", "tsx": "^4.0.0", "typescript": "^5.7.0", diff --git a/src/analyzer/index.ts b/src/analyzer/index.ts index 81426360..702680e8 100644 --- a/src/analyzer/index.ts +++ b/src/analyzer/index.ts @@ -5,4 +5,4 @@ * @comment -- "File writes handled by CLI/MCP callers" */ -export { generateSarif, type SarifOptions } from './sarif.js'; +export { generateSarif, type SarifOptions, type SarifVersionControl } from './sarif.js'; diff --git a/src/analyzer/sarif-context.ts b/src/analyzer/sarif-context.ts new file mode 100644 index 00000000..10e5aac3 --- /dev/null +++ b/src/analyzer/sarif-context.ts @@ -0,0 +1,496 @@ +/** + * GuardLink SARIF — the declared context around each finding. + * + * `sarif.ts` decides WHICH results exist and what each one says. This module + * adds what the model already declares around them, as SARIF members no + * existing consumer reads: + * + * - `locations[0].logicalLocations` and `properties['guardlink/anchor']`: + * the code the claim is attached to + * - `taxa`, with `run.taxonomies`: the claim's `cwe:` and `owasp:` refs + * - `relatedLocations`: `@boundary`, `@assumes`, `@handles`, `@transfers` + * and `@audit` annotations on the claim's asset + * - `codeFlows`: the declared `@flows` chain into the claim, only when the + * chain's last hop is declared on the claim's own handler or file + * - `run.graphs[0]`: every `@flows` and `@boundary`, with ids and claim keys + * + * ── Additive only ─────────────────────────────────────────────────── + * + * Downstream consumers key on the result index, on `message.text`, on rule + * ids and on `partialFingerprints`, so nothing here may insert, reorder or + * rewrite a result. Every member is appended after the ones `sarif.ts` wrote, + * and deleting the members added here gives back the export byte for byte + * (tests/sarif-enrichment.test.ts proves it against an export cut before this + * module existed). + * + * ── Never a guessed chain ─────────────────────────────────────────── + * + * `@flows` hops are not linked by call site: the model says `Client -> #api` + * and `#api -> #db`, not which handler of `#api` makes the second call. So only + * the LAST hop of a chain can be tied to the claim, by the same handler-scope + * rule route attribution uses (SPEC §3.6.2). A chain whose last hop is not tied + * that way is not emitted at all, and each upstream hop says it was joined by + * graph adjacency alone (`guardlink/hopAttribution: "graph"`). + * + * ── Never derived from @entitles or @actor ────────────────────────── + * + * SPEC §6.1: an entitlement changes no byte of `results` or `tool`. Nothing in + * this module reads `model.entitlements` or `model.actors`, and the claim-key + * lookup skips the `entitles` verb, so neither a relatedLocation nor a graph + * node can come from one. + * + * @flows ThreatModel -> #sarif via buildSarifContext -- "Flows, boundaries, assets and context annotations read from the parsed model" + * @exposes #sarif to #dos [low] cwe:CWE-400 -- "Enumerating upstream @flows chains is combinatorial in a dense flow graph" + * @mitigates #sarif against #dos using #resource-limits -- "Chain walk is capped by depth, by chains collected and by a step budget; at most three chains are emitted per result" + * @comment -- "Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)" + */ + +import type { + ThreatModel, SourceLocation, ThreatModelFlow, ThreatModelBoundary, +} from '../types/index.js'; +import { canonicaliser } from '../parser/canonical-ref.js'; +import { handlerScope, scopeEncloses } from '../parser/handler-scope.js'; +import { relationRecords, claimText } from '../parser/claim-key.js'; +import { parseRouteChannel } from '../parser/route.js'; + +// ─── SARIF 2.1.0 member types (the subset written here) ───────────── + +export interface SarifMessage { text: string } + +export interface SarifPhysicalLocation { + artifactLocation: { uri: string }; + region: { startLine: number; startColumn?: number }; +} + +export interface SarifLogicalLocation { name: string } + +export interface SarifLocation { + id?: number; + physicalLocation: SarifPhysicalLocation; + logicalLocations?: SarifLogicalLocation[]; + message?: SarifMessage; + properties?: Record; +} + +export interface SarifTaxonReference { + id: string; + index: number; + toolComponent: { name: string; index: number }; +} + +export interface SarifThreadFlowLocation { + location: SarifLocation; + kinds: string[]; + importance?: 'essential' | 'important' | 'unimportant'; + webRequest?: { method: string; target: string }; + properties?: Record; +} + +export interface SarifCodeFlow { + message: SarifMessage; + threadFlows: { locations: SarifThreadFlowLocation[] }[]; + properties?: Record; +} + +export interface SarifGraph { + description: SarifMessage; + nodes: { id: string; label: SarifMessage; location?: SarifLocation; properties: Record }[]; + edges: { id: string; sourceNodeId: string; targetNodeId: string; label?: SarifMessage; properties: Record }[]; +} + +export interface SarifTaxonomy { + name: string; + organization?: string; + informationUri: string; + isComprehensive: false; + taxa: { id: string }[]; +} + +/** The members a finding result can gain. Every one is optional and appended. */ +export interface EnrichableResult { + locations: SarifLocation[]; + properties?: Record; + taxa?: SarifTaxonReference[]; + relatedLocations?: SarifLocation[]; + codeFlows?: SarifCodeFlow[]; +} + +/** The fields of an `@exposes` or `@confirmed` claim this module reads. */ +export interface FindingClaim { + asset: string; + threat: string; + external_refs: string[]; + location: SourceLocation; +} + +export interface SarifContext { + /** Append the declared context to one exposure or confirmed result. */ + enrich(result: EnrichableResult, claim: FindingClaim, verb: 'exposes' | 'confirmed'): void; + /** `run.taxonomies` for the taxa the enriched results referenced, in first-use order. */ + taxonomies(): SarifTaxonomy[]; + /** `run.graphs[0]`, or null when the model declares no asset, flow or boundary. */ + graph(): SarifGraph | null; +} + +// ─── Bounds ───────────────────────────────────────────────────────── + +/** Longest upstream chain walked, in hops. */ +const MAX_CHAIN_HOPS = 6; +/** Chains collected per claim before ranking. */ +const MAX_CHAINS_COLLECTED = 200; +/** Graph steps per claim; a dense flow graph cannot turn one export into a hang. */ +const MAX_WALK_STEPS = 20_000; +/** Chains emitted per result. */ +const MAX_CHAINS_EMITTED = 3; + +// ─── Helpers ──────────────────────────────────────────────────────── + +export function physicalLocation(file: string, line: number): SarifPhysicalLocation { + // SARIF uses forward-slash URIs + return { artifactLocation: { uri: file.replace(/\\/g, '/') }, region: { startLine: line } }; +} + +const bareRef = (ref: string): string => (ref ?? '').trim().replace(/^#/, '').toLowerCase(); + +/** Unordered pair key: `@boundary` is undirected. NUL cannot occur in a ref. */ +const pairKey = (a: string, b: string): string => [a, b].sort().join('\u0000'); + +/** + * A `cwe:` or `owasp:` ref as a taxon, or null for any other scheme. + * + * cwe:CWE-89, cwe:89 → CWE "89" (the id form the MITRE CWE taxonomy uses) + * owasp:A03:2021 → OWASP "A03:2021" + * owasp:A03 → OWASP "A03" + */ +function taxonOf(ref: string): { taxonomy: 'CWE' | 'OWASP'; id: string } | null { + const cwe = /^cwe:\s*(?:cwe-)?(\d+)$/i.exec(ref.trim()); + if (cwe) return { taxonomy: 'CWE', id: String(Number(cwe[1])) }; + const owasp = /^owasp:\s*(a\d{1,2})(?::(\d{4}))?$/i.exec(ref.trim()); + if (owasp) return { taxonomy: 'OWASP', id: owasp[2] ? `${owasp[1].toUpperCase()}:${owasp[2]}` : owasp[1].toUpperCase() }; + return null; +} + +const TAXONOMY_INFO: Record<'CWE' | 'OWASP', Omit> = { + CWE: { name: 'CWE', organization: 'MITRE', informationUri: 'https://cwe.mitre.org/', isComprehensive: false }, + OWASP: { name: 'OWASP', organization: 'OWASP Foundation', informationUri: 'https://owasp.org/Top10/', isComprehensive: false }, +}; + +type HopTier = 'handler' | 'file'; + +/** + * Is a `@flows` hop tied to the claim, and how strongly — the route-attribution + * ladder of SPEC §3.6.2 applied to a flow instead of a route. + * + * handler — the flow is declared on the claim's handler, or on code enclosing it + * file — the flow is declared for the claim's whole file, or the claim is + * file-level and the flow sits somewhere in that file + * null — another file, or a SIBLING handler in the claim's file: tying it + * to this claim would be a guess + */ +function hopTier(flow: SourceLocation, claim: SourceLocation): HopTier | null { + if (flow.file !== claim.file) return null; + const own = handlerScope(claim); + const at = handlerScope(flow); + if (own && at && scopeEncloses(at, own)) return 'handler'; + if (!at || !own) return 'file'; + return null; +} + +const TIER_RANK: Record = { handler: 2, file: 1 }; + +// ─── Builder ──────────────────────────────────────────────────────── + +export function buildSarifContext(model: ThreatModel): SarifContext { + const key = canonicaliser(model); + const flows: ThreatModelFlow[] = model.flows ?? []; + const boundaries: ThreatModelBoundary[] = model.boundaries ?? []; + + // Claim keys for every relation the results can point at — never `entitles`. + const claimKeyOf = new Map(); + for (const r of relationRecords(model)) { + if (r.verb !== 'entitles') claimKeyOf.set(r.location, r.key); + } + + // ── Nodes: declared assets first, then undeclared endpoints in first-seen order ── + const declared = new Set(); + const nodes = new Map(); + for (const a of model.assets ?? []) { + const label = a.id ? `#${a.id}` : a.path.join('.'); + const id = key(label); + if (!id || nodes.has(id)) continue; + declared.add(id); + nodes.set(id, { + id, + label: { text: label }, + location: { physicalLocation: physicalLocation(a.location.file, a.location.line) }, + properties: { 'guardlink/declared': true, 'guardlink/path': a.path.join('.') }, + }); + } + const nodeFor = (ref: string): string => { + const id = key(ref); + if (!nodes.has(id)) nodes.set(id, { id, label: { text: ref }, properties: { 'guardlink/declared': false } }); + return id; + }; + + // ── Boundary edges, indexed by unordered pair for crossing detection ── + const boundaryEdgeIds = new Map(); + const boundaryLabel = new Map(); + const edges: SarifGraph['edges'] = []; + const crossingsOf = new Map(); + boundaries.forEach((b, i) => { + const id = `boundary:${i}`; + const a = nodeFor(b.asset_a); + const c = nodeFor(b.asset_b); + const pair = pairKey(a, c); + boundaryEdgeIds.set(pair, [...(boundaryEdgeIds.get(pair) ?? []), id]); + boundaryLabel.set(id, b.id ? `#${b.id}` : `${b.asset_a} and ${b.asset_b}`); + crossingsOf.set(id, []); + // The convention SPEC §3.2 gives `guardlink paths`: an undeclared endpoint + // is outside the system. With exactly one undeclared side, that side is + // outer; otherwise the annotation does not say, and neither do we. + const outer = [a, c].filter(n => !declared.has(n)); + const side = outer.length === 1 + ? { basis: 'undeclared-endpoint', outer: outer[0], inner: outer[0] === a ? c : a } + : { basis: 'unknown' }; + edges.push({ + id, sourceNodeId: a, targetNodeId: c, + label: { text: b.description || `@boundary ${claimText(['boundary', b])}` }, + properties: { + 'guardlink/kind': 'boundary', + 'guardlink/directed': false, + 'guardlink/boundaryId': b.id ?? null, + 'guardlink/claimKey': claimKeyOf.get(b.location) ?? null, + 'guardlink/location': { physicalLocation: physicalLocation(b.location.file, b.location.line) }, + 'guardlink/side': side, + 'guardlink/crossings': crossingsOf.get(id), + }, + }); + }); + + // ── Flow edges ── + const crossedBy = (f: ThreatModelFlow): string[] => boundaryEdgeIds.get(pairKey(key(f.source), key(f.target))) ?? []; + flows.forEach((f, i) => { + const id = `flow:${i}`; + const route = f.route ?? parseRouteChannel(f.mechanism); + const crosses = crossedBy(f); + for (const b of crosses) crossingsOf.get(b)!.push(id); + edges.push({ + id, sourceNodeId: nodeFor(f.source), targetNodeId: nodeFor(f.target), + ...(f.mechanism ? { label: { text: f.mechanism } } : {}), + properties: { + 'guardlink/kind': 'flow', + 'guardlink/directed': true, + 'guardlink/claimKey': claimKeyOf.get(f.location) ?? null, + 'guardlink/location': { physicalLocation: physicalLocation(f.location.file, f.location.line) }, + ...(route ? { 'guardlink/route': { http_method: route.method, http_path: route.path } } : {}), + ...(crosses.length ? { 'guardlink/crosses': crosses } : {}), + }, + }); + }); + + // ── Upstream chains ── + const inbound = new Map(); + flows.forEach((f, i) => { + const t = key(f.target); + inbound.set(t, [...(inbound.get(t) ?? []), i]); + }); + + /** + * Simple chains of flow indexes from an entry to `asset`, entry first, + * shortest first. An entry is an undeclared endpoint (outside the system, + * SPEC §3.2) or a declared asset nothing flows into. + * + * Breadth-first, so the step budget is spent on short chains before long + * ones: depth-first spent it all inside a dense cluster and never came back + * up to the one-hop entry beside it. + */ + const chainsInto = (asset: string): number[][] => { + const goal = key(asset); + const found: number[][] = []; + // Each partial chain is upstream-first: hops[0] flows into `goal`. + let frontier: { at: string; hops: number[]; seen: Set }[] = [{ at: goal, hops: [], seen: new Set([goal]) }]; + let steps = 0; + while (frontier.length > 0) { + const next: typeof frontier = []; + for (const { at, hops, seen } of frontier) { + if (++steps > MAX_WALK_STEPS || found.length >= MAX_CHAINS_COLLECTED) return found; + const into = inbound.get(at) ?? []; + if (hops.length > 0 && (into.length === 0 || !declared.has(at))) { + found.push([...hops].reverse()); + continue; + } + if (hops.length >= MAX_CHAIN_HOPS) continue; + for (const i of into) { + const from = key(flows[i].source); + if (seen.has(from)) continue; + // Queued partial chains count against the budget too, so the next + // level of a dense graph cannot grow without bound. + if (++steps > MAX_WALK_STEPS) return found; + next.push({ at: from, hops: [...hops, i], seen: new Set(seen).add(from) }); + } + } + frontier = next; + } + return found; + }; + + // ── Taxonomies, filled as results reference them ── + const taxonomies = new Map<'CWE' | 'OWASP', { index: number; taxa: Map }>(); + const taxonRef = (ref: string): SarifTaxonReference | null => { + const t = taxonOf(ref); + if (!t) return null; + let tax = taxonomies.get(t.taxonomy); + if (!tax) { tax = { index: taxonomies.size, taxa: new Map() }; taxonomies.set(t.taxonomy, tax); } + if (!tax.taxa.has(t.id)) tax.taxa.set(t.id, tax.taxa.size); + return { id: t.id, index: tax.taxa.get(t.id)!, toolComponent: { name: t.taxonomy, index: tax.index } }; + }; + + // ── Related annotations on an asset ── + const describe = (text: string, description?: string): string => description ? `${text}: ${description}` : text; + const relatedFor = (claim: FindingClaim): SarifLocation[] => { + const asset = key(claim.asset); + const threat = bareRef(claim.threat); + const out: { at: SourceLocation; text: string; verb: string; extra?: Record }[] = []; + boundaries.forEach((b, i) => { + if (key(b.asset_a) === asset || key(b.asset_b) === asset) { + out.push({ at: b.location, verb: 'boundary', text: describe(`@boundary ${claimText(['boundary', b])}`, b.description), extra: { 'guardlink/edge': `boundary:${i}` } }); + } + }); + for (const s of model.assumptions ?? []) { + if (key(s.asset) === asset) out.push({ at: s.location, verb: 'assumes', text: describe(`@assumes ${claimText(['assumes', s])}`, s.description) }); + } + for (const h of model.data_handling ?? []) { + if (key(h.asset) === asset) out.push({ at: h.location, verb: 'handles', text: describe(`@handles ${claimText(['handles', h])}`, h.description) }); + } + for (const t of model.transfers ?? []) { + if (key(t.source) === asset && bareRef(t.threat) === threat) { + out.push({ at: t.location, verb: 'transfers', text: describe(`@transfers ${claimText(['transfers', t])}`, t.description) }); + } + } + for (const a of model.audits ?? []) { + if (key(a.asset) === asset) out.push({ at: a.location, verb: 'audit', text: describe(`@audit ${claimText(['audit', a])}`, a.description) }); + } + return out.map((r, n) => ({ + id: n + 1, + physicalLocation: physicalLocation(r.at.file, r.at.line), + message: { text: r.text }, + properties: { + 'guardlink/verb': r.verb, + ...(claimKeyOf.has(r.at) ? { 'guardlink/claimKey': claimKeyOf.get(r.at) } : {}), + ...r.extra, + }, + })); + }; + + // ── Code flows into a claim ── + const codeFlowsFor = (claim: FindingClaim, verb: 'exposes' | 'confirmed'): { tier: HopTier; flows: SarifCodeFlow[] } | null => { + const ranked = chainsInto(claim.asset).map((chain, order) => { + const last = hopTier(flows[chain[chain.length - 1]].location, claim.location); + const upstreamOnHandler = chain.slice(0, -1).filter(i => hopTier(flows[i].location, claim.location) === 'handler').length; + const crossings = chain.filter(i => crossedBy(flows[i]).length > 0).length; + return { chain, last, score: [last ? TIER_RANK[last] : 0, upstreamOnHandler, crossings, -chain.length, -order] }; + }).sort((x, y) => { + for (let n = 0; n < x.score.length; n++) if (x.score[n] !== y.score[n]) return y.score[n] - x.score[n]; + return 0; + }); + const tier = ranked[0]?.last; + if (!tier) return null; + + const claimMessage = verb === 'exposes' + ? `@exposes ${claim.asset} to ${claim.threat}` + : `@confirmed ${claim.threat} on ${claim.asset}`; + const chosen = ranked.filter(r => r.last === tier).slice(0, MAX_CHAINS_EMITTED); + return { + tier, + flows: chosen.map(({ chain }) => { + const locations: SarifThreadFlowLocation[] = chain.map((i, n) => { + const f = flows[i]; + const route = f.route ?? parseRouteChannel(f.mechanism); + const crosses = crossedBy(f); + const via = f.mechanism ? ` via ${f.mechanism}` : ''; + const note = crosses.length ? ` [crosses ${crosses.map(b => boundaryLabel.get(b)).join(', ')}]` : ''; + const hop = n === chain.length - 1 ? tier : (hopTier(f.location, claim.location) ?? 'graph'); + return { + location: { + physicalLocation: physicalLocation(f.location.file, f.location.line), + message: { text: `${f.source} -> ${f.target}${via}${note}` }, + }, + kinds: crosses.length ? ['flow', 'boundary-crossing'] : ['flow'], + ...(route ? { webRequest: { method: route.method, target: route.path } } : {}), + properties: { + 'guardlink/edge': `flow:${i}`, + ...(claimKeyOf.has(f.location) ? { 'guardlink/claimKey': claimKeyOf.get(f.location) } : {}), + 'guardlink/hopAttribution': hop, + ...(crosses.length ? { 'guardlink/crosses': crosses } : {}), + }, + }; + }); + locations.push({ + location: { + physicalLocation: physicalLocation(claim.location.file, claim.location.line), + message: { text: claimMessage }, + }, + kinds: ['claim'], + importance: 'essential', + }); + return { + message: { text: `Declared @flows chain from ${flows[chain[0]].source} to ${claim.asset}; the last hop is declared on the claim's ${tier === 'handler' ? 'handler' : 'file'}` }, + threadFlows: [{ locations }], + properties: { 'guardlink/flowAttribution': tier }, + }; + }), + }; + }; + + return { + enrich(result, claim, verb) { + const loc = claim.location; + const symbol = loc.parent_symbol || (loc.anchor && loc.anchor.scope !== 'file' ? loc.anchor.symbol : null); + if (symbol) result.locations[0].logicalLocations = [{ name: symbol }]; + + const props = result.properties ?? (result.properties = {}); + if (loc.anchor) { + props['guardlink/anchor'] = { + scope: loc.anchor.scope, symbol: loc.anchor.symbol, + start_line: loc.anchor.start_line, end_line: loc.anchor.end_line, + }; + } + + const chains = codeFlowsFor(claim, verb); + if (chains) props['guardlink/flowAttribution'] = chains.tier; + + const seen = new Set(); + const taxa: SarifTaxonReference[] = []; + for (const ref of claim.external_refs ?? []) { + const t = taxonRef(ref); + if (!t) continue; + const k = `${t.toolComponent.name}\u0000${t.id}`; + if (seen.has(k)) continue; + seen.add(k); + taxa.push(t); + } + if (taxa.length) result.taxa = taxa; + + const related = relatedFor(claim); + if (related.length) result.relatedLocations = related; + + if (chains) result.codeFlows = chains.flows; + }, + + taxonomies() { + return [...taxonomies.entries()].map(([name, t]) => ({ + ...TAXONOMY_INFO[name], + taxa: [...t.taxa.keys()].map(id => ({ id })), + })); + }, + + graph() { + if (nodes.size === 0) return null; + return { + description: { text: 'GuardLink declared threat model: @flows edges (directed) and @boundary edges (undirected). Edge ids are local to this export; guardlink/claimKey is the stable identity.' }, + nodes: [...nodes.values()], + edges, + }; + }, + }; +} diff --git a/src/analyzer/sarif.ts b/src/analyzer/sarif.ts index 66a85aa5..8532ca0f 100644 --- a/src/analyzer/sarif.ts +++ b/src/analyzer/sarif.ts @@ -13,6 +13,14 @@ * 3. Parse errors (annotation syntax problems) * 4. Dangling references (broken #id refs) * + * That list, the order, every rule id and every message are a contract: + * consumers key on the result index and on `message.text`. Everything else the + * model declares around a finding — its enclosing symbol, CWE/OWASP taxa, the + * boundaries and assumptions on its asset, the `@flows` chain into it, the + * whole flow/boundary graph — is APPENDED by `sarif-context.ts` as SARIF + * members those consumers ignore. Stripping them gives back the earlier export + * byte for byte (tests/sarif-enrichment.test.ts). + * * We deliberately emit NOTHING for @entitles. @mitigates and @accepts already * remove an exposure from this export, and a suppression that also prevents * verification is how a threat model becomes confidently wrong. An entitlement @@ -40,6 +48,8 @@ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" + * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" + * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" * @flows ThreatModel -> #sarif via generateSarif -- "Model input" * @flows #sarif -> SarifLog via return -- "SARIF output" */ @@ -54,6 +64,10 @@ import { ACCEPTANCE_REGISTER_ID } from '../parser/acceptance.js'; import { computeAnnotationHash, ANNOTATION_HASH_VERSION } from '../parser/annotation-hash.js'; import { relationRecords, CLAIM_KEY_FINGERPRINT, CLAIM_KEY_PROPERTY } from '../parser/claim-key.js'; import { getPackageVersion } from '../version.js'; +import { + buildSarifContext, physicalLocation, + type SarifLocation, type SarifTaxonReference, type SarifCodeFlow, type SarifGraph, type SarifTaxonomy, +} from './sarif-context.js'; // ─── SARIF 2.1.0 types (subset) ───────────────────────────────────── @@ -99,7 +113,29 @@ interface SarifRun { * rather than by an authenticated decision. */ acceptance_register: typeof ACCEPTANCE_REGISTER_ID; + /** + * The shape of this export, so a consumer can tell which members to expect + * without sniffing for them. Absent on an export cut before the declared + * context (`taxa`, `relatedLocations`, `codeFlows`, `graphs`, …) was added; + * `1` is that additive shape. Bumped only when the shape changes. + */ + sarif_profile_version: typeof SARIF_PROFILE_VERSION; }; + /** Keeps GuardLink results in their own GitHub code scanning category. */ + automationDetails: { id: string }; + /** Present only when the caller knows the repository's web-hosted origin. */ + versionControlProvenance?: { repositoryUri: string; revisionId?: string; branch?: string }[]; + /** CWE and OWASP, holding only the taxa the results reference. */ + taxonomies?: SarifTaxonomy[]; + /** The declared `@flows` / `@boundary` graph. Absent when the model declares nothing. */ + graphs?: SarifGraph[]; +} + +/** Where the export was cut from; a null or absent field is left out of the SARIF. */ +export interface SarifVersionControl { + repositoryUri: string; + revisionId?: string | null; + branch?: string | null; } interface SarifRule { @@ -111,6 +147,7 @@ interface SarifRule { defaultConfiguration: { level: 'error' | 'warning' | 'note'; }; + help?: { text: string; markdown: string }; properties?: Record; } @@ -141,16 +178,12 @@ interface SarifResult { */ partialFingerprints?: Record; properties?: Record; -} - -interface SarifLocation { - physicalLocation: { - artifactLocation: { uri: string }; - region: { - startLine: number; - startColumn?: number; - }; - }; + /** CWE / OWASP refs of the claim, pointing into `run.taxonomies`. */ + taxa?: SarifTaxonReference[]; + /** Context annotations on the claim's asset: @boundary, @assumes, @handles, @transfers, @audit. */ + relatedLocations?: SarifLocation[]; + /** Declared @flows chains into the claim, only when the last hop is the claim's own. */ + codeFlows?: SarifCodeFlow[]; } // ─── Rule definitions ──────────────────────────────────────────────── @@ -198,6 +231,73 @@ const RULES: SarifRule[] = [ }, ]; +/** + * What each rule adds for GitHub code scanning, appended to the rule after its + * existing members so `RULES` itself — ids, names, descriptions, levels — is + * untouched. + * + * `security-severity` is how GitHub bands a security alert (critical ≥ 9.0, + * high 7.0–8.9, medium 4.0–6.9, low < 4.0). Rules are per coverage state, not + * per threat, so a band is the most a rule can say: `unmitigated-critical` + * covers critical AND high exposures and sits in the high band, and + * `unmitigated-exposure` covers medium, low and unset and sits in medium. The + * result's own `properties.severity` keeps the exact value. Diagnostics carry + * no `security-severity` and no `security` tag: an annotation typo is not a + * security finding. + */ +const RULE_EXTRAS: Record> = { + 'guardlink/unmitigated-exposure': { + help: { + text: 'An @exposes annotation declares this risk and no @mitigates or @accepts covers it (GuardLink SPEC §3.6.1). Add the control and record it with @mitigates, or have the accountable person record an @accepts.', + markdown: 'An `@exposes` annotation declares this risk and no `@mitigates` or `@accepts` covers it (GuardLink SPEC §3.6.1 says which ones do).\n\n' + + '- Fix the weakness and record the control with `@mitigates against using `.\n' + + '- Or, if the risk is knowingly taken, have the accountable person record `@accepts`. That is a governance decision, not a code change.\n\n' + + 'Related locations show the boundaries, assumptions, data classes, transfers and audits declared on the same asset; code flows, when present, show the declared `@flows` chain into the claim.', + }, + properties: { tags: ['security', 'threat-model'], 'security-severity': '5.0' }, + }, + 'guardlink/unmitigated-critical': { + help: { + text: 'A critical or high severity @exposes has no @mitigates or @accepts covering it (GuardLink SPEC §3.6.1). Address it before deployment.', + markdown: 'A critical or high severity `@exposes` has no `@mitigates` or `@accepts` covering it (GuardLink SPEC §3.6.1). Address it before deployment.\n\n' + + 'The result\'s `properties.severity` holds the exact severity; this rule covers both critical and high.', + }, + properties: { tags: ['security', 'threat-model'], 'security-severity': '8.9' }, + }, + 'guardlink/confirmed-exploitable': { + help: { + text: 'A @confirmed annotation records that this threat was reproduced by a pentest, scanner or manual test. It is not a false positive.', + markdown: 'A `@confirmed` annotation records that this threat was reproduced by a pentest, a scanner or a manual test. It is not a false positive and needs remediation.', + }, + properties: { tags: ['security', 'threat-model'], 'security-severity': '9.0' }, + }, + 'guardlink/parse-error': { + help: { + text: 'A GuardLink annotation could not be parsed, so the claim it makes is missing from the threat model. Fix its syntax.', + markdown: 'A GuardLink annotation could not be parsed, so the claim it makes is missing from the threat model. Fix its syntax; `guardlink validate .` reports the same error locally.', + }, + properties: { tags: ['threat-model'] }, + }, + 'guardlink/dangling-ref': { + help: { + text: 'An annotation names a #id that no @asset, @threat or @control defines. Define it, or correct the reference.', + markdown: 'An annotation names a `#id` that no `@asset`, `@threat` or `@control` defines. Define it in the definitions file, or correct the reference.', + }, + properties: { tags: ['threat-model'] }, + }, +}; + +/** + * Version of the export's shape, in `runs[0].properties.sarif_profile_version`. + * 1 — the declared context: taxa, relatedLocations, codeFlows, graphs, rule help + * and tags, automationDetails, versionControlProvenance. Purely additive over + * the unversioned export before it. + */ +export const SARIF_PROFILE_VERSION = 1 as const; + +/** `runs[0].automationDetails.id`: the GitHub code scanning category, trailing slash and all. */ +export const SARIF_AUTOMATION_ID = 'guardlink/threat-model/'; + // ─── Generator ─────────────────────────────────────────────────────── export interface SarifOptions { @@ -205,8 +305,21 @@ export interface SarifOptions { includeDiagnostics?: boolean; /** Include dangling reference warnings */ includeDanglingRefs?: boolean; - /** Only include unmitigated exposures at or above this severity */ + /** + * Only include unmitigated exposures at or above this severity. + * + * `@confirmed` results are always included. A confirmed result is a + * reproduced exploit, the strongest evidence this export carries, and a + * severity filter that could drop one would turn a noise control into a way + * of hiding verified findings. + */ minSeverity?: Severity; + /** + * Where the export was cut from, written to `runs[0].versionControlProvenance`. + * The exporter does no I/O, so a front end that knows the repository supplies + * it; without a `repositoryUri` (which SARIF requires) nothing is written. + */ + versionControl?: SarifVersionControl | null; } export function generateSarif( @@ -225,6 +338,10 @@ export function generateSarif( // expense-api, the critical #db → #sqli was one of two findings dropped. const coverage = buildCoverageIndex(model); + // The declared context each finding result gains (sarif-context.ts). Every + // member it adds is appended; nothing above it changes. + const context = buildSarifContext(model); + // Which HTTP route reaches each finding. A route `@flows` (mechanism // "METHOD./path") belongs to the handler it is declared on (SPEC §3.6.2); a // finding the model does not tie to one route is reported ambiguous, with the @@ -298,6 +415,7 @@ export function generateSarif( ...reachabilityFor(e.asset, e.location), }, }); + context.enrich(results[results.length - 1], e, 'exposes'); } // ── Confirmed exploitable ── @@ -323,6 +441,7 @@ export function generateSarif( ...reachabilityFor(c.asset, c.location), }, }); + context.enrich(results[results.length - 1], c, 'confirmed'); } // ── Parse errors ── @@ -350,6 +469,10 @@ export function generateSarif( } } + const vcs = options.versionControl; + const taxonomies = context.taxonomies(); + const graph = context.graph(); + return { $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json', version: '2.1.0', @@ -359,7 +482,7 @@ export function generateSarif( name: 'GuardLink', version: getPackageVersion(), informationUri: 'https://guardlink.bugb.io', - rules: RULES, + rules: RULES.map(rule => ({ ...rule, ...RULE_EXTRAS[rule.id] })), }, }, results, @@ -368,7 +491,18 @@ export function generateSarif( annotation_hash_version: ANNOTATION_HASH_VERSION, generator: `guardlink@${getPackageVersion()}`, acceptance_register: ACCEPTANCE_REGISTER_ID, + sarif_profile_version: SARIF_PROFILE_VERSION, }, + automationDetails: { id: SARIF_AUTOMATION_ID }, + ...(vcs?.repositoryUri ? { + versionControlProvenance: [{ + repositoryUri: vcs.repositoryUri, + ...(vcs.revisionId ? { revisionId: vcs.revisionId } : {}), + ...(vcs.branch ? { branch: vcs.branch } : {}), + }], + } : {}), + ...(taxonomies.length ? { taxonomies } : {}), + ...(graph ? { graphs: [graph] } : {}), }], }; } @@ -410,14 +544,7 @@ export function threatId(asset: string, threat: string, file: string): string { // ─── Helpers ───────────────────────────────────────────────────────── function locationFrom(file: string, line: number): SarifLocation { - // SARIF uses forward-slash URIs - const uri = file.replace(/\\/g, '/'); - return { - physicalLocation: { - artifactLocation: { uri }, - region: { startLine: line }, - }, - }; + return { physicalLocation: physicalLocation(file, line) }; } const SEV_ORDER: Record = { critical: 0, high: 1, medium: 2, low: 3 }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 30da0db6..50fb7695 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -83,7 +83,7 @@ import { explicitDecider, identitySuggestion, formatProposalForReview, formatProposalLine, summarizeDecisions, proposalsPath, type DecisionResult, type EntitlementProposal, type ProposalStatus, } from '../review/entitlements.js'; -import { loadWorkspaceConfig, populateMetadata, mergeReports, resolveReportPaths, mergeVerdict, formatMergeVerdict, formatMergeSummary, diffMergedReports, formatDiffSummary, linkProject, addToWorkspace, removeFromWorkspace, estateReport, formatEstateReport, readMergedReport, NotAMergedReport } from '../workspace/index.js'; +import { loadWorkspaceConfig, populateMetadata, readVersionControl, mergeReports, resolveReportPaths, mergeVerdict, formatMergeVerdict, formatMergeSummary, diffMergedReports, formatDiffSummary, linkProject, addToWorkspace, removeFromWorkspace, estateReport, formatEstateReport, readMergedReport, NotAMergedReport } from '../workspace/index.js'; import type { MergedReport, LinkResult } from '../workspace/index.js'; import type { ThreatModel, ParseDiagnostic, Severity } from '../types/index.js'; import type { VerificationReport, HandoffReport } from '../parser/index.js'; @@ -1214,7 +1214,7 @@ program .argument('[dir]', 'Project directory to scan', '.') .option('-p, --project ', 'Project name (default: the name in .guardlink/config.json)') .option('-o, --output ', 'Write SARIF to file (default: stdout)') - .option('--min-severity ', 'Only include exposures at or above this severity (critical|high|medium|low)') + .option('--min-severity ', 'Only include unmitigated exposures at or above this severity (critical|high|medium|low); @confirmed results are always included') .option('--no-diagnostics', 'Exclude parse errors from SARIF output') .action(async (dir: string, opts: { project: string; output?: string; minSeverity?: string; diagnostics?: boolean }) => { const root = resolve(dir); @@ -1231,6 +1231,7 @@ program includeDiagnostics: opts.diagnostics !== false, includeDanglingRefs: true, minSeverity: opts.minSeverity as any, + versionControl: readVersionControl(root), }, ); diff --git a/src/index.ts b/src/index.ts index 59ae324a..826d8564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ export { generateReport, generateMermaid } from './report/index.js'; export { diffModels, formatDiff, formatDiffMarkdown, parseAtRef } from './diff/index.js'; export type { ThreatModelDiff, DiffSummary, Change, ChangeKind } from './diff/index.js'; export { generateSarif } from './analyzer/index.js'; -export type { SarifOptions } from './analyzer/index.js'; +export type { SarifOptions, SarifVersionControl } from './analyzer/index.js'; export { runCiChecks, formatCiReport, CI_SCHEMA } from './ci/index.js'; export type { CiReport, CiSummary, CiOptions } from './ci/index.js'; export { computeBlame, attachBlame, buildBlamePayload, formatBlameText, readBlameConfig, DEFAULT_TOOL_RULES, BLAME_SCHEMA } from './blame/index.js'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f09fc7bd..e401c3b7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -87,7 +87,7 @@ import { loadWorklist, reachFor, worklistSummaryLine, type Worklist } from '../c import { generateThreatReport, listThreatReports, loadThreatReportsForDashboard, buildConfig, serializeModelCompact, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage, type AnalysisFramework } from '../analyze/index.js'; import { buildAnnotatePrompt } from '../agents/prompts.js'; import { syncAgentFiles } from '../init/index.js'; -import { loadWorkspaceConfig } from '../workspace/index.js'; +import { loadWorkspaceConfig, readVersionControl } from '../workspace/index.js'; import { computeBlame, buildBlamePayload } from '../blame/index.js'; import { getPackageVersion } from '../version.js'; import type { ThreatModel } from '../types/index.js'; @@ -829,7 +829,13 @@ export function createServer(): McpServer { const { model, diagnostics } = await getModel(root); const { writeFile } = await import('node:fs/promises'); const { resolve } = await import('node:path'); - const sarif = generateSarif(model, diagnostics, [], { includeDiagnostics: true, includeDanglingRefs: true }); + // Dangling refs computed exactly as `guardlink sarif` computes them: one + // model must give one SARIF whichever front end exported it. + const sarif = generateSarif(model, diagnostics, findDanglingRefs(model), { + includeDiagnostics: true, + includeDanglingRefs: true, + versionControl: readVersionControl(root), + }); await writeFile(resolve(root, output), JSON.stringify(sarif, null, 2) + '\n'); const resultCount = sarif.runs[0]?.results?.length ?? 0; return { diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 762ad5a5..3412c429 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -44,7 +44,7 @@ import { describeConfigSource } from '../agents/config.js'; import { getReviewableExposures, applyReviewAction, summarizeReview, horizonFrom, ReviewRejected, type ReviewResult } from '../review/index.js'; import { readAcceptancePolicy, ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js'; import { explicitDecider, identitySuggestion } from '../review/entitlements.js'; -import { loadWorkspaceConfig, linkProject, addToWorkspace, removeFromWorkspace, mergeReports, formatMergeSummary, diffMergedReports } from '../workspace/index.js'; +import { loadWorkspaceConfig, readVersionControl, linkProject, addToWorkspace, removeFromWorkspace, mergeReports, formatMergeSummary, diffMergedReports } from '../workspace/index.js'; import type { MergedReport } from '../workspace/index.js'; import { describeCoverage } from '../parser/coverage.js'; @@ -1042,6 +1042,7 @@ export async function cmdSarif(args: string, ctx: TuiContext): Promise { const sarif = generateSarif(ctx.model, diagnostics, danglingDiags, { includeDiagnostics: true, includeDanglingRefs: true, + versionControl: readVersionControl(ctx.root), }); const json = JSON.stringify(sarif, null, 2); diff --git a/src/workspace/index.ts b/src/workspace/index.ts index 9680987b..8079f3ba 100644 --- a/src/workspace/index.ts +++ b/src/workspace/index.ts @@ -13,6 +13,7 @@ export type { export { REPORT_SCHEMA_VERSION, populateMetadata, + readVersionControl, loadWorkspaceConfig, parseWorkspaceYaml, serializeWorkspaceYaml, diff --git a/src/workspace/metadata.ts b/src/workspace/metadata.ts index 5b21e3dd..7f994036 100644 --- a/src/workspace/metadata.ts +++ b/src/workspace/metadata.ts @@ -7,6 +7,9 @@ * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" + * @flows GitConfig -> #report-metadata via readVersionControl -- "remote.origin.url, HEAD and branch for SARIF versionControlProvenance" + * @exposes #report-metadata to #data-exposure [low] cwe:CWE-200 -- "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning" + * @mitigates #report-metadata against #data-exposure using #key-redaction -- "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all" */ import { execSync } from 'node:child_process'; @@ -16,6 +19,7 @@ import { computeAnnotationHash } from '../parser/annotation-hash.js'; import { getPackageVersion } from '../version.js'; import type { ThreatModel, ReportMetadata, ReportParseState, ParseDiagnostic } from '../types/index.js'; import type { WorkspaceConfig } from './types.js'; +import { linksFromRemote } from '../dashboard/links.js'; /** * Current report JSON schema version. @@ -93,6 +97,27 @@ function getBranch(root: string): string | null { } } +/** + * Where a SARIF export is cut from: the repository's web URL, HEAD and branch. + * + * Null when the repository has no origin that `linksFromRemote` accepts — not a + * git checkout, no `origin`, or a local-path remote. SARIF requires a + * `repositoryUri` on every `versionControlProvenance` entry, so without one + * there is nothing honest to write. The URL is rebuilt from hostname and path + * only, so credentials embedded in the remote never reach the export. + */ +export function readVersionControl(root: string): { repositoryUri: string; revisionId: string | null; branch: string | null } | null { + let remote: string; + try { + remote = execSync('git config --get remote.origin.url', { cwd: root, encoding: 'utf-8', stdio: 'pipe' }).trim(); + } catch { + return null; + } + const links = remote ? linksFromRemote(remote) : null; + if (!links) return null; + return { repositoryUri: links.web, revisionId: getCommitSha(root), branch: getBranch(root) }; +} + /** * Try to load workspace.yaml from the project's .guardlink/ directory. * Returns null if not found or invalid. diff --git a/tests/actor-entitlement.test.ts b/tests/actor-entitlement.test.ts index 5c6bd12a..f6e39f80 100644 --- a/tests/actor-entitlement.test.ts +++ b/tests/actor-entitlement.test.ts @@ -606,6 +606,46 @@ describe('§3.2 still holds with the threat slot', () => { }); }); +describe('§3.2 still holds with the declared context in the SARIF', () => { + // taxa, relatedLocations, codeFlows and run.graphs are derived from the model + // around each result, so §3.2 has to be re-proved for them: neither an + // entitlement nor an actor may contribute a related location, a chain, a graph + // node or a taxon. + const file = 'common/archiver/filestore/archiver.go'; + const base = { + assets: [{ path: ['Archiver', 'FS'], id: 'archival-fs', location: loc('.guardlink/definitions.ts', 1) }], + exposures: [{ + asset: '#archival-fs', threat: '#path-traversal', severity: 'high' as const, + external_refs: ['cwe:CWE-22', 'owasp:A01:2021'], description: 'archival URI is used as a filesystem path', + location: loc(file, 61), + }], + flows: [{ source: 'Namespace_Admin', target: '#archival-fs', mechanism: 'archival URI', location: loc(file, 60) }], + boundaries: [{ asset_a: 'Namespace_Admin', asset_b: '#archival-fs', id: 'archive-edge', location: loc(file, 59) }], + assumptions: [{ asset: '#archival-fs', description: 'URI validated by the API layer', location: loc(file, 58) }], + }; + + it('produces byte-identical results, tool, graphs and taxonomies', () => { + const without = generateSarif(model(base as Partial)).runs[0]; + const with_ = generateSarif(model({ + ...(base as Partial), + actors: [actor('ns-admin', 'Namespace_Admin')], + entitlements: [effective()], + })).runs[0]; + // The context is really there, so this is not passing on absent members. + expect(without.results[0].codeFlows).toHaveLength(1); + expect(without.results[0].relatedLocations?.length).toBe(2); + expect(without.results[0].taxa?.length).toBe(2); + expect(without.graphs?.[0].nodes.length).toBe(2); + + const { properties: _w, ...withRun } = with_; + const { properties: _o, ...withoutRun } = without; + expect(JSON.stringify(withRun)).toBe(JSON.stringify(withoutRun)); + // An actor with the same name as a flow endpoint is not promoted to a node + // of its own, nor marked declared. + expect(with_.graphs?.[0].nodes.find(n => n.id === 'namespace_admin')?.properties['guardlink/declared']).toBe(false); + }); +}); + describe('§9.7 diff keys on the join triple', () => { it('reports a capability edit on the same triple as a modification', () => { const diff = diffModels( diff --git a/tests/fixtures/sarif-baseline/README.md b/tests/fixtures/sarif-baseline/README.md new file mode 100644 index 00000000..ab8463bb --- /dev/null +++ b/tests/fixtures/sarif-baseline/README.md @@ -0,0 +1,21 @@ +# SARIF baselines + +`tests/sarif-enrichment.test.ts` proves that the declared context the SARIF +export carries (SPEC §6.6) is additive: it deletes every member that context +adds and compares what is left with these files, byte for byte, on `results` +and on `tool` (the driver version aside). + +| File | What it is | +|---|---| +| `expense-api.sarif` | `guardlink sarif tests/fixtures/expense-api`, cut before the declared context existed | +| `sarif-shop.sarif` | `guardlink sarif tests/fixtures/sarif-shop`, cut the same way | +| `sarif-schema-2.1.0.json` | the OASIS SARIF 2.1.0 JSON schema, unmodified, from `https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json`; every export in the test is validated against it | + +**Do not regenerate the two `.sarif` files to make the test pass.** They are the +earlier export, and the test's claim is that nothing in `results` or `tool` has +moved since. Regenerate them only when a change to `results` or `tool` is +intended — a fixture edit, or a deliberate change to a rule or a message — and +say so in that change, because every consumer keyed on the result index, +`message.text` or a rule id sees it too. To regenerate one, export it with +`guardlink sarif` and delete the members the test's `strip()` deletes, so the +file stays the shape of the export without its declared context. diff --git a/tests/fixtures/sarif-baseline/expense-api.sarif b/tests/fixtures/sarif-baseline/expense-api.sarif new file mode 100644 index 00000000..bce41975 --- /dev/null +++ b/tests/fixtures/sarif-baseline/expense-api.sarif @@ -0,0 +1,458 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "GuardLink", + "version": "2.1.0", + "informationUri": "https://guardlink.bugb.io", + "rules": [ + { + "id": "guardlink/unmitigated-exposure", + "name": "UnmitigatedExposure", + "shortDescription": { + "text": "Asset exposed to threat with no mitigation or acceptance" + }, + "fullDescription": { + "text": "An @exposes annotation exists but no matching @mitigates or @accepts covers this (asset, threat) pair. This represents an acknowledged but unaddressed security risk." + }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { + "level": "warning" + } + }, + { + "id": "guardlink/unmitigated-critical", + "name": "UnmitigatedCriticalExposure", + "shortDescription": { + "text": "Critical/high severity exposure with no mitigation" + }, + "fullDescription": { + "text": "A critical or high severity exposure exists without mitigation. This should be addressed before deployment." + }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { + "level": "error" + } + }, + { + "id": "guardlink/confirmed-exploitable", + "name": "ConfirmedExploitable", + "shortDescription": { + "text": "Threat verified exploitable through testing" + }, + "fullDescription": { + "text": "A @confirmed annotation marks this threat as verified through pentest, scanning, or manual reproduction. This is not a false positive and requires immediate remediation." + }, + "helpUri": "https://guardlink.bugb.io/docs/confirmed", + "defaultConfiguration": { + "level": "error" + } + }, + { + "id": "guardlink/parse-error", + "name": "AnnotationParseError", + "shortDescription": { + "text": "Malformed GuardLink annotation" + }, + "fullDescription": { + "text": "A GuardLink annotation could not be parsed. Check syntax against the specification." + }, + "helpUri": "https://guardlink.bugb.io/docs/syntax", + "defaultConfiguration": { + "level": "error" + } + }, + { + "id": "guardlink/dangling-ref", + "name": "DanglingReference", + "shortDescription": { + "text": "Reference to undefined threat, control, or asset ID" + }, + "fullDescription": { + "text": "An annotation references a #id that is not defined anywhere in the project." + }, + "helpUri": "https://guardlink.bugb.io/docs/definitions", + "defaultConfiguration": { + "level": "warning" + } + } + ] + } + }, + "results": [ + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#auth is exposed to timing: candidate == record['password_hash'] is a plain string compare that short-circuits on the first differing byte, unlike verify_token which uses compare_digest" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/auth.py" + }, + "region": { + "startLine": 36 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-426767ebdbf0", + "guardlink/claimKey": "7118788f2053a51dde1b6f42d15669c90e00e30be9af21e7a951a538af0f2e5e:0" + }, + "properties": { + "threatId": "gl-426767ebdbf0", + "claimKey": "7118788f2053a51dde1b6f42d15669c90e00e30be9af21e7a951a538af0f2e5e:0", + "severity": "low", + "asset": "#auth", + "threat": "#timing", + "externalRefs": [ + "cwe:CWE-208" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#config is exposed to weak-secret: EXPENSE_SECRET falls back to the literal 'dev-secret-change-me'; with it unset, every session token is forgeable by anyone who has read this file" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/config.py" + }, + "region": { + "startLine": 3 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-01a6865ea1eb", + "guardlink/claimKey": "dee8304ad95360193dc7c88caf0c5be2a29573821604de8649080f7d9db40b87:0" + }, + "properties": { + "threatId": "gl-01a6865ea1eb", + "claimKey": "dee8304ad95360193dc7c88caf0c5be2a29573821604de8649080f7d9db40b87:0", + "severity": "high", + "asset": "#config", + "threat": "#weak-secret", + "externalRefs": [ + "cwe:CWE-1188" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#db is exposed to sqli: user_id and category are interpolated into the SELECT with %-formatting; category comes straight from the ?category= query string" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/db.py" + }, + "region": { + "startLine": 9 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-7e48fa8384ec", + "guardlink/claimKey": "f8fdef6933f7f513005749a1110118fe9f96fc14953bff5efada3a02e5957719:0" + }, + "properties": { + "threatId": "gl-7e48fa8384ec", + "claimKey": "f8fdef6933f7f513005749a1110118fe9f96fc14953bff5efada3a02e5957719:0", + "severity": "critical", + "asset": "#db", + "threat": "#sqli", + "externalRefs": [ + "cwe:CWE-89" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#api is exposed to cred-stuffing: POST /login has no rate limit, no lockout and no delay; an attacker can try passwords as fast as the service answers" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/main.py" + }, + "region": { + "startLine": 13 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-8e29ce671483", + "guardlink/claimKey": "2ac9478d02d769e299d37d3afc0cbbce21b662e55f1bef77007b8170d6075b80:0" + }, + "properties": { + "threatId": "gl-8e29ce671483", + "claimKey": "2ac9478d02d769e299d37d3afc0cbbce21b662e55f1bef77007b8170d6075b80:0", + "severity": "medium", + "asset": "#api", + "threat": "#cred-stuffing", + "externalRefs": [ + "cwe:CWE-307" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#api is exposed to malformed-input: body['username'] and body['password'] are indexed directly after get_json(force=True), so a request missing either raises KeyError and returns a 500" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/main.py" + }, + "region": { + "startLine": 13 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-6680fd8153c7", + "guardlink/claimKey": "fef9b5e5c7f7652908d2750e10446aa4c2b4140bfb84b985b8324a967f67304d:0" + }, + "properties": { + "threatId": "gl-6680fd8153c7", + "claimKey": "fef9b5e5c7f7652908d2750e10446aa4c2b4140bfb84b985b8324a967f67304d:0", + "severity": "low", + "asset": "#api", + "threat": "#malformed-input", + "externalRefs": [ + "cwe:CWE-248" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#api is exposed to ssrf: body['webhook'] is handed to notify.send_webhook with no validation, so an authenticated user chooses what host the server calls" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/main.py" + }, + "region": { + "startLine": 31 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-4319a5d29978", + "guardlink/claimKey": "c0584a02d3fb51b70be151edf1cd583d0637a8c0a5a2bf402cf004be7f81f848:0" + }, + "properties": { + "threatId": "gl-4319a5d29978", + "claimKey": "c0584a02d3fb51b70be151edf1cd583d0637a8c0a5a2bf402cf004be7f81f848:0", + "severity": "high", + "asset": "#api", + "threat": "#ssrf", + "externalRefs": [ + "cwe:CWE-918" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#api is exposed to malformed-input: body['amount'] and body['note'] are indexed directly; a request missing either is a 500" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/main.py" + }, + "region": { + "startLine": 31 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-6680fd8153c7", + "guardlink/claimKey": "722e3a8b08d5a4ba30dd87a74410dcbbc0165245424b74a787c82b124b0380c1:0" + }, + "properties": { + "threatId": "gl-6680fd8153c7", + "claimKey": "722e3a8b08d5a4ba30dd87a74410dcbbc0165245424b74a787c82b124b0380c1:0", + "severity": "low", + "asset": "#api", + "threat": "#malformed-input", + "externalRefs": [ + "cwe:CWE-248" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#api is exposed to path-traversal: from the URL goes to storage.read_receipt unmodified; the route does no containment check of its own" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/main.py" + }, + "region": { + "startLine": 43 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-7853561aee28", + "guardlink/claimKey": "3e643396efdbb8ca7aff653b587d87193978511ac46af49193c15e4a6d3fbf69:0" + }, + "properties": { + "threatId": "gl-7853561aee28", + "claimKey": "3e643396efdbb8ca7aff653b587d87193978511ac46af49193c15e4a6d3fbf69:0", + "severity": "high", + "asset": "#api", + "threat": "#path-traversal", + "externalRefs": [ + "cwe:CWE-22" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#notify is exposed to ssrf: url is body['webhook'] from POST /expenses, POSTed unvalidated — no scheme check, no host allowlist, so 169.254.169.254 and http://localhost/ are both reachable" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/notify.py" + }, + "region": { + "startLine": 5 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-36c34d53b7da", + "guardlink/claimKey": "f4cd6070e743fdd8f285e1c96d883973f630865d7ca3592d030f9c8399f9278d:0" + }, + "properties": { + "threatId": "gl-36c34d53b7da", + "claimKey": "f4cd6070e743fdd8f285e1c96d883973f630865d7ca3592d030f9c8399f9278d:0", + "severity": "high", + "asset": "#notify", + "threat": "#ssrf", + "externalRefs": [ + "cwe:CWE-918" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#receipts is exposed to path-traversal: filename is joined onto the upload root with no normalisation or containment check; os.path.join also discards the prefix entirely if filename is absolute" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/storage.py" + }, + "region": { + "startLine": 5 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-9304bd8ad08c", + "guardlink/claimKey": "55f8c49c0f98bb657105f3c015a1741a41c47321a620c8340a943cf1850d0435:0" + }, + "properties": { + "threatId": "gl-9304bd8ad08c", + "claimKey": "55f8c49c0f98bb657105f3c015a1741a41c47321a620c8340a943cf1850d0435:0", + "severity": "high", + "asset": "#receipts", + "threat": "#path-traversal", + "externalRefs": [ + "cwe:CWE-22" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#receipts is exposed to path-traversal: Same unchecked join as the read path, but writing: a traversing filename creates directories and a file outside the upload root" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "app/storage.py" + }, + "region": { + "startLine": 16 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-9304bd8ad08c", + "guardlink/claimKey": "d02957a66a307defa7a74b632419cd0db787dcb875047f8d1fd9daeb2c705046:0" + }, + "properties": { + "threatId": "gl-9304bd8ad08c", + "claimKey": "d02957a66a307defa7a74b632419cd0db787dcb875047f8d1fd9daeb2c705046:0", + "severity": "high", + "asset": "#receipts", + "threat": "#path-traversal", + "externalRefs": [ + "cwe:CWE-22" + ] + } + } + ], + "properties": { + "annotation_hash": "sha256-v3:81168ffb1ea44c87c243a36fcb00eeb910cc9b425a8873de9b680913ec1d2f83", + "annotation_hash_version": 3, + "generator": "guardlink@2.1.0", + "acceptance_register": "code-annotations" + } + } + ] +} diff --git a/tests/fixtures/sarif-baseline/sarif-schema-2.1.0.json b/tests/fixtures/sarif-baseline/sarif-schema-2.1.0.json new file mode 100644 index 00000000..0f58372b --- /dev/null +++ b/tests/fixtures/sarif-baseline/sarif-schema-2.1.0.json @@ -0,0 +1,3389 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema", + "id": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json", + "description": "Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema: a standard format for the output of static analysis tools.", + "additionalProperties": false, + "type": "object", + "properties": { + + "$schema": { + "description": "The URI of the JSON schema corresponding to the version.", + "type": "string", + "format": "uri" + }, + + "version": { + "description": "The SARIF format version of this log file.", + "enum": [ "2.1.0" ], + "type": "string" + }, + + "runs": { + "description": "The set of runs contained in this log file.", + "type": [ "array", "null" ], + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/run" + } + }, + + "inlineExternalProperties": { + "description": "References to external property files that share data between runs.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/externalProperties" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the log file.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "version", "runs" ], + + "definitions": { + + "address": { + "description": "A physical or virtual address, or a range of addresses, in an 'addressable region' (memory or a binary file).", + "additionalProperties": false, + "type": "object", + "properties": { + + "absoluteAddress": { + "description": "The address expressed as a byte offset from the start of the addressable region.", + "type": "integer", + "minimum": -1, + "default": -1 + + }, + + "relativeAddress": { + "description": "The address expressed as a byte offset from the absolute address of the top-most parent object.", + "type": "integer" + + }, + + "length": { + "description": "The number of bytes in this range of addresses.", + "type": "integer" + }, + + "kind": { + "description": "An open-ended string that identifies the address kind. 'data', 'function', 'header','instruction', 'module', 'page', 'section', 'segment', 'stack', 'stackFrame', 'table' are well-known values.", + "type": "string" + }, + + "name": { + "description": "A name that is associated with the address, e.g., '.text'.", + "type": "string" + }, + + "fullyQualifiedName": { + "description": "A human-readable fully qualified name that is associated with the address.", + "type": "string" + }, + + "offsetFromParent": { + "description": "The byte offset of this address from the absolute or relative address of the parent object.", + "type": "integer" + }, + + "index": { + "description": "The index within run.addresses of the cached object for this address.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "parentIndex": { + "description": "The index within run.addresses of the parent object.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the address.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifact": { + "description": "A single artifact. In some cases, this artifact might be nested within another artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "description": { + "description": "A short description of the artifact.", + "$ref": "#/definitions/message" + }, + + "location": { + "description": "The location of the artifact.", + "$ref": "#/definitions/artifactLocation" + }, + + "parentIndex": { + "description": "Identifies the index of the immediate parent of the artifact, if this artifact is nested.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "offset": { + "description": "The offset in bytes of the artifact within its containing artifact.", + "type": "integer", + "minimum": 0 + }, + + "length": { + "description": "The length of the artifact in bytes.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "roles": { + "description": "The role or roles played by the artifact in the analysis.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "enum": [ + "analysisTarget", + "attachment", + "responseFile", + "resultFile", + "standardStream", + "tracedFile", + "unmodified", + "modified", + "added", + "deleted", + "renamed", + "uncontrolled", + "driver", + "extension", + "translation", + "taxonomy", + "policy", + "referencedOnCommandLine", + "memoryContents", + "directory", + "userSpecifiedConfiguration", + "toolSpecifiedConfiguration", + "debugOutputFile" + ], + "type": "string" + } + }, + + "mimeType": { + "description": "The MIME type (RFC 2045) of the artifact.", + "type": "string", + "pattern": "[^/]+/.+" + }, + + "contents": { + "description": "The contents of the artifact.", + "$ref": "#/definitions/artifactContent" + }, + + "encoding": { + "description": "Specifies the encoding for an artifact object that refers to a text file.", + "type": "string" + }, + + "sourceLanguage": { + "description": "Specifies the source language for any artifact object that refers to a text file that contains source code.", + "type": "string" + }, + + "hashes": { + "description": "A dictionary, each of whose keys is the name of a hash function and each of whose values is the hashed value of the artifact produced by the specified hash function.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "lastModifiedTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the artifact was most recently modified. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifactChange": { + "description": "A change to a single artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "artifactLocation": { + "description": "The location of the artifact to change.", + "$ref": "#/definitions/artifactLocation" + }, + + "replacements": { + "description": "An array of replacement objects, each of which represents the replacement of a single region in a single artifact specified by 'artifactLocation'.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/replacement" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the change.", + "$ref": "#/definitions/propertyBag" + } + + }, + + "required": [ "artifactLocation", "replacements" ] + }, + + "artifactContent": { + "description": "Represents the contents of an artifact.", + "type": "object", + "additionalProperties": false, + "properties": { + + "text": { + "description": "UTF-8-encoded content from a text artifact.", + "type": "string" + }, + + "binary": { + "description": "MIME Base64-encoded content from a binary artifact, or from a text artifact in its original encoding.", + "type": "string" + }, + + "rendered": { + "description": "An alternate rendered representation of the artifact (e.g., a decompiled representation of a binary region).", + "$ref": "#/definitions/multiformatMessageString" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact content.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifactLocation": { + "description": "Specifies the location of an artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "uri": { + "description": "A string containing a valid relative or absolute URI.", + "type": "string", + "format": "uri-reference" + }, + + "uriBaseId": { + "description": "A string which indirectly specifies the absolute URI with respect to which a relative URI in the \"uri\" property is interpreted.", + "type": "string" + }, + + "index": { + "description": "The index within the run artifacts array of the artifact object associated with the artifact location.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "description": { + "description": "A short description of the artifact location.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "attachment": { + "description": "An artifact relevant to a result.", + "type": "object", + "additionalProperties": false, + "properties": { + + "description": { + "description": "A message describing the role played by the attachment.", + "$ref": "#/definitions/message" + }, + + "artifactLocation": { + "description": "The location of the attachment.", + "$ref": "#/definitions/artifactLocation" + }, + + "regions": { + "description": "An array of regions of interest within the attachment.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/region" + } + }, + + "rectangles": { + "description": "An array of rectangles specifying areas of interest within the image.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/rectangle" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the attachment.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "artifactLocation" ] + }, + + "codeFlow": { + "description": "A set of threadFlows which together describe a pattern of code execution relevant to detecting a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "message": { + "description": "A message relevant to the code flow.", + "$ref": "#/definitions/message" + }, + + "threadFlows": { + "description": "An array of one or more unique threadFlow objects, each of which describes the progress of a program through a thread of execution.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/threadFlow" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the code flow.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "threadFlows" ] + }, + + "configurationOverride": { + "description": "Information about how a specific rule or notification was reconfigured at runtime.", + "type": "object", + "additionalProperties": false, + "properties": { + + "configuration": { + "description": "Specifies how the rule or notification was configured during the scan.", + "$ref": "#/definitions/reportingConfiguration" + }, + + "descriptor": { + "description": "A reference used to locate the descriptor whose configuration was overridden.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the configuration override.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "configuration", "descriptor" ] + }, + + "conversion": { + "description": "Describes how a converter transformed the output of a static analysis tool from the analysis tool's native output format into the SARIF format.", + "additionalProperties": false, + "type": "object", + "properties": { + + "tool": { + "description": "A tool object that describes the converter.", + "$ref": "#/definitions/tool" + }, + + "invocation": { + "description": "An invocation object that describes the invocation of the converter.", + "$ref": "#/definitions/invocation" + }, + + "analysisToolLogFiles": { + "description": "The locations of the analysis tool's per-run log files.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the conversion.", + "$ref": "#/definitions/propertyBag" + } + + }, + + "required": [ "tool" ] + }, + + "edge": { + "description": "Represents a directed edge in a graph.", + "type": "object", + "additionalProperties": false, + "properties": { + + "id": { + "description": "A string that uniquely identifies the edge within its graph.", + "type": "string" + }, + + "label": { + "description": "A short description of the edge.", + "$ref": "#/definitions/message" + }, + + "sourceNodeId": { + "description": "Identifies the source node (the node at which the edge starts).", + "type": "string" + }, + + "targetNodeId": { + "description": "Identifies the target node (the node at which the edge ends).", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the edge.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "id", "sourceNodeId", "targetNodeId" ] + }, + + "edgeTraversal": { + "description": "Represents the traversal of a single edge during a graph traversal.", + "type": "object", + "additionalProperties": false, + "properties": { + + "edgeId": { + "description": "Identifies the edge being traversed.", + "type": "string" + }, + + "message": { + "description": "A message to display to the user as the edge is traversed.", + "$ref": "#/definitions/message" + }, + + "finalState": { + "description": "The values of relevant expressions after the edge has been traversed.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "stepOverEdgeCount": { + "description": "The number of edge traversals necessary to return from a nested graph.", + "type": "integer", + "minimum": 0 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the edge traversal.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "edgeId" ] + }, + + "exception": { + "description": "Describes a runtime exception encountered during the execution of an analysis tool.", + "type": "object", + "additionalProperties": false, + "properties": { + + "kind": { + "type": "string", + "description": "A string that identifies the kind of exception, for example, the fully qualified type name of an object that was thrown, or the symbolic name of a signal." + }, + + "message": { + "description": "A message that describes the exception.", + "type": "string" + }, + + "stack": { + "description": "The sequence of function calls leading to the exception.", + "$ref": "#/definitions/stack" + }, + + "innerExceptions": { + "description": "An array of exception objects each of which is considered a cause of this exception.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/exception" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the exception.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "externalProperties": { + "description": "The top-level element of an external property file.", + "type": "object", + "additionalProperties": false, + "properties": { + + "schema": { + "description": "The URI of the JSON schema corresponding to the version of the external property file format.", + "type": "string", + "format": "uri" + }, + + "version": { + "description": "The SARIF format version of this external properties object.", + "enum": [ "2.1.0" ], + "type": "string" + }, + + "guid": { + "description": "A stable, unique identifier for this external properties object, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "runGuid": { + "description": "A stable, unique identifier for the run associated with this external properties object, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "conversion": { + "description": "A conversion object that will be merged with a separate run.", + "$ref": "#/definitions/conversion" + }, + + "graphs": { + "description": "An array of graph objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/graph" + } + }, + + "externalizedProperties": { + "description": "Key/value pairs that provide additional information that will be merged with a separate run.", + "$ref": "#/definitions/propertyBag" + }, + + "artifacts": { + "description": "An array of artifact objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifact" + } + }, + + "invocations": { + "description": "Describes the invocation of the analysis tool that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/invocation" + } + }, + + "logicalLocations": { + "description": "An array of logical locations such as namespaces, types or functions that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "threadFlowLocations": { + "description": "An array of threadFlowLocation objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "results": { + "description": "An array of result objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/result" + } + }, + + "taxonomies": { + "description": "Tool taxonomies that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "driver": { + "description": "The analysis tool object that will be merged with a separate run.", + "$ref": "#/definitions/toolComponent" + }, + + "extensions": { + "description": "Tool extensions that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "policies": { + "description": "Tool policies that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "translations": { + "description": "Tool translations that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "addresses": { + "description": "Addresses that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/address" + } + }, + + "webRequests": { + "description": "Requests that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webRequest" + } + }, + + "webResponses": { + "description": "Responses that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webResponse" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external properties.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "externalPropertyFileReference": { + "description": "Contains information that enables a SARIF consumer to locate the external property file that contains the value of an externalized property associated with the run.", + "type": "object", + "additionalProperties": false, + "properties": { + + "location": { + "description": "The location of the external property file.", + "$ref": "#/definitions/artifactLocation" + }, + + "guid": { + "description": "A stable, unique identifier for the external property file in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "itemCount": { + "description": "A non-negative integer specifying the number of items contained in the external property file.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external property file.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [ + { "required": [ "location" ] }, + { "required": [ "guid" ] } + ] + }, + + "externalPropertyFileReferences": { + "description": "References to external property files that should be inlined with the content of a root log file.", + "additionalProperties": false, + "type": "object", + "properties": { + + "conversion": { + "description": "An external property file containing a run.conversion object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "graphs": { + "description": "An array of external property files containing a run.graphs object to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "externalizedProperties": { + "description": "An external property file containing a run.properties object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "artifacts": { + "description": "An array of external property files containing run.artifacts arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "invocations": { + "description": "An array of external property files containing run.invocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "logicalLocations": { + "description": "An array of external property files containing run.logicalLocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "threadFlowLocations": { + "description": "An array of external property files containing run.threadFlowLocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "results": { + "description": "An array of external property files containing run.results arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "taxonomies": { + "description": "An array of external property files containing run.taxonomies arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "addresses": { + "description": "An array of external property files containing run.addresses arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "driver": { + "description": "An external property file containing a run.driver object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "extensions": { + "description": "An array of external property files containing run.extensions arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "policies": { + "description": "An array of external property files containing run.policies arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "translations": { + "description": "An array of external property files containing run.translations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "webRequests": { + "description": "An array of external property files containing run.requests arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "webResponses": { + "description": "An array of external property files containing run.responses arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external property files.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "fix": { + "description": "A proposed fix for the problem represented by a result object. A fix specifies a set of artifacts to modify. For each artifact, it specifies a set of bytes to remove, and provides a set of new bytes to replace them.", + "additionalProperties": false, + "type": "object", + "properties": { + + "description": { + "description": "A message that describes the proposed fix, enabling viewers to present the proposed change to an end user.", + "$ref": "#/definitions/message" + }, + + "artifactChanges": { + "description": "One or more artifact changes that comprise a fix for a result.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifactChange" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the fix.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "artifactChanges" ] + }, + + "graph": { + "description": "A network of nodes and directed edges that describes some aspect of the structure of the code (for example, a call graph).", + "type": "object", + "additionalProperties": false, + "properties": { + + "description": { + "description": "A description of the graph.", + "$ref": "#/definitions/message" + }, + + "nodes": { + "description": "An array of node objects representing the nodes of the graph.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/node" + } + }, + + "edges": { + "description": "An array of edge objects representing the edges of the graph.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/edge" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the graph.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "graphTraversal": { + "description": "Represents a path through a graph.", + "type": "object", + "additionalProperties": false, + "properties": { + + "runGraphIndex": { + "description": "The index within the run.graphs to be associated with the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "resultGraphIndex": { + "description": "The index within the result.graphs to be associated with the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "description": { + "description": "A description of this graph traversal.", + "$ref": "#/definitions/message" + }, + + "initialState": { + "description": "Values of relevant expressions at the start of the graph traversal that may change during graph traversal.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "immutableState": { + "description": "Values of relevant expressions at the start of the graph traversal that remain constant for the graph traversal.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "edgeTraversals": { + "description": "The sequences of edges traversed by this graph traversal.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/edgeTraversal" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the graph traversal.", + "$ref": "#/definitions/propertyBag" + } + }, + "oneOf": [ + { "required": [ "runGraphIndex" ] }, + { "required": [ "resultGraphIndex" ] } + ] + }, + + "invocation": { + "description": "The runtime environment of the analysis tool run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "commandLine": { + "description": "The command line used to invoke the tool.", + "type": "string" + }, + + "arguments": { + "description": "An array of strings, containing in order the command line arguments passed to the tool from the operating system.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "type": "string" + } + }, + + "responseFiles": { + "description": "The locations of any response files specified on the tool's command line.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "startTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the invocation started. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "endTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the invocation ended. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "exitCode": { + "description": "The process exit code.", + "type": "integer" + }, + + "ruleConfigurationOverrides": { + "description": "An array of configurationOverride objects that describe rules related runtime overrides.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/configurationOverride" + } + }, + + "notificationConfigurationOverrides": { + "description": "An array of configurationOverride objects that describe notifications related runtime overrides.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/configurationOverride" + } + }, + + "toolExecutionNotifications": { + "description": "A list of runtime conditions detected by the tool during the analysis.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/notification" + } + }, + + "toolConfigurationNotifications": { + "description": "A list of conditions detected by the tool that are relevant to the tool's configuration.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/notification" + } + }, + + "exitCodeDescription": { + "description": "The reason for the process exit.", + "type": "string" + }, + + "exitSignalName": { + "description": "The name of the signal that caused the process to exit.", + "type": "string" + }, + + "exitSignalNumber": { + "description": "The numeric value of the signal that caused the process to exit.", + "type": "integer" + }, + + "processStartFailureMessage": { + "description": "The reason given by the operating system that the process failed to start.", + "type": "string" + }, + + "executionSuccessful": { + "description": "Specifies whether the tool's execution completed successfully.", + "type": "boolean" + }, + + "machine": { + "description": "The machine on which the invocation occurred.", + "type": "string" + }, + + "account": { + "description": "The account under which the invocation occurred.", + "type": "string" + }, + + "processId": { + "description": "The id of the process in which the invocation occurred.", + "type": "integer" + }, + + "executableLocation": { + "description": "An absolute URI specifying the location of the executable that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "workingDirectory": { + "description": "The working directory for the invocation.", + "$ref": "#/definitions/artifactLocation" + }, + + "environmentVariables": { + "description": "The environment variables associated with the analysis tool process, expressed as key/value pairs.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "stdin": { + "description": "A file containing the standard input stream to the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stdout": { + "description": "A file containing the standard output stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stderr": { + "description": "A file containing the standard error stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stdoutStderr": { + "description": "A file containing the interleaved standard output and standard error stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the invocation.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "executionSuccessful" ] + }, + + "location": { + "description": "A location within a programming artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "id": { + "description": "Value that distinguishes this location from all other locations within a single result object.", + "type": "integer", + "minimum": -1, + "default": -1 + }, + + "physicalLocation": { + "description": "Identifies the artifact and region.", + "$ref": "#/definitions/physicalLocation" + }, + + "logicalLocations": { + "description": "The logical locations associated with the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "message": { + "description": "A message relevant to the location.", + "$ref": "#/definitions/message" + }, + + "annotations": { + "description": "A set of regions relevant to the location.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/region" + } + }, + + "relationships": { + "description": "An array of objects that describe relationships between this location and others.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/locationRelationship" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "locationRelationship": { + "description": "Information about the relation of one location to another.", + "type": "object", + "additionalProperties": false, + "properties": { + + "target": { + "description": "A reference to the related location.", + "type": "integer", + "minimum": 0 + }, + + "kinds": { + "description": "A set of distinct strings that categorize the relationship. Well-known kinds include 'includes', 'isIncludedBy' and 'relevant'.", + "type": "array", + "default": [ "relevant" ], + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "description": { + "description": "A description of the location relationship.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the location relationship.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "target" ] + }, + + "logicalLocation": { + "description": "A logical location of a construct that produced a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "name": { + "description": "Identifies the construct in which the result occurred. For example, this property might contain the name of a class or a method.", + "type": "string" + }, + + "index": { + "description": "The index within the logical locations array.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "fullyQualifiedName": { + "description": "The human-readable fully qualified name of the logical location.", + "type": "string" + }, + + "decoratedName": { + "description": "The machine-readable name for the logical location, such as a mangled function name provided by a C++ compiler that encodes calling convention, return type and other details along with the function name.", + "type": "string" + }, + + "parentIndex": { + "description": "Identifies the index of the immediate parent of the construct in which the result was detected. For example, this property might point to a logical location that represents the namespace that holds a type.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "kind": { + "description": "The type of construct this logical location component refers to. Should be one of 'function', 'member', 'module', 'namespace', 'parameter', 'resource', 'returnType', 'type', 'variable', 'object', 'array', 'property', 'value', 'element', 'text', 'attribute', 'comment', 'declaration', 'dtd' or 'processingInstruction', if any of those accurately describe the construct.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the logical location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "message": { + "description": "Encapsulates a message intended to be read by the end user.", + "type": "object", + "additionalProperties": false, + + "properties": { + + "text": { + "description": "A plain text message string.", + "type": "string" + }, + + "markdown": { + "description": "A Markdown message string.", + "type": "string" + }, + + "id": { + "description": "The identifier for this message.", + "type": "string" + }, + + "arguments": { + "description": "An array of strings to substitute into the message string.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "type": "string" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the message.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [ + { "required": [ "text" ] }, + { "required": [ "id" ] } + ] + }, + + "multiformatMessageString": { + "description": "A message string or message format string rendered in multiple formats.", + "type": "object", + "additionalProperties": false, + + "properties": { + + "text": { + "description": "A plain text message string or format string.", + "type": "string" + }, + + "markdown": { + "description": "A Markdown message string or format string.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the message.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "text" ] + }, + + "node": { + "description": "Represents a node in a graph.", + "type": "object", + "additionalProperties": false, + + "properties": { + + "id": { + "description": "A string that uniquely identifies the node within its graph.", + "type": "string" + }, + + "label": { + "description": "A short description of the node.", + "$ref": "#/definitions/message" + }, + + "location": { + "description": "A code location associated with the node.", + "$ref": "#/definitions/location" + }, + + "children": { + "description": "Array of child nodes.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/node" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the node.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "id" ] + }, + + "notification": { + "description": "Describes a condition relevant to the tool itself, as opposed to being relevant to a target being analyzed by the tool.", + "type": "object", + "additionalProperties": false, + "properties": { + + "locations": { + "description": "The locations relevant to this notification.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "message": { + "description": "A message that describes the condition that was encountered.", + "$ref": "#/definitions/message" + }, + + "level": { + "description": "A value specifying the severity level of the notification.", + "default": "warning", + "enum": [ "none", "note", "warning", "error" ], + "type": "string" + }, + + "threadId": { + "description": "The thread identifier of the code that generated the notification.", + "type": "integer" + }, + + "timeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the analysis tool generated the notification.", + "type": "string", + "format": "date-time" + }, + + "exception": { + "description": "The runtime exception, if any, relevant to this notification.", + "$ref": "#/definitions/exception" + }, + + "descriptor": { + "description": "A reference used to locate the descriptor relevant to this notification.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "associatedRule": { + "description": "A reference used to locate the rule descriptor associated with this notification.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the notification.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "message" ] + }, + + "physicalLocation": { + "description": "A physical location relevant to a result. Specifies a reference to a programming artifact together with a range of bytes or characters within that artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "address": { + "description": "The address of the location.", + "$ref": "#/definitions/address" + }, + + "artifactLocation": { + "description": "The location of the artifact.", + "$ref": "#/definitions/artifactLocation" + }, + + "region": { + "description": "Specifies a portion of the artifact.", + "$ref": "#/definitions/region" + }, + + "contextRegion": { + "description": "Specifies a portion of the artifact that encloses the region. Allows a viewer to display additional context around the region.", + "$ref": "#/definitions/region" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the physical location.", + "$ref": "#/definitions/propertyBag" + } + }, + + "anyOf": [ + { + "required": [ "address" ] + }, + { + "required": [ "artifactLocation" ] + } + ] + }, + + "propertyBag": { + "description": "Key/value pairs that provide additional information about the object.", + "type": "object", + "additionalProperties": true, + "properties": { + "tags": { + + "description": "A set of distinct strings that provide additional information.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + } + } + }, + + "rectangle": { + "description": "An area within an image.", + "additionalProperties": false, + "type": "object", + "properties": { + + "top": { + "description": "The Y coordinate of the top edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "left": { + "description": "The X coordinate of the left edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "bottom": { + "description": "The Y coordinate of the bottom edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "right": { + "description": "The X coordinate of the right edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "message": { + "description": "A message relevant to the rectangle.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the rectangle.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "region": { + "description": "A region within an artifact where a result was detected.", + "additionalProperties": false, + "type": "object", + "properties": { + + "startLine": { + "description": "The line number of the first character in the region.", + "type": "integer", + "minimum": 1 + }, + + "startColumn": { + "description": "The column number of the first character in the region.", + "type": "integer", + "minimum": 1 + }, + + "endLine": { + "description": "The line number of the last character in the region.", + "type": "integer", + "minimum": 1 + }, + + "endColumn": { + "description": "The column number of the character following the end of the region.", + "type": "integer", + "minimum": 1 + }, + + "charOffset": { + "description": "The zero-based offset from the beginning of the artifact of the first character in the region.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "charLength": { + "description": "The length of the region in characters.", + "type": "integer", + "minimum": 0 + }, + + "byteOffset": { + "description": "The zero-based offset from the beginning of the artifact of the first byte in the region.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "byteLength": { + "description": "The length of the region in bytes.", + "type": "integer", + "minimum": 0 + }, + + "snippet": { + "description": "The portion of the artifact contents within the specified region.", + "$ref": "#/definitions/artifactContent" + }, + + "message": { + "description": "A message relevant to the region.", + "$ref": "#/definitions/message" + }, + + "sourceLanguage": { + "description": "Specifies the source language, if any, of the portion of the artifact specified by the region object.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the region.", + "$ref": "#/definitions/propertyBag" + } + }, + + "anyOf": [ + { "required": [ "startLine" ] }, + { "required": [ "charOffset" ] }, + { "required": [ "byteOffset" ] } + ] + }, + + "replacement": { + "description": "The replacement of a single region of an artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "deletedRegion": { + "description": "The region of the artifact to delete.", + "$ref": "#/definitions/region" + }, + + "insertedContent": { + "description": "The content to insert at the location specified by the 'deletedRegion' property.", + "$ref": "#/definitions/artifactContent" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the replacement.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "deletedRegion" ] + }, + + "reportingDescriptor": { + "description": "Metadata that describes a specific report produced by the tool, as part of the analysis it provides or its runtime reporting.", + "additionalProperties": false, + "type": "object", + "properties": { + + "id": { + "description": "A stable, opaque identifier for the report.", + "type": "string" + }, + + "deprecatedIds": { + "description": "An array of stable, opaque identifiers by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "guid": { + "description": "A unique identifier for the reporting descriptor in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "deprecatedGuids": { + "description": "An array of unique identifies in the form of a GUID by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + } + }, + + "name": { + "description": "A report identifier that is understandable to an end user.", + "type": "string" + }, + + "deprecatedNames": { + "description": "An array of readable identifiers by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "shortDescription": { + "description": "A concise description of the report. Should be a single sentence that is understandable when visible space is limited to a single line of text.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A description of the report. Should, as far as possible, provide details sufficient to enable resolution of any problem indicated by the result.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "messageStrings": { + "description": "A set of name/value pairs with arbitrary names. Each value is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "defaultConfiguration": { + "description": "Default reporting configuration information.", + "$ref": "#/definitions/reportingConfiguration" + }, + + "helpUri": { + "description": "A URI where the primary documentation for the report can be found.", + "type": "string", + "format": "uri" + }, + + "help": { + "description": "Provides the primary documentation for the report, useful when there is no online documentation.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "relationships": { + "description": "An array of objects that describe relationships between this reporting descriptor and others.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorRelationship" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the report.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "id" ] + }, + + "reportingConfiguration": { + "description": "Information about a rule or notification that can be configured at runtime.", + "type": "object", + "additionalProperties": false, + "properties": { + + "enabled": { + "description": "Specifies whether the report may be produced during the scan.", + "type": "boolean", + "default": true + }, + + "level": { + "description": "Specifies the failure level for the report.", + "default": "warning", + "enum": [ "none", "note", "warning", "error" ], + "type": "string" + }, + + "rank": { + "description": "Specifies the relative priority of the report. Used for analysis output only.", + "type": "number", + "default": -1.0, + "minimum": -1.0, + "maximum": 100.0 + }, + + "parameters": { + "description": "Contains configuration information specific to a report.", + "$ref": "#/definitions/propertyBag" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting configuration.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "reportingDescriptorReference": { + "description": "Information about how to locate a relevant reporting descriptor.", + "type": "object", + "additionalProperties": false, + "properties": { + + "id": { + "description": "The id of the descriptor.", + "type": "string" + }, + + "index": { + "description": "The index into an array of descriptors in toolComponent.ruleDescriptors, toolComponent.notificationDescriptors, or toolComponent.taxonomyDescriptors, depending on context.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "guid": { + "description": "A guid that uniquely identifies the descriptor.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "toolComponent": { + "description": "A reference used to locate the toolComponent associated with the descriptor.", + "$ref": "#/definitions/toolComponentReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting descriptor reference.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [ + { "required": [ "index" ] }, + { "required": [ "guid" ] }, + { "required": [ "id" ] } + ] + }, + + "reportingDescriptorRelationship": { + "description": "Information about the relation of one reporting descriptor to another.", + "type": "object", + "additionalProperties": false, + "properties": { + + "target": { + "description": "A reference to the related reporting descriptor.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "kinds": { + "description": "A set of distinct strings that categorize the relationship. Well-known kinds include 'canPrecede', 'canFollow', 'willPrecede', 'willFollow', 'superset', 'subset', 'equal', 'disjoint', 'relevant', and 'incomparable'.", + "type": "array", + "default": [ "relevant" ], + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "description": { + "description": "A description of the reporting descriptor relationship.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting descriptor reference.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "target" ] + }, + + "result": { + "description": "A result produced by an analysis tool.", + "additionalProperties": false, + "type": "object", + "properties": { + + "ruleId": { + "description": "The stable, unique identifier of the rule, if any, to which this result is relevant.", + "type": "string" + }, + + "ruleIndex": { + "description": "The index within the tool component rules array of the rule object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "rule": { + "description": "A reference used to locate the rule descriptor relevant to this result.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "kind": { + "description": "A value that categorizes results by evaluation state.", + "default": "fail", + "enum": [ "notApplicable", "pass", "fail", "review", "open", "informational" ], + "type": "string" + }, + + "level": { + "description": "A value specifying the severity level of the result.", + "default": "warning", + "enum": [ "none", "note", "warning", "error" ], + "type": "string" + }, + + "message": { + "description": "A message that describes the result. The first sentence of the message only will be displayed when visible space is limited.", + "$ref": "#/definitions/message" + }, + + "analysisTarget": { + "description": "Identifies the artifact that the analysis tool was instructed to scan. This need not be the same as the artifact where the result actually occurred.", + "$ref": "#/definitions/artifactLocation" + }, + + "locations": { + "description": "The set of locations where the result was detected. Specify only one location unless the problem indicated by the result can only be corrected by making a change at every specified location.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "guid": { + "description": "A stable, unique identifier for the result in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "correlationGuid": { + "description": "A stable, unique identifier for the equivalence class of logically identical results to which this result belongs, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "occurrenceCount": { + "description": "A positive integer specifying the number of times this logically unique result was observed in this run.", + "type": "integer", + "minimum": 1 + }, + + "partialFingerprints": { + "description": "A set of strings that contribute to the stable, unique identity of the result.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "fingerprints": { + "description": "A set of strings each of which individually defines a stable, unique identity for the result.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "stacks": { + "description": "An array of 'stack' objects relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/stack" + } + }, + + "codeFlows": { + "description": "An array of 'codeFlow' objects relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/codeFlow" + } + }, + + "graphs": { + "description": "An array of zero or more unique graph objects associated with the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graph" + } + }, + + "graphTraversals": { + "description": "An array of one or more unique 'graphTraversal' objects.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graphTraversal" + } + }, + + "relatedLocations": { + "description": "A set of locations relevant to this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "suppressions": { + "description": "A set of suppressions relevant to this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/suppression" + } + }, + + "baselineState": { + "description": "The state of a result relative to a baseline of a previous run.", + "enum": [ + "new", + "unchanged", + "updated", + "absent" + ], + "type": "string" + }, + + "rank": { + "description": "A number representing the priority or importance of the result.", + "type": "number", + "default": -1.0, + "minimum": -1.0, + "maximum": 100.0 + }, + + "attachments": { + "description": "A set of artifacts relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/attachment" + } + }, + + "hostedViewerUri": { + "description": "An absolute URI at which the result can be viewed.", + "type": "string", + "format": "uri" + }, + + "workItemUris": { + "description": "The URIs of the work items associated with this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string", + "format": "uri" + } + }, + + "provenance": { + "description": "Information about how and when the result was detected.", + "$ref": "#/definitions/resultProvenance" + }, + + "fixes": { + "description": "An array of 'fix' objects, each of which represents a proposed fix to the problem indicated by the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/fix" + } + }, + + "taxa": { + "description": "An array of references to taxonomy reporting descriptors that are applicable to the result.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorReference" + } + }, + + "webRequest": { + "description": "A web request associated with this result.", + "$ref": "#/definitions/webRequest" + }, + + "webResponse": { + "description": "A web response associated with this result.", + "$ref": "#/definitions/webResponse" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the result.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "message" ] + }, + + "resultProvenance": { + "description": "Contains information about how and when a result was detected.", + "additionalProperties": false, + "type": "object", + "properties": { + + "firstDetectionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the result was first detected. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "lastDetectionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the result was most recently detected. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "firstDetectionRunGuid": { + "description": "A GUID-valued string equal to the automationDetails.guid property of the run in which the result was first detected.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "lastDetectionRunGuid": { + "description": "A GUID-valued string equal to the automationDetails.guid property of the run in which the result was most recently detected.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "invocationIndex": { + "description": "The index within the run.invocations array of the invocation object which describes the tool invocation that detected the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "conversionSources": { + "description": "An array of physicalLocation objects which specify the portions of an analysis tool's output that a converter transformed into the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/physicalLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the result.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "run": { + "description": "Describes a single run of an analysis tool, and contains the reported output of that run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "tool": { + "description": "Information about the tool or tool pipeline that generated the results in this run. A run can only contain results produced by a single tool or tool pipeline. A run can aggregate results from multiple log files, as long as context around the tool run (tool command-line arguments and the like) is identical for all aggregated files.", + "$ref": "#/definitions/tool" + }, + + "invocations": { + "description": "Describes the invocation of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/invocation" + } + }, + + "conversion": { + "description": "A conversion object that describes how a converter transformed an analysis tool's native reporting format into the SARIF format.", + "$ref": "#/definitions/conversion" + }, + + "language": { + "description": "The language of the messages emitted into the log file during this run (expressed as an ISO 639-1 two-letter lowercase culture code) and an optional region (expressed as an ISO 3166-1 two-letter uppercase subculture code associated with a country or region). The casing is recommended but not required (in order for this data to conform to RFC5646).", + "type": "string", + "default": "en-US", + "pattern": "^[a-zA-Z]{2}(-[a-zA-Z]{2})?$" + }, + + "versionControlProvenance": { + "description": "Specifies the revision in version control of the artifacts that were scanned.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/versionControlDetails" + } + }, + + "originalUriBaseIds": { + "description": "The artifact location specified by each uriBaseId symbol on the machine where the tool originally ran.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "artifacts": { + "description": "An array of artifact objects relevant to the run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifact" + } + }, + + "logicalLocations": { + "description": "An array of logical locations such as namespaces, types or functions.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "graphs": { + "description": "An array of zero or more unique graph objects associated with the run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graph" + } + }, + + "results": { + "description": "The set of results contained in an SARIF log. The results array can be omitted when a run is solely exporting rules metadata. It must be present (but may be empty) if a log file represents an actual scan.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/result" + } + }, + + "automationDetails": { + "description": "Automation details that describe this run.", + "$ref": "#/definitions/runAutomationDetails" + }, + + "runAggregates": { + "description": "Automation details that describe the aggregate of runs to which this run belongs.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/runAutomationDetails" + } + }, + + "baselineGuid": { + "description": "The 'guid' property of a previous SARIF 'run' that comprises the baseline that was used to compute result 'baselineState' properties for the run.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "redactionTokens": { + "description": "An array of strings used to replace sensitive information in a redaction-aware property.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + }, + + "defaultEncoding": { + "description": "Specifies the default encoding for any artifact object that refers to a text file.", + "type": "string" + }, + + "defaultSourceLanguage": { + "description": "Specifies the default source language for any artifact object that refers to a text file that contains source code.", + "type": "string" + }, + + "newlineSequences": { + "description": "An ordered list of character sequences that were treated as line breaks when computing region information for the run.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "default": [ "\r\n", "\n" ], + "items": { + "type": "string" + } + }, + + "columnKind": { + "description": "Specifies the unit in which the tool measures columns.", + "enum": [ "utf16CodeUnits", "unicodeCodePoints" ], + "type": "string" + }, + + "externalPropertyFileReferences": { + "description": "References to external property files that should be inlined with the content of a root log file.", + "$ref": "#/definitions/externalPropertyFileReferences" + }, + + "threadFlowLocations": { + "description": "An array of threadFlowLocation objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "taxonomies": { + "description": "An array of toolComponent objects relevant to a taxonomy in which results are categorized.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "addresses": { + "description": "Addresses associated with this run instance, if any.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/address" + } + }, + + "translations": { + "description": "The set of available translations of the localized data provided by the tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "policies": { + "description": "Contains configurations that may potentially override both reportingDescriptor.defaultConfiguration (the tool's default severities) and invocation.configurationOverrides (severities established at run-time from the command line).", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "webRequests": { + "description": "An array of request objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webRequest" + } + }, + + "webResponses": { + "description": "An array of response objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webResponse" + } + }, + + "specialLocations": { + "description": "A specialLocations object that defines locations of special significance to SARIF consumers.", + "$ref": "#/definitions/specialLocations" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the run.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "tool" ] + }, + + "runAutomationDetails": { + "description": "Information that describes a run's identity and role within an engineering system process.", + "additionalProperties": false, + "type": "object", + "properties": { + + "description": { + "description": "A description of the identity and role played within the engineering system by this object's containing run object.", + "$ref": "#/definitions/message" + }, + + "id": { + "description": "A hierarchical string that uniquely identifies this object's containing run object.", + "type": "string" + }, + + "guid": { + "description": "A stable, unique identifier for this object's containing run object in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "correlationGuid": { + "description": "A stable, unique identifier for the equivalence class of runs to which this object's containing run object belongs in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the run automation details.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "specialLocations": { + "description": "Defines locations of special significance to SARIF consumers.", + "type": "object", + "additionalProperties": false, + "properties": { + + "displayBase": { + "description": "Provides a suggestion to SARIF consumers to display file paths relative to the specified location.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the special locations.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "stack": { + "description": "A call stack that is relevant to a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "message": { + "description": "A message relevant to this call stack.", + "$ref": "#/definitions/message" + }, + + "frames": { + "description": "An array of stack frames that represents a sequence of calls, rendered in reverse chronological order, that comprise the call stack.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/stackFrame" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the stack.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "frames" ] + }, + + "stackFrame": { + "description": "A function call within a stack trace.", + "additionalProperties": false, + "type": "object", + "properties": { + + "location": { + "description": "The location to which this stack frame refers.", + "$ref": "#/definitions/location" + }, + + "module": { + "description": "The name of the module that contains the code of this stack frame.", + "type": "string" + }, + + "threadId": { + "description": "The thread identifier of the stack frame.", + "type": "integer" + }, + + "parameters": { + "description": "The parameters of the call that is executing.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "type": "string", + "default": [] + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the stack frame.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "suppression": { + "description": "A suppression that is relevant to a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "guid": { + "description": "A stable, unique identifier for the suprression in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "kind": { + "description": "A string that indicates where the suppression is persisted.", + "enum": [ + "inSource", + "external" + ], + "type": "string" + }, + + "status": { + "description": "A string that indicates the review status of the suppression.", + "enum": [ + "accepted", + "underReview", + "rejected" + ], + "type": "string" + }, + + "justification": { + "description": "A string representing the justification for the suppression.", + "type": "string" + }, + + "location": { + "description": "Identifies the location associated with the suppression.", + "$ref": "#/definitions/location" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the suppression.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "kind" ] + }, + + "threadFlow": { + "description": "Describes a sequence of code locations that specify a path through a single thread of execution such as an operating system or fiber.", + "type": "object", + "additionalProperties": false, + "properties": { + + "id": { + "description": "An string that uniquely identifies the threadFlow within the codeFlow in which it occurs.", + "type": "string" + }, + + "message": { + "description": "A message relevant to the thread flow.", + "$ref": "#/definitions/message" + }, + + + "initialState": { + "description": "Values of relevant expressions at the start of the thread flow that may change during thread flow execution.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "immutableState": { + "description": "Values of relevant expressions at the start of the thread flow that remain constant.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "locations": { + "description": "A temporally ordered array of 'threadFlowLocation' objects, each of which describes a location visited by the tool while producing the result.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the thread flow.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "locations" ] + }, + + "threadFlowLocation": { + "description": "A location visited by an analysis tool while simulating or monitoring the execution of a program.", + "additionalProperties": false, + "type": "object", + "properties": { + + "index": { + "description": "The index within the run threadFlowLocations array.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "location": { + "description": "The code location.", + "$ref": "#/definitions/location" + }, + + "stack": { + "description": "The call stack leading to this location.", + "$ref": "#/definitions/stack" + }, + + "kinds": { + "description": "A set of distinct strings that categorize the thread flow location. Well-known kinds include 'acquire', 'release', 'enter', 'exit', 'call', 'return', 'branch', 'implicit', 'false', 'true', 'caution', 'danger', 'unknown', 'unreachable', 'taint', 'function', 'handler', 'lock', 'memory', 'resource', 'scope' and 'value'.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + }, + + "taxa": { + "description": "An array of references to rule or taxonomy reporting descriptors that are applicable to the thread flow location.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorReference" + } + }, + + "module": { + "description": "The name of the module that contains the code that is executing.", + "type": "string" + }, + + "state": { + "description": "A dictionary, each of whose keys specifies a variable or expression, the associated value of which represents the variable or expression value. For an annotation of kind 'continuation', for example, this dictionary might hold the current assumed values of a set of global variables.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "nestingLevel": { + "description": "An integer representing a containment hierarchy within the thread flow.", + "type": "integer", + "minimum": 0 + }, + + "executionOrder": { + "description": "An integer representing the temporal order in which execution reached this location.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "executionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which this location was executed.", + "type": "string", + "format": "date-time" + }, + + "importance": { + "description": "Specifies the importance of this location in understanding the code flow in which it occurs. The order from most to least important is \"essential\", \"important\", \"unimportant\". Default: \"important\".", + "enum": [ "important", "essential", "unimportant" ], + "default": "important", + "type": "string" + }, + + "webRequest": { + "description": "A web request associated with this thread flow location.", + "$ref": "#/definitions/webRequest" + }, + + "webResponse": { + "description": "A web response associated with this thread flow location.", + "$ref": "#/definitions/webResponse" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the threadflow location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "tool": { + "description": "The analysis tool that was run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "driver": { + "description": "The analysis tool that was run.", + "$ref": "#/definitions/toolComponent" + }, + + "extensions": { + "description": "Tool extensions that contributed to or reconfigured the analysis tool that was run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the tool.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "driver" ] + }, + + "toolComponent": { + "description": "A component, such as a plug-in or the driver, of the analysis tool that was run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "guid": { + "description": "A unique identifier for the tool component in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "name": { + "description": "The name of the tool component.", + "type": "string" + }, + + "organization": { + "description": "The organization or company that produced the tool component.", + "type": "string" + }, + + "product": { + "description": "A product suite to which the tool component belongs.", + "type": "string" + }, + + "productSuite": { + "description": "A localizable string containing the name of the suite of products to which the tool component belongs.", + "type": "string" + }, + + "shortDescription": { + "description": "A brief description of the tool component.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A comprehensive description of the tool component.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullName": { + "description": "The name of the tool component along with its version and any other useful identifying information, such as its locale.", + "type": "string" + }, + + "version": { + "description": "The tool component version, in whatever format the component natively provides.", + "type": "string" + }, + + "semanticVersion": { + "description": "The tool component version in the format specified by Semantic Versioning 2.0.", + "type": "string" + }, + + "dottedQuadFileVersion": { + "description": "The binary version of the tool component's primary executable file expressed as four non-negative integers separated by a period (for operating systems that express file versions in this way).", + "type": "string", + "pattern": "[0-9]+(\\.[0-9]+){3}" + }, + + "releaseDateUtc": { + "description": "A string specifying the UTC date (and optionally, the time) of the component's release.", + "type": "string" + }, + + "downloadUri": { + "description": "The absolute URI from which the tool component can be downloaded.", + "type": "string", + "format": "uri" + }, + + "informationUri": { + "description": "The absolute URI at which information about this version of the tool component can be found.", + "type": "string", + "format": "uri" + }, + + "globalMessageStrings": { + "description": "A dictionary, each of whose keys is a resource identifier and each of whose values is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "notifications": { + "description": "An array of reportingDescriptor objects relevant to the notifications related to the configuration and runtime execution of the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "rules": { + "description": "An array of reportingDescriptor objects relevant to the analysis performed by the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "taxa": { + "description": "An array of reportingDescriptor objects relevant to the definitions of both standalone and tool-defined taxonomies.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "locations": { + "description": "An array of the artifactLocation objects associated with the tool component.", + "type": "array", + "minItems": 0, + "default": [], + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "language": { + "description": "The language of the messages emitted into the log file during this run (expressed as an ISO 639-1 two-letter lowercase language code) and an optional region (expressed as an ISO 3166-1 two-letter uppercase subculture code associated with a country or region). The casing is recommended but not required (in order for this data to conform to RFC5646).", + "type": "string", + "default": "en-US", + "pattern": "^[a-zA-Z]{2}(-[a-zA-Z]{2})?$" + }, + + "contents": { + "description": "The kinds of data contained in this object.", + "type": "array", + "uniqueItems": true, + "default": [ "localizedData", "nonLocalizedData" ], + "items": { + "enum": [ + "localizedData", + "nonLocalizedData" + ], + "type": "string" + } + }, + + "isComprehensive": { + "description": "Specifies whether this object contains a complete definition of the localizable and/or non-localizable data for this component, as opposed to including only data that is relevant to the results persisted to this log file.", + "type": "boolean", + "default": false + }, + + "localizedDataSemanticVersion": { + "description": "The semantic version of the localized strings defined in this component; maintained by components that provide translations.", + "type": "string" + }, + + "minimumRequiredLocalizedDataSemanticVersion": { + "description": "The minimum value of localizedDataSemanticVersion required in translations consumed by this component; used by components that consume translations.", + "type": "string" + }, + + "associatedComponent": { + "description": "The component which is strongly associated with this component. For a translation, this refers to the component which has been translated. For an extension, this is the driver that provides the extension's plugin model.", + "$ref": "#/definitions/toolComponentReference" + }, + + "translationMetadata": { + "description": "Translation metadata, required for a translation, not populated by other component types.", + "$ref": "#/definitions/translationMetadata" + }, + + "supportedTaxonomies": { + "description": "An array of toolComponentReference objects to declare the taxonomies supported by the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponentReference" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the tool component.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "name" ] + }, + + "toolComponentReference": { + "description": "Identifies a particular toolComponent object, either the driver or an extension.", + "type": "object", + "additionalProperties": false, + "properties": { + + "name": { + "description": "The 'name' property of the referenced toolComponent.", + "type": "string" + }, + + "index": { + "description": "An index into the referenced toolComponent in tool.extensions.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "guid": { + "description": "The 'guid' property of the referenced toolComponent.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the toolComponentReference.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "translationMetadata": { + "description": "Provides additional metadata related to translation.", + "type": "object", + "additionalProperties": false, + "properties": { + + "name": { + "description": "The name associated with the translation metadata.", + "type": "string" + }, + + "fullName": { + "description": "The full name associated with the translation metadata.", + "type": "string" + }, + + "shortDescription": { + "description": "A brief description of the translation metadata.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A comprehensive description of the translation metadata.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "downloadUri": { + "description": "The absolute URI from which the translation metadata can be downloaded.", + "type": "string", + "format": "uri" + }, + + "informationUri": { + "description": "The absolute URI from which information related to the translation metadata can be downloaded.", + "type": "string", + "format": "uri" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the translation metadata.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "name" ] + }, + + "versionControlDetails": { + "description": "Specifies the information necessary to retrieve a desired revision from a version control system.", + "type": "object", + "additionalProperties": false, + "properties": { + + "repositoryUri": { + "description": "The absolute URI of the repository.", + "type": "string", + "format": "uri" + }, + + "revisionId": { + "description": "A string that uniquely and permanently identifies the revision within the repository.", + "type": "string" + }, + + "branch": { + "description": "The name of a branch containing the revision.", + "type": "string" + }, + + "revisionTag": { + "description": "A tag that has been applied to the revision.", + "type": "string" + }, + + "asOfTimeUtc": { + "description": "A Coordinated Universal Time (UTC) date and time that can be used to synchronize an enlistment to the state of the repository at that time.", + "type": "string", + "format": "date-time" + }, + + "mappedTo": { + "description": "The location in the local file system to which the root of the repository was mapped at the time of the analysis.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the version control details.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "repositoryUri" ] + }, + + "webRequest": { + "description": "Describes an HTTP request.", + "type": "object", + "additionalProperties": false, + "properties": { + + "index": { + "description": "The index within the run.webRequests array of the request object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + + }, + + "protocol": { + "description": "The request protocol. Example: 'http'.", + "type": "string" + }, + + "version": { + "description": "The request version. Example: '1.1'.", + "type": "string" + }, + + "target": { + "description": "The target of the request.", + "type": "string" + }, + + "method": { + "description": "The HTTP method. Well-known values are 'GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'.", + "type": "string" + }, + + "headers": { + "description": "The request headers.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "parameters": { + "description": "The request parameters.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "body": { + "description": "The body of the request.", + "$ref": "#/definitions/artifactContent" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the request.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "webResponse": { + "description": "Describes the response to an HTTP request.", + "type": "object", + "additionalProperties": false, + "properties": { + + "index": { + "description": "The index within the run.webResponses array of the response object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "protocol": { + "description": "The response protocol. Example: 'http'.", + "type": "string" + }, + + "version": { + "description": "The response version. Example: '1.1'.", + "type": "string" + }, + + "statusCode": { + "description": "The response status code. Example: 451.", + "type": "integer" + }, + + "reasonPhrase": { + "description": "The response reason. Example: 'Not found'.", + "type": "string" + }, + + "headers": { + "description": "The response headers.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "body": { + "description": "The body of the response.", + "$ref": "#/definitions/artifactContent" + }, + + "noResponseReceived": { + "description": "Specifies whether a response was received from the server.", + "type": "boolean", + "default": false + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the response.", + "$ref": "#/definitions/propertyBag" + } + } + } + } +} diff --git a/tests/fixtures/sarif-baseline/sarif-shop.sarif b/tests/fixtures/sarif-baseline/sarif-shop.sarif new file mode 100644 index 00000000..4916c846 --- /dev/null +++ b/tests/fixtures/sarif-baseline/sarif-shop.sarif @@ -0,0 +1,378 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "GuardLink", + "version": "2.1.0", + "informationUri": "https://guardlink.bugb.io", + "rules": [ + { + "id": "guardlink/unmitigated-exposure", + "name": "UnmitigatedExposure", + "shortDescription": { + "text": "Asset exposed to threat with no mitigation or acceptance" + }, + "fullDescription": { + "text": "An @exposes annotation exists but no matching @mitigates or @accepts covers this (asset, threat) pair. This represents an acknowledged but unaddressed security risk." + }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { + "level": "warning" + } + }, + { + "id": "guardlink/unmitigated-critical", + "name": "UnmitigatedCriticalExposure", + "shortDescription": { + "text": "Critical/high severity exposure with no mitigation" + }, + "fullDescription": { + "text": "A critical or high severity exposure exists without mitigation. This should be addressed before deployment." + }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { + "level": "error" + } + }, + { + "id": "guardlink/confirmed-exploitable", + "name": "ConfirmedExploitable", + "shortDescription": { + "text": "Threat verified exploitable through testing" + }, + "fullDescription": { + "text": "A @confirmed annotation marks this threat as verified through pentest, scanning, or manual reproduction. This is not a false positive and requires immediate remediation." + }, + "helpUri": "https://guardlink.bugb.io/docs/confirmed", + "defaultConfiguration": { + "level": "error" + } + }, + { + "id": "guardlink/parse-error", + "name": "AnnotationParseError", + "shortDescription": { + "text": "Malformed GuardLink annotation" + }, + "fullDescription": { + "text": "A GuardLink annotation could not be parsed. Check syntax against the specification." + }, + "helpUri": "https://guardlink.bugb.io/docs/syntax", + "defaultConfiguration": { + "level": "error" + } + }, + { + "id": "guardlink/dangling-ref", + "name": "DanglingReference", + "shortDescription": { + "text": "Reference to undefined threat, control, or asset ID" + }, + "fullDescription": { + "text": "An annotation references a #id that is not defined anywhere in the project." + }, + "helpUri": "https://guardlink.bugb.io/docs/definitions", + "defaultConfiguration": { + "level": "warning" + } + } + ] + } + }, + "results": [ + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#store is exposed to dos: Export reads the whole table into memory" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/export.ts" + }, + "region": { + "startLine": 7 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-2425fd275e00", + "guardlink/claimKey": "63046fdb7b8a3ceb3d74a9cd89038d5839c08faa739708f94c7978c346f96040:0" + }, + "properties": { + "threatId": "gl-2425fd275e00", + "claimKey": "63046fdb7b8a3ceb3d74a9cd89038d5839c08faa739708f94c7978c346f96040:0", + "severity": "medium", + "asset": "#store", + "threat": "#dos", + "externalRefs": [ + "cwe:CWE-400" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#mailer is exposed to ssrf: Webhook URL is not validated before the request" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/notify.ts" + }, + "region": { + "startLine": 7 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-9e2c8abd85e9", + "guardlink/claimKey": "b2c5328c212860a3a2eede9990b55fa53a0ff7d876925945d6469fa78be65b16:0" + }, + "properties": { + "threatId": "gl-9e2c8abd85e9", + "claimKey": "b2c5328c212860a3a2eede9990b55fa53a0ff7d876925945d6469fa78be65b16:0", + "severity": "medium", + "asset": "#mailer", + "threat": "#ssrf", + "externalRefs": [ + "cwe:CWE-918" + ] + } + }, + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#mailer is exposed to log-flood: Delivery failures log the full response body" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/notify.ts" + }, + "region": { + "startLine": 11 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-f5db85adaace", + "guardlink/claimKey": "4d2de4d866ac144b57f6c0e7b6e7881ca0210f882a3e80aae20d3e9c5dde5b9c:0" + }, + "properties": { + "threatId": "gl-f5db85adaace", + "claimKey": "4d2de4d866ac144b57f6c0e7b6e7881ca0210f882a3e80aae20d3e9c5dde5b9c:0", + "severity": "low", + "asset": "#mailer", + "threat": "#log-flood" + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#orders is exposed to idor: No owner check before the order is returned" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 12 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-75dd0eba6eb6", + "guardlink/claimKey": "d78739e45bf0d57d323c4e8db7219094c8991ef40bc54b79a372f9458d42256c:0" + }, + "properties": { + "threatId": "gl-75dd0eba6eb6", + "claimKey": "d78739e45bf0d57d323c4e8db7219094c8991ef40bc54b79a372f9458d42256c:0", + "severity": "high", + "asset": "#orders", + "threat": "#idor", + "externalRefs": [ + "cwe:CWE-639", + "owasp:A01:2021" + ], + "codegraph_reachability": { + "http_method": "GET", + "http_path": "/orders/:id" + }, + "route_attribution": "handler" + } + }, + { + "ruleId": "guardlink/unmitigated-critical", + "level": "error", + "message": { + "text": "#store is exposed to sqli: Search term concatenated into SQL" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 24 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-55d85b3632f3", + "guardlink/claimKey": "aa7d0f55159827485580599056a256250d97d83583387f0d915cdae280fb2f99:0" + }, + "properties": { + "threatId": "gl-55d85b3632f3", + "claimKey": "aa7d0f55159827485580599056a256250d97d83583387f0d915cdae280fb2f99:0", + "severity": "critical", + "asset": "#store", + "threat": "#sqli", + "externalRefs": [ + "cwe:CWE-89", + "owasp:A03:2021" + ], + "codegraph_reachability": { + "http_method": "POST", + "http_path": "/orders/search" + }, + "route_attribution": "handler" + } + }, + { + "ruleId": "guardlink/unmitigated-exposure", + "level": "warning", + "message": { + "text": "#orders is exposed to csrf: Refers to a threat this model never defines" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 35 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-d4687ef0175b", + "guardlink/claimKey": "8759962b4676d2f13d89628f4b6670226d133e3eb73680cfd0da4d4adf84e617:0" + }, + "properties": { + "threatId": "gl-d4687ef0175b", + "claimKey": "8759962b4676d2f13d89628f4b6670226d133e3eb73680cfd0da4d4adf84e617:0", + "severity": "medium", + "asset": "#orders", + "threat": "#csrf", + "codegraph_reachability": { + "http_method": "DELETE", + "http_path": "/orders/:id" + }, + "route_attribution": "handler" + } + }, + { + "ruleId": "guardlink/confirmed-exploitable", + "level": "error", + "message": { + "text": "CONFIRMED: #orders exploitable via idor: Reproduced: user B read user A's order by id" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 13 + } + } + } + ], + "partialFingerprints": { + "guardlink/threatId": "gl-75dd0eba6eb6" + }, + "properties": { + "threatId": "gl-75dd0eba6eb6", + "severity": "high", + "asset": "#orders", + "threat": "#idor", + "externalRefs": [ + "cwe:CWE-639" + ], + "codegraph_reachability": { + "http_method": "GET", + "http_path": "/orders/:id" + }, + "route_attribution": "handler" + } + }, + { + "ruleId": "guardlink/parse-error", + "level": "error", + "message": { + "text": "Malformed @exposes annotation: could not parse arguments (looks structural — found a #reference)" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/notify.ts" + }, + "region": { + "startLine": 12 + } + } + } + ] + }, + { + "ruleId": "guardlink/dangling-ref", + "level": "warning", + "message": { + "text": "Dangling reference: #csrf is never defined" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/web.ts" + }, + "region": { + "startLine": 35 + } + } + } + ] + } + ], + "properties": { + "annotation_hash": "sha256-v3:3f7dfe18e0889164b8a438f8808d09ecfb57c45d50d4a454daf87e77dfa8739f", + "annotation_hash_version": 3, + "generator": "guardlink@2.1.0", + "acceptance_register": "code-annotations" + } + } + ] +} diff --git a/tests/fixtures/sarif-shop/.guardlink/config.json b/tests/fixtures/sarif-shop/.guardlink/config.json new file mode 100644 index 00000000..b965259e --- /dev/null +++ b/tests/fixtures/sarif-shop/.guardlink/config.json @@ -0,0 +1,15 @@ +{ + "version": "1.1.0", + "project": "sarif-shop", + "language": "typescript", + "annotation_mode": "inline", + "definitions": "definitions.ts", + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + ".git" + ] +} diff --git a/tests/fixtures/sarif-shop/.guardlink/definitions.ts b/tests/fixtures/sarif-shop/.guardlink/definitions.ts new file mode 100644 index 00000000..d167e4e4 --- /dev/null +++ b/tests/fixtures/sarif-shop/.guardlink/definitions.ts @@ -0,0 +1,15 @@ +// Definitions for the sarif-shop fixture. See ../FIXTURE.md. + +// @asset Shop.Web (#web) -- "Public HTTP API" +// @asset Shop.Orders (#orders) -- "Order service behind the API" +// @asset Shop.Store (#store) -- "Order database" +// @asset Shop.Mailer (#mailer) -- "Outbound webhook client" + +// @threat SQL_Injection (#sqli) [critical] cwe:CWE-89 owasp:A03:2021 -- "Request text reaches a query unescaped" +// @threat Insecure_Direct_Object_Reference (#idor) [high] cwe:CWE-639 owasp:A01:2021 -- "Objects fetched by id with no owner check" +// @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "Caller chooses where the server connects" +// @threat Log_Flooding (#log-flood) [low] -- "Unbounded caller text written to logs" +// @threat Denial_Of_Service (#dos) [medium] cwe:CWE-400 -- "Unbounded work per request" + +// @control Parameterized_Queries (#param-queries) -- "Bound query parameters" +// @control Owner_Check (#owner-check) -- "Ownership verified before access" diff --git a/tests/fixtures/sarif-shop/FIXTURE.md b/tests/fixtures/sarif-shop/FIXTURE.md new file mode 100644 index 00000000..6f035e3b --- /dev/null +++ b/tests/fixtures/sarif-shop/FIXTURE.md @@ -0,0 +1,24 @@ +# Fixture: `sarif-shop` + +A small inline-mode TypeScript service written to exercise every part of the +SARIF export that `expense-api` does not: route-channel `@flows`, a `@confirmed` +finding, `owasp:` references, a `@boundary` between two declared assets, a +`@transfers`, a covered exposure, a parse error and a dangling reference. + +It is read by `tests/sarif-enrichment.test.ts`. Each annotation is placed for a +reason the tests rely on: + +| Where | What it pins | +|---|---| +| `src/web.ts` `searchOrders` | a three-hop chain whose every hop is on the claim's handler, crossing two boundaries | +| `src/web.ts` `getOrder` | a `@confirmed` result gets a chain too | +| `src/web.ts` `deleteOrder` | flows into `#orders` exist in this file, but only on sibling handlers, so no chain is emitted; the `#csrf` exposure is the dangling reference | +| `src/notify.ts` | file-level claims: `file` attribution, `@transfers` scoped to one threat, no logical location; the trailing `@exposes #mailer to` is the parse error | +| `src/export.ts` | an exposure whose asset is only reached from another file, so no chain is emitted | + +Editing this fixture changes the export it produces, so +`tests/fixtures/sarif-baseline/sarif-shop.sarif` must be regenerated with it — +see that directory's README. + +It does not perturb this repository's own model: `tests` is excluded in +`.guardlink/config.json`, and `**/tests/**` is in the parser's `DEFAULT_EXCLUDE`. diff --git a/tests/fixtures/sarif-shop/src/export.ts b/tests/fixtures/sarif-shop/src/export.ts new file mode 100644 index 00000000..6f95346c --- /dev/null +++ b/tests/fixtures/sarif-shop/src/export.ts @@ -0,0 +1,11 @@ +/** + * Bulk export. Nothing in this file declares a flow into #store, so the only + * chain into it lives elsewhere: an exposure here has no attributed chain. + */ + +/** + * @exposes #store to #dos [medium] cwe:CWE-400 -- "Export reads the whole table into memory" + */ +export function exportAll(): string[] { + return []; +} diff --git a/tests/fixtures/sarif-shop/src/notify.ts b/tests/fixtures/sarif-shop/src/notify.ts new file mode 100644 index 00000000..9e3e713c --- /dev/null +++ b/tests/fixtures/sarif-shop/src/notify.ts @@ -0,0 +1,16 @@ +/** + * Outbound webhooks for the sarif-shop fixture. Every annotation here is + * file-level: it sits on the module, not on a function. + * + * @flows #orders -> #mailer via notify -- "Order events queued for delivery" + * @flows #mailer -> ExternalHost via fetch -- "Webhook POSTed to a caller-supplied URL" + * @exposes #mailer to #ssrf [medium] cwe:CWE-918 -- "Webhook URL is not validated before the request" + * @transfers #ssrf from #mailer to #web -- "URL validation is the API layer's job" + * @assumes #mailer -- "Callers pass only registered webhook URLs" + * @audit #mailer -- "Confirm the API layer really rejects private address ranges" + * @exposes #mailer to #log-flood [low] -- "Delivery failures log the full response body" + * @exposes #mailer to + */ +export async function notify(url: string, body: string): Promise { + await fetch(url, { method: 'POST', body }); +} diff --git a/tests/fixtures/sarif-shop/src/web.ts b/tests/fixtures/sarif-shop/src/web.ts new file mode 100644 index 00000000..5e6e95dd --- /dev/null +++ b/tests/fixtures/sarif-shop/src/web.ts @@ -0,0 +1,43 @@ +/** + * HTTP entry points for the sarif-shop fixture. + * + * @boundary between Client and #web (#edge) -- "Internet to API: nothing before this point is trusted" + * @flows Client -> #web via HTTPS -- "Every request arrives over TLS" + */ +export const service = 'web'; + +/** + * @flows Client -> #web via GET./orders/:id -- "Order id from the path" + * @flows #web -> #orders via getOrder -- "Id handed to the order service unchanged" + * @exposes #orders to #idor [high] cwe:CWE-639 owasp:A01:2021 -- "No owner check before the order is returned" + * @confirmed #idor on #orders [high] cwe:CWE-639 -- "Reproduced: user B read user A's order by id" + */ +export function getOrder(id: string): string { + return lookup(id); +} + +/** + * @flows Client -> #web via POST./orders/search -- "Free-text search term in the body" + * @flows #web -> #orders via searchOrders -- "Term passed to the order service" + * @flows #orders -> #store via query -- "Term reaches SQL" + * @boundary between #orders and #store (#data) -- "Service to database" + * @exposes #store to #sqli [critical] cwe:CWE-89 owasp:A03:2021 -- "Search term concatenated into SQL" + * @handles pii on #store -- "Customer names and addresses" + */ +export function searchOrders(term: string): string { + return lookup(`SELECT * FROM orders WHERE note LIKE '%${term}%'`); +} + +/** + * @flows Client -> #web via DELETE./orders/:id -- "Order id from the path" + * @exposes #orders to #dos [medium] cwe:CWE-400 -- "Cascade delete is unbounded" + * @mitigates #orders against #dos using #param-queries -- "Delete is a single bound statement" + * @exposes #orders to #csrf [medium] -- "Refers to a threat this model never defines" + */ +export function deleteOrder(id: string): string { + return lookup(id); +} + +function lookup(text: string): string { + return text; +} diff --git a/tests/query-views.test.ts b/tests/query-views.test.ts index 0ac33dad..43fa1452 100644 --- a/tests/query-views.test.ts +++ b/tests/query-views.test.ts @@ -194,7 +194,11 @@ describe('the node counter against browser-measured geometry', () => { // `Workspace.Estate (#estate-view)` with its flows and by putting // #vacuous-pass / #fail-closed on a second asset. The optional code-graph // adapter took 33/80/46 to 34/88/47 by declaring `GuardLink.CodeGraph - // (#codegraph)` with its exposures, controls, @validates and flows. + // (#codegraph)` with its exposures, controls, @validates and flows. The + // SARIF version-control provenance took the whole model from 47 to 48 by + // giving `Workspace.Metadata (#report-metadata)` its first exposure — the + // git remote it reads may embed credentials — which the filtered graph, + // showing high and critical only, does not draw. // When they move, re-render the diagram in a browser and count the SVG // again; never bump them to whatever the counter says, which leaves it // checking itself. @@ -205,12 +209,13 @@ describe('the node counter against browser-measured geometry', () => { // confirmed to be 1440x900, counting `.node` and `path.flowchart-link` in // the SVG. Chrome reported filtered {nodes:34, links:88, clusters:8} and // whole model {nodes:47, links:174}; Chrome and measureLegibility agree on - // both pairs. + // both pairs. Re-measured for 48 the same way: filtered {nodes:34, links:88, + // clusters:8}, whole model {nodes:48, links:179}, and the counter agrees. const m = measureLegibility(generateThreatGraph(ordered, { icons: 'none' })); expect(m.nodes).toBe(34); expect(m.edges).toBe(88); - // And the whole model is 47 nodes, which is the number this work exists for. - expect(measureLegibility(generateThreatGraph(ordered, { showAll: true, icons: 'none' })).nodes).toBe(47); + // And the whole model is 48 nodes, which is the number this work exists for. + expect(measureLegibility(generateThreatGraph(ordered, { showAll: true, icons: 'none' })).nodes).toBe(48); }); }); diff --git a/tests/sarif-enrichment.test.ts b/tests/sarif-enrichment.test.ts new file mode 100644 index 00000000..5ba68772 --- /dev/null +++ b/tests/sarif-enrichment.test.ts @@ -0,0 +1,462 @@ +/** + * The declared context the SARIF export carries around each finding + * (src/analyzer/sarif-context.ts), and the guarantee that makes it safe to ship: + * it is purely additive. + * + * Consumers of this export key on the result index, on `message.text`, on rule + * ids and on `partialFingerprints`. So the central test here strips every member + * the enrichment adds and compares what is left, byte for byte, with exports cut + * BEFORE the enrichment existed (tests/fixtures/sarif-baseline/*.sarif, written + * by `guardlink sarif` at the commit this change was based on). If a result is + * added, dropped, reordered or reworded, or a rule changes, that comparison + * fails. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { readFileSync, mkdtempSync, rmSync, cpSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv from 'ajv-draft-04'; +import addFormats from 'ajv-formats'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { generateSarif, SARIF_AUTOMATION_ID, SARIF_PROFILE_VERSION } from '../src/analyzer/sarif.js'; +import { parseProject } from '../src/parser/parse-project.js'; +import { findDanglingRefs } from '../src/parser/validate.js'; +import { readVersionControl } from '../src/workspace/metadata.js'; +import { createServer } from '../src/mcp/server.js'; +import type { ThreatModel } from '../src/types/index.js'; + +const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const fixture = (name: string) => join(repoRoot, 'tests', 'fixtures', name); +const baselineDir = fixture('sarif-baseline'); + +type Sarif = ReturnType; +type Run = Sarif['runs'][number]; +type Result = Run['results'][number]; + +/** What `guardlink sarif ` computes, minus the git provenance. */ +async function exportOf(root: string, minSeverity?: 'critical' | 'high' | 'medium' | 'low'): Promise { + const { model, diagnostics } = await parseProject({ root, project: 'unused' }); + return generateSarif(model, diagnostics, findDanglingRefs(model), { + includeDiagnostics: true, includeDanglingRefs: true, minSeverity, + }); +} + +/** + * Delete exactly the members the enrichment adds. Deliberately a list, not a + * pattern: a member added later and not named here makes the byte comparison + * fail, which is the point — every addition has to be argued additive. + */ +function strip(run: Run): Pick { + const { results, tool } = structuredClone({ results: run.results, tool: run.tool }); + for (const r of results) { + delete r.taxa; + delete r.relatedLocations; + delete r.codeFlows; + for (const l of r.locations) delete l.logicalLocations; + if (r.properties) { + delete r.properties['guardlink/anchor']; + delete r.properties['guardlink/flowAttribution']; + } + } + for (const rule of tool.driver.rules) { + delete rule.help; + delete rule.properties; + } + return { results, tool }; +} + +/** The driver version moves with package.json; nothing else in `tool` may. */ +const withoutVersion = (tool: Run['tool']) => ({ ...tool, driver: { ...tool.driver, version: '' } }); + +const baseline = (name: string): Run => + JSON.parse(readFileSync(join(baselineDir, `${name}.sarif`), 'utf-8')).runs[0]; + +const validator = (() => { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + return ajv.compile(JSON.parse(readFileSync(join(baselineDir, 'sarif-schema-2.1.0.json'), 'utf-8'))); +})(); + +function expectSchemaValid(sarif: unknown): void { + const ok = validator(JSON.parse(JSON.stringify(sarif))); + expect(validator.errors ?? []).toEqual([]); + expect(ok).toBe(true); +} + +const byMessage = (run: Run, start: string): Result => { + const found = run.results.find(r => r.message.text.startsWith(start)); + if (!found) throw new Error(`no result starting "${start}"`); + return found; +}; +const props = (r: Result) => r.properties as Record; + +// ─── Additive ──────────────────────────────────────────────────────── + +describe('SARIF enrichment is additive', () => { + for (const name of ['expense-api', 'sarif-shop']) { + it(`${name}: stripping the new members gives back the earlier export's results and tool, byte for byte`, async () => { + const run = (await exportOf(fixture(name))).runs[0]; + const before = baseline(name); + const after = strip(run); + expect(JSON.stringify(after.results)).toBe(JSON.stringify(before.results)); + expect(JSON.stringify(withoutVersion(after.tool))).toBe(JSON.stringify(withoutVersion(before.tool))); + }); + + it(`${name}: the comparison is not vacuous — the enrichment did add members`, async () => { + const run = (await exportOf(fixture(name))).runs[0]; + expect(run.results.length).toBeGreaterThan(0); + expect(run.results.some(r => r.codeFlows?.length)).toBe(true); + expect(run.results.some(r => r.relatedLocations?.length)).toBe(true); + expect(run.results.some(r => r.taxa?.length)).toBe(true); + expect(run.results.some(r => r.locations[0].logicalLocations?.length)).toBe(true); + expect(run.graphs?.[0].edges.length).toBeGreaterThan(0); + expect(run.tool.driver.rules.every(r => r.help && r.properties)).toBe(true); + }); + } + + it('keeps the run envelope: provenance properties unchanged, new ones appended', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const before = baseline('sarif-shop'); + const { sarif_profile_version, ...rest } = run.properties; + expect(sarif_profile_version).toBe(SARIF_PROFILE_VERSION); + expect(Object.keys(rest)).toEqual(Object.keys(before.properties)); + expect(rest.annotation_hash).toBe(before.properties.annotation_hash); + // Existing run members first, in their old order. + expect(Object.keys(run).slice(0, 3)).toEqual(['tool', 'results', 'properties']); + }); +}); + +// ─── Schema ────────────────────────────────────────────────────────── + +describe('SARIF enrichment validates against the SARIF 2.1.0 schema', () => { + it('expense-api', async () => expectSchemaValid(await exportOf(fixture('expense-api')))); + it('sarif-shop', async () => expectSchemaValid(await exportOf(fixture('sarif-shop')))); + it('this repository', async () => expectSchemaValid(await exportOf(repoRoot)), 60_000); + + it('with version control provenance', async () => { + const { model } = await parseProject({ root: fixture('sarif-shop'), project: 'unused' }); + expectSchemaValid(generateSarif(model, [], [], { + versionControl: { repositoryUri: 'https://example.com/org/shop', revisionId: 'a'.repeat(40), branch: 'main' }, + })); + }); + + it('a partial model with no assets, flows or boundaries', () => { + expectSchemaValid(generateSarif({ + exposures: [{ asset: '#a', threat: '#t', severity: 'low', external_refs: ['cwe:CWE-79'], location: { file: 'a.ts', line: 1 } }], + mitigations: [], acceptances: [], confirmed: [], flows: [], + } as unknown as ThreatModel)); + }); +}); + +// ─── Code flows ────────────────────────────────────────────────────── + +describe('codeFlows: the declared @flows chain into a claim', () => { + it('follows the chain whose hops are declared on the claim\'s own handler, ending at the claim', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const sqli = byMessage(run, '#store is exposed to sqli'); + expect(props(sqli)['guardlink/flowAttribution']).toBe('handler'); + const [first] = sqli.codeFlows!; + const steps = first.threadFlows[0].locations; + expect(steps.map(s => s.location.message?.text)).toEqual([ + 'Client -> #web via POST./orders/search [crosses #edge]', + '#web -> #orders via searchOrders', + '#orders -> #store via query [crosses #data]', + '@exposes #store to #sqli', + ]); + expect(steps.map(s => s.kinds)).toEqual([ + ['flow', 'boundary-crossing'], ['flow'], ['flow', 'boundary-crossing'], ['claim'], + ]); + expect(steps[0].webRequest).toEqual({ method: 'POST', target: '/orders/search' }); + expect(steps.slice(0, 3).map(s => s.properties?.['guardlink/hopAttribution'])).toEqual(['handler', 'handler', 'handler']); + expect(steps[3].importance).toBe('essential'); + // Every hop is sited at its own @flows line. + expect(steps.every(s => s.location.physicalLocation.artifactLocation.uri === 'src/web.ts')).toBe(true); + }); + + it('marks an upstream hop joined only by graph adjacency as such', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const ssrf = byMessage(run, '#mailer is exposed to ssrf'); + expect(props(ssrf)['guardlink/flowAttribution']).toBe('file'); + for (const flow of ssrf.codeFlows!) { + const steps = flow.threadFlows[0].locations; + const last = steps[steps.length - 2]; + expect(last.location.message?.text).toBe('#orders -> #mailer via notify'); + expect(last.properties?.['guardlink/hopAttribution']).toBe('file'); + // The hops before it are in another file: nothing ties them to this claim. + for (const s of steps.slice(0, -2)) expect(s.properties?.['guardlink/hopAttribution']).toBe('graph'); + } + }); + + it('emits no chain when no hop into the asset is declared on the claim\'s handler or file', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + // src/export.ts declares no flow; the only flows into #store are in src/web.ts. + const dos = byMessage(run, '#store is exposed to dos'); + expect(dos.codeFlows).toBeUndefined(); + expect(props(dos)).not.toHaveProperty('guardlink/flowAttribution'); + // deleteOrder's file has flows into #orders, but only on SIBLING handlers. + const csrf = byMessage(run, '#orders is exposed to csrf'); + expect(csrf.codeFlows).toBeUndefined(); + }); + + it('gives a @confirmed result its chain too, ending at the confirmed claim', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const confirmed = run.results.find(r => r.ruleId === 'guardlink/confirmed-exploitable')!; + const steps = confirmed.codeFlows![0].threadFlows[0].locations; + expect(steps[steps.length - 1].location.message?.text).toBe('@confirmed #idor on #orders'); + expect(steps.map(s => s.location.message?.text)).toContain('Client -> #web via GET./orders/:id [crosses #edge]'); + }); + + it('emits at most three chains per result', async () => { + const run = (await exportOf(fixture('expense-api'))).runs[0]; + expect(Math.max(...run.results.map(r => r.codeFlows?.length ?? 0))).toBe(3); + }); + + it('terminates on a cycle and on a dense graph', () => { + // A ring of 12 assets with every pair joined both ways: chain enumeration + // without bounds would explode. The export must still return promptly. + const assets = Array.from({ length: 12 }, (_, i) => `#n${i}`); + const flows = assets.flatMap((a, i) => assets.filter((_, j) => j !== i).map(b => ({ + source: a, target: b, mechanism: 'call', location: { file: 'g.ts', line: i + 1 }, + }))); + flows.push({ source: 'Client', target: '#n0', mechanism: 'HTTPS', location: { file: 'g.ts', line: 99 } }); + const model = { + assets: assets.map((a, i) => ({ path: [`N${i}`], id: a.slice(1), location: { file: 'd.ts', line: i + 1 } })), + exposures: [{ asset: '#n5', threat: '#t', severity: 'high', external_refs: [], location: { file: 'g.ts', line: 6 } }], + mitigations: [], acceptances: [], confirmed: [], boundaries: [], flows, + } as unknown as ThreatModel; + const started = Date.now(); + const sarif = generateSarif(model); + expect(Date.now() - started).toBeLessThan(5_000); + expect(sarif.runs[0].results).toHaveLength(1); + const chains = sarif.runs[0].results[0].codeFlows!; + expect(chains.length).toBeLessThanOrEqual(3); + // The budget is spent shortest-first, so the two-hop chain from the one + // entry is found rather than lost inside the cluster. + expect(chains[0].threadFlows[0].locations.map(l => l.location.message?.text)).toEqual([ + 'Client -> #n0 via HTTPS', '#n0 -> #n5 via call', '@exposes #n5 to #t', + ]); + }); +}); + +// ─── Related locations, taxa, logical locations ────────────────────── + +describe('relatedLocations: declared context on the claim\'s asset', () => { + it('lists boundaries, assumptions, data classes, transfers and audits, each sited and keyed', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const ssrf = byMessage(run, '#mailer is exposed to ssrf'); + expect(ssrf.relatedLocations!.map(l => [l.id, l.properties?.['guardlink/verb'], l.message?.text])).toEqual([ + [1, 'assumes', '@assumes #mailer: Callers pass only registered webhook URLs'], + [2, 'transfers', '@transfers #ssrf from #mailer to #web: URL validation is the API layer\'s job'], + [3, 'audit', '@audit #mailer: Confirm the API layer really rejects private address ranges'], + ]); + for (const l of ssrf.relatedLocations!) { + expect(l.properties?.['guardlink/claimKey']).toMatch(/^[0-9a-f]{64}:\d+$/); + expect(l.physicalLocation.artifactLocation.uri).toBe('src/notify.ts'); + } + // A transfer names one threat: it is context for #ssrf, not for #log-flood. + const flood = byMessage(run, '#mailer is exposed to log-flood'); + expect(flood.relatedLocations!.map(l => l.properties?.['guardlink/verb'])).toEqual(['assumes', 'audit']); + }); + + it('points a boundary at its graph edge', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const sqli = byMessage(run, '#store is exposed to sqli'); + const boundary = sqli.relatedLocations!.find(l => l.properties?.['guardlink/verb'] === 'boundary')!; + expect(boundary.message?.text).toBe('@boundary between #orders and #store (#data): Service to database'); + const edge = run.graphs![0].edges.find(e => e.id === boundary.properties?.['guardlink/edge'])!; + expect(edge.properties['guardlink/boundaryId']).toBe('data'); + expect(sqli.relatedLocations!.map(l => l.properties?.['guardlink/verb'])).toEqual(['boundary', 'handles']); + }); +}); + +describe('taxa: CWE and OWASP refs as SARIF taxonomies', () => { + it('maps the claim\'s own refs, keeping properties.externalRefs', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const sqli = byMessage(run, '#store is exposed to sqli'); + expect(props(sqli).externalRefs).toEqual(['cwe:CWE-89', 'owasp:A03:2021']); + expect(sqli.taxa!.map(t => [t.toolComponent.name, t.id])).toEqual([['CWE', '89'], ['OWASP', 'A03:2021']]); + }); + + it('resolves every reference through run.taxonomies by index', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + expect(run.taxonomies!.map(t => t.name)).toEqual(['CWE', 'OWASP']); + for (const r of run.results) { + for (const t of r.taxa ?? []) { + const tax = run.taxonomies![t.toolComponent.index]; + expect(tax.name).toBe(t.toolComponent.name); + expect(tax.taxa[t.index].id).toBe(t.id); + } + } + }); + + it('ignores schemes it has no taxonomy for, and omits run.taxonomies when nothing is referenced', () => { + const sarif = generateSarif({ + exposures: [{ asset: '#a', threat: '#t', severity: 'low', external_refs: ['capec:CAPEC-66'], location: { file: 'a.ts', line: 1 } }], + mitigations: [], acceptances: [], confirmed: [], flows: [], + } as unknown as ThreatModel); + expect(sarif.runs[0].results[0].taxa).toBeUndefined(); + expect(sarif.runs[0].taxonomies).toBeUndefined(); + }); +}); + +describe('logicalLocations and the anchor', () => { + it('names the handler a claim is attached to, and leaves the region on the annotation line', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const before = byMessage(baseline('sarif-shop') as Run, '#store is exposed to sqli'); + const sqli = byMessage(run, '#store is exposed to sqli'); + expect(sqli.locations[0].logicalLocations).toEqual([{ name: 'searchOrders' }]); + expect(sqli.locations[0].physicalLocation).toEqual(before.locations[0].physicalLocation); + expect(props(sqli)['guardlink/anchor']).toMatchObject({ scope: 'symbol', symbol: 'searchOrders' }); + }); + + it('gives a module-level claim no logical location', async () => { + const run = (await exportOf(fixture('sarif-shop'))).runs[0]; + const ssrf = byMessage(run, '#mailer is exposed to ssrf'); + expect(ssrf.locations[0].logicalLocations).toBeUndefined(); + expect(props(ssrf)['guardlink/anchor']).toMatchObject({ scope: 'file' }); + }); +}); + +// ─── Graph ─────────────────────────────────────────────────────────── + +describe('run.graphs: the declared flow and boundary graph', () => { + it('carries every @flows and @boundary as an edge between existing nodes', async () => { + const { model } = await parseProject({ root: fixture('sarif-shop'), project: 'unused' }); + const graph = generateSarif(model).runs[0].graphs![0]; + const nodeIds = new Set(graph.nodes.map(n => n.id)); + expect(graph.edges.filter(e => e.properties['guardlink/kind'] === 'flow')).toHaveLength(model.flows.length); + expect(graph.edges.filter(e => e.properties['guardlink/kind'] === 'boundary')).toHaveLength(model.boundaries.length); + for (const e of graph.edges) { + expect(nodeIds.has(e.sourceNodeId) && nodeIds.has(e.targetNodeId)).toBe(true); + expect(e.properties['guardlink/claimKey']).toMatch(/^[0-9a-f]{64}:\d+$/); + } + expect(new Set(graph.edges.map(e => e.id)).size).toBe(graph.edges.length); + // Declared assets are nodes with their definition site; undeclared endpoints are marked. + expect(graph.nodes.find(n => n.id === 'store')).toMatchObject({ label: { text: '#store' }, properties: { 'guardlink/declared': true } }); + expect(graph.nodes.find(n => n.id === 'client')).toMatchObject({ label: { text: 'Client' }, properties: { 'guardlink/declared': false } }); + }); + + it('infers a boundary\'s outer side only from an undeclared endpoint', async () => { + const graph = (await exportOf(fixture('sarif-shop'))).runs[0].graphs![0]; + const edge = graph.edges.find(e => e.properties['guardlink/boundaryId'] === 'edge')!; + expect(edge.properties['guardlink/side']).toEqual({ basis: 'undeclared-endpoint', outer: 'client', inner: 'web' }); + const data = graph.edges.find(e => e.properties['guardlink/boundaryId'] === 'data')!; + expect(data.properties['guardlink/side']).toEqual({ basis: 'unknown' }); + }); + + it('links each boundary to the flows that cross it, and each crossing flow back', async () => { + const graph = (await exportOf(fixture('sarif-shop'))).runs[0].graphs![0]; + const edge = graph.edges.find(e => e.properties['guardlink/boundaryId'] === 'edge')!; + const crossings = edge.properties['guardlink/crossings'] as string[]; + expect(crossings.length).toBe(4); // HTTPS, GET, POST, DELETE + for (const id of crossings) { + const flow = graph.edges.find(e => e.id === id)!; + expect(flow.properties['guardlink/crosses']).toEqual([edge.id]); + } + const post = graph.edges.find(e => e.label?.text === 'POST./orders/search')!; + expect(post.properties['guardlink/route']).toEqual({ http_method: 'POST', http_path: '/orders/search' }); + }); +}); + +// ─── Rules and run envelope ───────────────────────────────────────── + +describe('rules and the run envelope', () => { + const run = generateSarif({ exposures: [], mitigations: [], acceptances: [], confirmed: [], flows: [] } as unknown as ThreatModel).runs[0]; + const rule = (id: string) => run.tool.driver.rules.find(r => r.id === id)!; + + it('bands security-severity for GitHub, and keeps diagnostics out of the security category', () => { + expect(rule('guardlink/confirmed-exploitable').properties).toEqual({ tags: ['security', 'threat-model'], 'security-severity': '9.0' }); + expect(rule('guardlink/unmitigated-critical').properties).toEqual({ tags: ['security', 'threat-model'], 'security-severity': '8.9' }); + expect(rule('guardlink/unmitigated-exposure').properties).toEqual({ tags: ['security', 'threat-model'], 'security-severity': '5.0' }); + for (const id of ['guardlink/parse-error', 'guardlink/dangling-ref']) { + expect(rule(id).properties).toEqual({ tags: ['threat-model'] }); + } + for (const r of run.tool.driver.rules) expect(r.help?.text && r.help?.markdown).toBeTruthy(); + }); + + it('files results under their own code scanning category', () => { + expect(run.automationDetails).toEqual({ id: SARIF_AUTOMATION_ID }); + expect(SARIF_AUTOMATION_ID).toBe('guardlink/threat-model/'); + }); + + it('writes versionControlProvenance only when given a repository URI, dropping empty fields', () => { + expect(run.versionControlProvenance).toBeUndefined(); + const m = { exposures: [], mitigations: [], acceptances: [], confirmed: [], flows: [] } as unknown as ThreatModel; + expect(generateSarif(m, [], [], { versionControl: { repositoryUri: 'https://example.com/o/r', revisionId: 'abc', branch: null } }) + .runs[0].versionControlProvenance).toEqual([{ repositoryUri: 'https://example.com/o/r', revisionId: 'abc' }]); + expect(generateSarif(m, [], [], { versionControl: null }).runs[0].versionControlProvenance).toBeUndefined(); + }); +}); + +describe('readVersionControl', () => { + const roots: string[] = []; + afterAll(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }); }); + const repo = (remote?: string) => { + const root = mkdtempSync(join(tmpdir(), 'gl-sarif-vcs-')); + roots.push(root); + const git = (args: string) => execSync(`git ${args}`, { cwd: root, stdio: 'pipe' }); + git('init -q -b trunk'); + git('-c user.email=t@example.com -c user.name=t commit -q --allow-empty -m init'); + if (remote) git(`remote add origin ${remote}`); + return root; + }; + + it('reads the web URL, HEAD and branch, and never the credentials in the remote', () => { + const root = repo('https://someone:s3cret-token@github.com/acme/shop.git'); + const vcs = readVersionControl(root)!; + expect(vcs.repositoryUri).toBe('https://github.com/acme/shop'); + expect(vcs.revisionId).toMatch(/^[0-9a-f]{40}$/); + expect(vcs.branch).toBe('trunk'); + expect(JSON.stringify(vcs)).not.toContain('s3cret'); + }); + + it('is null with no origin, or an origin that is a local path', () => { + expect(readVersionControl(repo())).toBeNull(); + expect(readVersionControl(repo('/srv/git/shop.git'))).toBeNull(); + }); +}); + +// ─── The two front-end defects ─────────────────────────────────────── + +describe('--min-severity', () => { + it('filters unmitigated exposures and always keeps @confirmed results', async () => { + const all = (await exportOf(fixture('sarif-shop'))).runs[0]; + const critical = (await exportOf(fixture('sarif-shop'), 'critical')).runs[0]; + const exposures = (run: Run) => run.results.filter(r => r.ruleId.startsWith('guardlink/unmitigated')); + expect(exposures(all).length).toBeGreaterThan(1); + expect(exposures(critical).map(r => props(r).severity)).toEqual(['critical']); + // The confirmed finding is [high], below the floor, and is still exported. + const confirmed = critical.results.filter(r => r.ruleId === 'guardlink/confirmed-exploitable'); + expect(confirmed.map(r => props(r).severity)).toEqual(['high']); + }); +}); + +describe('MCP guardlink_sarif', () => { + const roots: string[] = []; + afterAll(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }); }); + + it('reports dangling refs exactly as `guardlink sarif` does', async () => { + const root = mkdtempSync(join(tmpdir(), 'gl-sarif-mcp-')); + roots.push(root); + cpSync(fixture('sarif-shop'), root, { recursive: true }); + + const server = createServer(); + const client = new Client({ name: 'test', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + await client.callTool({ name: 'guardlink_sarif', arguments: { root, output: 'out.sarif' } }); + } finally { + await client.close(); + } + + const viaMcp = JSON.parse(readFileSync(join(root, 'out.sarif'), 'utf-8')).runs[0] as Run; + const viaCli = (await exportOf(root)).runs[0]; + expect(viaMcp.results.filter(r => r.ruleId === 'guardlink/dangling-ref')).toHaveLength(1); + expect(JSON.stringify(viaMcp.results)).toBe(JSON.stringify(viaCli.results)); + }); +}); From fea5ee9dcc7e3f42491deb0c57773d26e4a127be Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 00:49:24 +0530 Subject: [PATCH 2/4] docs(spec): rewrite SARIF section 6 to match the export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6.1-§6.4 promised results for @accepts, @assumes, @audit, @mitigates and @handles, low severity as note, a cwe result property and TS200x rule ids; the exporter has produced none of them. They now state the five rules, the result order and levels, what every other annotation becomes and why it is not a result, the rule help and security-severity bands, and the taxa mapping, with an example cut from the sarif-shop fixture. §6.5 is unchanged. New §6.6 describes the declared context and how a chain is attributed; new §6.7 the run envelope and the compatibility invariants. --- docs/SPEC.md | 254 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 181 insertions(+), 73 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 0a8a1dba..6694ac24 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1050,24 +1050,43 @@ A `@validates` annotation with no corresponding test execution data is **unverif ## 6. SARIF Output Mapping -GuardLink annotations map naturally to SARIF 2.1.0 (Static Analysis Results Interchange Format) for integration with GitHub Code Scanning, GitLab SAST, Azure DevOps, and other platforms. +`guardlink sarif` (and the MCP `guardlink_sarif` tool and the TUI `/sarif` command, which produce the same document) exports the threat model as one SARIF 2.1.0 run for GitHub code scanning, VS Code, Azure DevOps and other SARIF consumers. This section describes what that export contains, and what it deliberately leaves out. + +**The export is a contract in two layers.** The *results* — which annotations become results, in what order, under which rule id, with which `message.text`, `partialFingerprints` and existing `properties` — are stable: consumers key on the result index, on `message.text` and on rule ids, and GitHub reopens every alert whose rule id changes. Everything else the model declares around a finding is *declared context* (§6.6): SARIF members appended after the stable ones, which a consumer that does not know them ignores. Deleting every declared-context member gives back the results and the `tool` object of an export cut before the context existed, byte for byte, and the test suite checks exactly that. ### 6.1. Mapping Rules -| Annotation | SARIF Representation | -|------------|---------------------| -| `@exposes` | `result` with `level: "warning"` or `"error"` (based on severity) | -| `@accepts` | `result` with `level: "note"`, `kind: "informational"` | -| `@assumes` | `result` with `level: "note"`, `kind: "review"` | -| `@audit` | `result` with `level: "note"`, `kind: "review"` | -| `@mitigates` | `result` with `level: "none"`, `kind: "pass"` (suppresses matching `@exposes`) | -| `@handles` (secrets/pii) | `result` with `level: "note"` for data flow tracking | -| `@actor` / `@entitles` | **Not exported.** No result, no suppression, no property on any result | +Results, in this order: + +| Source | `ruleId` | `level` | `message.text` | +|---|---|---|---| +| `@exposes` not covered by a `@mitigates` or `@accepts` (§3.6.1), severity `critical` or `high` | `guardlink/unmitigated-critical` | `error` | `{asset} is exposed to {threat}` + `: {description}` when present | +| the same, any other severity or none | `guardlink/unmitigated-exposure` | `warning` | as above | +| `@confirmed` | `guardlink/confirmed-exploitable` | `error` | `CONFIRMED: {asset} exploitable via {threat}` + `: {description}` when present | +| parse error (a diagnostic at level `error`) | `guardlink/parse-error` | `error` | the diagnostic message | +| reference to an undefined `#id` | `guardlink/dangling-ref` | `warning` | the diagnostic message | + +`{threat}` is written without its leading `#`. Exposures appear in model order, then confirmed findings, then parse errors, then dangling references. `--min-severity` drops exposures below the given severity and never drops a `@confirmed` result: a confirmed result is a reproduced exploit, and a noise filter must not be able to hide one. `--no-diagnostics` drops the parse errors. + +Every other annotation is **not** a result: -`@entitles` is deliberately absent from this mapping. Two annotations already remove an exposure from the export (`@mitigates`, `@accepts`), and an exposure hidden from the export cannot be tested. Entitlement is a claim about *purpose* that no probe can verify, so it must never be the third such mechanism: a conforming exporter MUST produce **byte-identical `runs[].results` and `runs[].tool`** for a model with entitlements and the same model without them. Only the downstream *recommendation* may change (§3.2). +| Annotation | In the export | Why it is not a result | +|---|---|---| +| `@mitigates`, `@accepts` | Not at all. A covered exposure is left out of the results. | GitHub ignores SARIF `suppressions` and would open a covered exposure as an alert. `runs[0].properties.acceptance_register` names where the acceptances that removed results came from (§6.7). | +| `@boundary`, `@flows` | `run.graphs`, `relatedLocations` (boundaries), `codeFlows` (§6.6) | Context, not findings. | +| `@assumes`, `@handles`, `@audit` | `relatedLocations` on results for the same asset (§6.6) | Context, not findings. | +| `@transfers` | `relatedLocations` on results for the same asset and threat (§6.6). A transfer does not cover an exposure. | Context, not findings. | +| `@validates`, `@owns`, `@feature`, `@comment`, `@shield` | Not exported | — | +| `@actor` / `@entitles` | **Not exported.** No result, no suppression, no property on any result, and nothing in the declared context | See below. | + +New result kinds — covered exposures with their suppressions, `@boundary` claims, review items for `@assumes` and `@audit` — would change the result index every existing consumer keys on and open a GitHub alert per annotation. If they are added, it will be behind an opt-in profile that appends them after every result above, never in this default export. + +`@entitles` is deliberately absent from this mapping. Two annotations already remove an exposure from the export (`@mitigates`, `@accepts`), and an exposure hidden from the export cannot be tested. Entitlement is a claim about *purpose* that no probe can verify, so it must never be the third such mechanism: a conforming exporter MUST produce **byte-identical `runs[].results` and `runs[].tool`** for a model with entitlements and the same model without them. Only the downstream *recommendation* may change (§3.2). The declared context is held to the same rule: no related location, chain, graph node or taxon may be derived from an `@entitles` or an `@actor`. This was previously stated as byte-identical SARIF *documents*, and the narrower wording is the accurate one rather than a relaxation. `runs[].properties` carries provenance — the `annotation_hash` naming the annotation set the export was cut from — and an `@entitles` **is** an annotation, so a hash that could not see one would report a model whose entitlements had been rewritten as unchanged. That is the silent all-clear §2 exists to prevent, and the reason the annotation hash was taught about entitlements in the first place. The invariant that matters is the one the table above states literally: no result, no suppression, and no property **on any result**. Provenance is not a finding. +**Result properties.** Each `@exposes` and `@confirmed` result carries, in `properties`: `threatId` and (exposures only) `claimKey`, mirroring `partialFingerprints` (§6.5); `severity` (`unset` when the annotation has none); `asset` and `threat` as written; `externalRefs` when the annotation has any; and the route, below. The declared context adds `guardlink/anchor` and `guardlink/flowAttribution` (§6.6). Parse-error and dangling-ref results carry no `properties`. + **Routes.** Each `@exposes` and `@confirmed` result names the HTTP route that reaches it, decided by §3.6.2, in its `properties`: | §3.6.2 answer | `properties` | @@ -1080,34 +1099,44 @@ A consumer that probes the endpoint in `codegraph_reachability` is told which st ### 6.2. Severity Mapping -| GuardLink Severity | SARIF Level | -|--------------------|-------------| -| `critical` / `P0` | `error` | -| `high` / `P1` | `error` | -| `medium` / `P2` | `warning` | -| `low` / `P3` | `note` | -| (no severity) | `warning` | +Rules are per *coverage state*, not per threat, so the `level` follows the rule: + +| GuardLink severity | Rule | SARIF `level` | +|---|---|---| +| `critical` / `P0`, `high` / `P1` | `guardlink/unmitigated-critical` | `error` | +| `medium` / `P2`, `low` / `P3`, none | `guardlink/unmitigated-exposure` | `warning` | +| any, on a `@confirmed` | `guardlink/confirmed-exploitable` | `error` | + +Low severity is a `warning`, not a `note`: the rule decides the level, and one rule holds medium, low and unset alike. The exact severity is always in the result's `properties.severity`. + +Each rule also carries `help` (`text` and `markdown`) and `properties` for GitHub code scanning: + +| Rule | `tags` | `security-severity` | GitHub band | +|---|---|---|---| +| `guardlink/confirmed-exploitable` | `security`, `threat-model` | `9.0` | critical | +| `guardlink/unmitigated-critical` | `security`, `threat-model` | `8.9` | high | +| `guardlink/unmitigated-exposure` | `security`, `threat-model` | `5.0` | medium | +| `guardlink/parse-error`, `guardlink/dangling-ref` | `threat-model` | — | not a security alert | + +A rule can only be banded: `unmitigated-critical` covers both critical and high exposures and therefore sits in the high band. Exact per-finding severity on GitHub, and CWE tags (GitHub reads a CWE only from a rule's `tags`, as `external/cwe/cwe-89`), would need a rule per threat, which would change every rule id and reopen every existing alert. That is not done in this export. ### 6.3. External Reference Mapping -When annotations include external references (§2.8), they map to SARIF `taxa` references: +External references (§2.8) on an `@exposes` or `@confirmed` stay in `properties.externalRefs` exactly as written, and `cwe:` and `owasp:` references are also mapped to SARIF taxa: -```json -{ - "taxa": [ - { - "toolComponent": { "name": "CWE", "guid": "..." }, - "id": "CWE-89", - "index": 0 - } - ] -} -``` +| Reference | `result.taxa[]` entry | +|---|---| +| `cwe:CWE-89` (or `cwe:89`) | `{ "id": "89", "toolComponent": { "name": "CWE", … } }` — the numeric id the MITRE CWE taxonomy uses | +| `owasp:A03:2021` | `{ "id": "A03:2021", "toolComponent": { "name": "OWASP", … } }` | +| `owasp:A03` | `{ "id": "A03", "toolComponent": { "name": "OWASP", … } }` | +| any other scheme (`capec:`, `cve:`, `attack:`, …) | none; it stays in `externalRefs` only | -CWE references additionally populate the `cwe` property on SARIF results, which GitHub Code Scanning uses for CWE badge display. +`run.taxonomies` holds one entry per taxonomy referenced (`CWE` from MITRE, `OWASP` from the OWASP Foundation), each with `isComprehensive: false` and only the taxa the results reference. Every reference carries both indexes: `toolComponent.index` into `run.taxonomies`, and `index` into that taxonomy's `taxa`. Only the claim's own references are mapped — the same ones `externalRefs` carries — not those of the `@threat` definition. ### 6.4. Example SARIF Output +One exposure from the `tests/fixtures/sarif-shop` fixture, abridged (`…`) where the shape repeats: + ```json { "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", @@ -1115,61 +1144,100 @@ CWE references additionally populate the `cwe` property on SARIF results, which "runs": [{ "tool": { "driver": { - "name": "guardlink", - "version": "1.0.0", - "informationUri": "https://github.com/Bugb-Technologies/guardlink", + "name": "GuardLink", + "version": "2.1.0", + "informationUri": "https://guardlink.bugb.io", "rules": [{ - "id": "TS2001", - "name": "ExposedThreat", - "shortDescription": { "text": "Code exposes an asset to a known threat" }, - "defaultConfiguration": { "level": "warning" } - }, { - "id": "TS2002", - "name": "UnmitigatedExposure", - "shortDescription": { "text": "Exposure has no corresponding mitigation" }, - "defaultConfiguration": { "level": "error" } - }, { - "id": "TS2003", - "name": "AcceptedRisk", - "shortDescription": { "text": "Risk has been consciously accepted" }, - "defaultConfiguration": { "level": "note" } - }, { - "id": "TS2004", - "name": "SecurityAssumption", - "shortDescription": { "text": "Code relies on a security assumption" }, - "defaultConfiguration": { "level": "note" } - }, { - "id": "TS2005", - "name": "AuditRequired", - "shortDescription": { "text": "Code flagged for security review" }, - "defaultConfiguration": { "level": "note" } - }] + "id": "guardlink/unmitigated-critical", + "name": "UnmitigatedCriticalExposure", + "shortDescription": { "text": "Critical/high severity exposure with no mitigation" }, + "fullDescription": { "text": "A critical or high severity exposure exists without mitigation. This should be addressed before deployment." }, + "helpUri": "https://guardlink.bugb.io/docs/exposures", + "defaultConfiguration": { "level": "error" }, + "help": { "text": "…", "markdown": "…" }, + "properties": { "tags": ["security", "threat-model"], "security-severity": "8.9" } + }, "…"] } }, "results": [{ - "ruleId": "TS2001", + "ruleId": "guardlink/unmitigated-critical", "level": "error", - "message": { "text": "App.API.Users is exposed to IDOR [P1]: No ownership check on GET /users/:id" }, + "message": { "text": "#store is exposed to sqli: Search term concatenated into SQL" }, "locations": [{ - "physicalLocation": { - "artifactLocation": { "uri": "src/api/users.ts" }, - "region": { "startLine": 42 } - }, - "logicalLocations": [{ - "name": "getUser", - "kind": "function" - }] + "physicalLocation": { "artifactLocation": { "uri": "src/web.ts" }, "region": { "startLine": 24 } }, + "logicalLocations": [{ "name": "searchOrders" }] }], - "taxa": [{ - "toolComponent": { "name": "CWE" }, - "id": "CWE-639" + "partialFingerprints": { "guardlink/threatId": "gl-…", "guardlink/claimKey": "…:0" }, + "properties": { + "threatId": "gl-…", "claimKey": "…:0", "severity": "critical", "asset": "#store", "threat": "#sqli", + "externalRefs": ["cwe:CWE-89", "owasp:A03:2021"], + "codegraph_reachability": { "http_method": "POST", "http_path": "/orders/search" }, + "route_attribution": "handler", + "guardlink/anchor": { "scope": "symbol", "symbol": "searchOrders", "start_line": 27, "end_line": 29 }, + "guardlink/flowAttribution": "handler" + }, + "taxa": [ + { "id": "89", "index": 0, "toolComponent": { "name": "CWE", "index": 0 } }, + { "id": "A03:2021", "index": 0, "toolComponent": { "name": "OWASP", "index": 1 } } + ], + "relatedLocations": [{ + "id": 1, + "physicalLocation": { "artifactLocation": { "uri": "src/web.ts" }, "region": { "startLine": 23 } }, + "message": { "text": "@boundary between #orders and #store (#data): Service to database" }, + "properties": { "guardlink/verb": "boundary", "guardlink/claimKey": "…:0", "guardlink/edge": "boundary:1" } + }, "…"], + "codeFlows": [{ + "message": { "text": "Declared @flows chain from Client to #store; the last hop is declared on the claim's handler" }, + "threadFlows": [{ "locations": [ + { "location": { "physicalLocation": { "…": "src/web.ts:20" }, "message": { "text": "Client -> #web via POST./orders/search [crosses #edge]" } }, + "kinds": ["flow", "boundary-crossing"], "webRequest": { "method": "POST", "target": "/orders/search" }, + "properties": { "guardlink/edge": "flow:5", "guardlink/claimKey": "…:0", "guardlink/hopAttribution": "handler", "guardlink/crosses": ["boundary:0"] } }, + { "location": { "physicalLocation": { "…": "src/web.ts:21" }, "message": { "text": "#web -> #orders via searchOrders" } }, + "kinds": ["flow"], "properties": { "…": "…" } }, + { "location": { "physicalLocation": { "…": "src/web.ts:22" }, "message": { "text": "#orders -> #store via query [crosses #data]" } }, + "kinds": ["flow", "boundary-crossing"], "properties": { "…": "…" } }, + { "location": { "physicalLocation": { "…": "src/web.ts:24" }, "message": { "text": "@exposes #store to #sqli" } }, + "kinds": ["claim"], "importance": "essential" } + ]}], + "properties": { "guardlink/flowAttribution": "handler" } }] + }], + "properties": { + "annotation_hash": "sha256-v3:…", + "annotation_hash_version": 3, + "generator": "guardlink@2.1.0", + "acceptance_register": "code-annotations", + "sarif_profile_version": 1 + }, + "automationDetails": { "id": "guardlink/threat-model/" }, + "versionControlProvenance": [{ "repositoryUri": "https://github.com/acme/shop", "revisionId": "…", "branch": "main" }], + "taxonomies": [ + { "name": "CWE", "organization": "MITRE", "informationUri": "https://cwe.mitre.org/", "isComprehensive": false, "taxa": [{ "id": "89" }] }, + { "name": "OWASP", "organization": "OWASP Foundation", "informationUri": "https://owasp.org/Top10/", "isComprehensive": false, "taxa": [{ "id": "A03:2021" }] } + ], + "graphs": [{ + "description": { "text": "GuardLink declared threat model: …" }, + "nodes": [ + { "id": "store", "label": { "text": "#store" }, "location": { "…": "…" }, "properties": { "guardlink/declared": true, "guardlink/path": "Shop.Store" } }, + { "id": "client", "label": { "text": "Client" }, "properties": { "guardlink/declared": false } }, + "…" + ], + "edges": [{ + "id": "boundary:0", "sourceNodeId": "client", "targetNodeId": "web", + "label": { "text": "Internet to API: nothing before this point is trusted" }, + "properties": { + "guardlink/kind": "boundary", "guardlink/directed": false, "guardlink/boundaryId": "edge", + "guardlink/claimKey": "…:0", "guardlink/location": { "…": "…" }, + "guardlink/side": { "basis": "undeclared-endpoint", "outer": "client", "inner": "web" }, + "guardlink/crossings": ["flow:2", "flow:3", "flow:5", "flow:8"] + } + }, "…"] }] }] } ``` -When uploaded to GitHub via the Code Scanning API, `@exposes` annotations appear as inline security alerts on the relevant lines in pull requests. +When uploaded to GitHub via the code scanning API, exposures and confirmed findings appear as security alerts on the annotation lines, banded by `security-severity`, with the declared chain under "Show paths". ### 6.5. Result Identity @@ -1346,6 +1414,46 @@ inherits `:0`, which is the deleted claim's exact key, so a finding stam first joins to the second. Separately: rewording a claim's own description re-keys it, so a stamp taken before the rewording is refused as stale. +### 6.6. Declared Context + +Members each `@exposes` and `@confirmed` result gains from the model, all appended after the members §6.1 and §6.5 describe. Parse-error and dangling-ref results gain none. + +| Member | Content | Present when | +|---|---|---| +| `locations[0].logicalLocations` | `[{ "name": symbol }]` — the function or other declaration the claim is attached to (§3.6). No `kind` is claimed: the structure layer knows the scope, not what sort of declaration it is | the claim is attached to a symbol or block, not to the file | +| `properties["guardlink/anchor"]` | `{ scope, symbol, start_line, end_line }` — the claim's anchor. The `region` stays the annotation line | the claim has an anchor | +| `taxa` | §6.3 | the claim has a `cwe:` or `owasp:` reference | +| `relatedLocations` | One entry per `@boundary` naming the claim's asset (either side), `@assumes`, `@handles` and `@audit` on it, and `@transfers` of the claim's threat from it — in that order, numbered from `1`. `message.text` is the annotation as `@verb arguments: description`; `properties` holds `guardlink/verb`, `guardlink/claimKey` and, for a boundary, `guardlink/edge` (its graph edge id) | any such annotation exists | +| `codeFlows` | Up to three declared `@flows` chains into the claim's asset, then the claim itself (`kinds: ["claim"]`, `importance: "essential"`) | see below | +| `properties["guardlink/flowAttribution"]` | `handler` or `file`: how the chain's last hop is tied to the claim | `codeFlows` is present | + +**A chain is never guessed.** `@flows` hops are not linked by call site: the model can say `Client -> #api` and `#api -> #db` without saying which handler of `#api` makes the second call. So only a chain's **last** hop — the one into the claim's asset — is tied to the claim, by the same ladder route attribution uses (§3.6.2): declared on the claim's own handler or on code enclosing it (`handler`), or declared for the claim's file, or anywhere in the file of a file-level claim (`file`). A hop on a sibling handler, or in another file, ties nothing. When no chain's last hop is tied, the result has no `codeFlows` at all. Chains whose last hop has the strongest tie are emitted, preferring chains with more hops on the claim's handler, then more boundary crossings, then fewer hops. + +Each hop is a `threadFlowLocation` at its own `@flows` line: `message.text` is `A -> B via M`, with `[crosses #id]` when the hop's endpoints are the two sides of a `@boundary`; `kinds` is `["flow"]` or `["flow", "boundary-crossing"]`; a route channel adds `webRequest { method, target }`; and `properties` holds `guardlink/edge` (the graph edge), `guardlink/claimKey`, `guardlink/crosses` (boundary edge ids) and `guardlink/hopAttribution` — `handler` or `file` when the hop is itself tied to the claim, `graph` when it is joined to the next hop only because its target is that hop's source. A chain starts at an *entry*: an endpoint no `@asset` declares (outside the system, as `guardlink paths` reads it) or a declared asset nothing flows into. The walk is bounded in depth, in chains collected and in steps, and shorter chains are found first. + +**The graph.** `run.graphs[0]` holds the declared model as SARIF nodes and edges: + +- **Nodes** — every declared asset (`id` its canonical id, `label` `#id` or the dotted path, `location` its definition, `properties["guardlink/declared"]: true` and `guardlink/path`), then every `@flows` or `@boundary` endpoint no asset declares (`guardlink/declared: false`). Actors are not nodes. +- **Boundary edges** — `boundary:`, one per `@boundary`, undirected (`guardlink/directed: false`), with `guardlink/kind: "boundary"`, `guardlink/boundaryId`, `guardlink/claimKey`, `guardlink/location`, `guardlink/crossings` (the flow edges whose endpoints are its two sides) and `guardlink/side`. A boundary has no direction in the grammar, so the side is inferred only when exactly one endpoint is undeclared: `{ basis: "undeclared-endpoint", outer, inner }`. Otherwise it is `{ basis: "unknown" }`. +- **Flow edges** — `flow:`, one per `@flows` hop, directed, with `label` the mechanism, `guardlink/kind: "flow"`, `guardlink/claimKey`, `guardlink/location`, `guardlink/route` for a route channel, and `guardlink/crosses` when it crosses a boundary. + +Edge ids are positions in this export and change when annotations are added or removed; `guardlink/claimKey` is the identity that survives (§6.5). + +### 6.7. Run Envelope and Compatibility + +| Member | Content | +|---|---| +| `properties.annotation_hash`, `annotation_hash_version` | the annotation set the export was cut from (§8.2.2) | +| `properties.generator` | `guardlink@` | +| `properties.acceptance_register` | `code-annotations`: the acceptances that removed results were read from source, not from a server decision log | +| `properties.sarif_profile_version` | `1`: this shape. Absent on exports cut before the declared context existed. Bumped when the shape changes | +| `automationDetails.id` | `guardlink/threat-model/`, so GitHub files GuardLink alerts in their own category beside other tools. An upload that sets a `category` explicitly overrides it | +| `versionControlProvenance` | `[{ repositoryUri, revisionId, branch }]` — the repository's web URL (rebuilt from host and path, so credentials in the remote never reach the export), HEAD and branch. Omitted when the repository has no web-hosted `origin`, since SARIF requires `repositoryUri` | + +Artifact URIs are relative to the project root and carry no `uriBaseId`. + +A conforming exporter keeps these invariants across versions of this export: `message.text` byte-stable; rule ids stable; result order stable, with any new result only ever appended; `partialFingerprints` keys unchanged; `properties.annotation_hash` unchanged in meaning; nothing derived from `@entitles` or `@actor`; and no suppressed result. + --- ## 7. Diff and Change Detection From 048131a1699e2274e678ab9d1f7e551c46ef9587 Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 00:49:24 +0530 Subject: [PATCH 3/4] chore(artifacts): regenerate agent instructions and threat-model artifacts --- .clinerules | 8 +- .cursor/rules/guardlink.mdc | 8 +- .gemini/GEMINI.md | 8 +- .github/copilot-instructions.md | 8 +- .guardlink/README.md | 4 +- .guardlink/graph/MANIFEST.json | 160 ++++++++------- .guardlink/graph/attack-surface.mmd | 65 +++--- .../graph/by-asset/agent-launcher.flows.mmd | 2 +- .../graph/by-asset/agent-launcher.threats.mmd | 2 +- .guardlink/graph/by-asset/blame.flows.mmd | 2 +- .guardlink/graph/by-asset/blame.threats.mmd | 2 +- .guardlink/graph/by-asset/cli.flows.mmd | 2 +- .guardlink/graph/by-asset/cli.threats.mmd | 2 +- .guardlink/graph/by-asset/codegraph.flows.mmd | 2 +- .../graph/by-asset/codegraph.threats.mmd | 2 +- .guardlink/graph/by-asset/dashboard.flows.mmd | 2 +- .../graph/by-asset/dashboard.threats.mmd | 2 +- .guardlink/graph/by-asset/diff.flows.mmd | 2 +- .guardlink/graph/by-asset/diff.threats.mmd | 2 +- .../graph/by-asset/estate-view.flows.mmd | 4 +- .../graph/by-asset/estate-view.threats.mmd | 2 +- .guardlink/graph/by-asset/gate.flows.mmd | 2 +- .guardlink/graph/by-asset/gate.threats.mmd | 2 +- .guardlink/graph/by-asset/init.flows.mmd | 2 +- .guardlink/graph/by-asset/init.threats.mmd | 2 +- .../graph/by-asset/llm-client.flows.mmd | 2 +- .../graph/by-asset/llm-client.threats.mmd | 2 +- .guardlink/graph/by-asset/mcp.flows.mmd | 2 +- .guardlink/graph/by-asset/mcp.threats.mmd | 2 +- .../graph/by-asset/merge-engine.flows.mmd | 2 +- .../graph/by-asset/merge-engine.threats.mmd | 2 +- .guardlink/graph/by-asset/parser.flows.mmd | 2 +- .guardlink/graph/by-asset/parser.threats.mmd | 2 +- .../graph/by-asset/report-metadata.flows.mmd | 6 +- .../by-asset/report-metadata.threats.mmd | 21 ++ .guardlink/graph/by-asset/report.flows.mmd | 2 +- .guardlink/graph/by-asset/report.threats.mmd | 2 +- .guardlink/graph/by-asset/sarif.flows.mmd | 3 +- .guardlink/graph/by-asset/sarif.threats.mmd | 7 +- .guardlink/graph/by-asset/suggest.flows.mmd | 2 +- .guardlink/graph/by-asset/suggest.threats.mmd | 2 +- .guardlink/graph/by-asset/tui.flows.mmd | 2 +- .guardlink/graph/by-asset/tui.threats.mmd | 2 +- .../by-asset/workspace-config.threats.mmd | 2 +- .../graph/by-asset/workspace-link.flows.mmd | 2 +- .../graph/by-boundary/agent-boundary.mmd | 2 +- .../graph/by-boundary/cli-input-boundary.mmd | 2 +- .../graph/by-boundary/codegraph-boundary.mmd | 2 +- .guardlink/graph/by-boundary/fs-boundary.mmd | 2 +- .guardlink/graph/by-boundary/git-boundary.mmd | 6 +- .../graph/by-boundary/llm-api-boundary.mmd | 2 +- .guardlink/graph/by-boundary/mcp-boundary.mmd | 2 +- .../graph/by-boundary/mcp-tool-boundary.mmd | 2 +- .../graph/by-boundary/nvd-api-boundary.mmd | 2 +- .../graph/by-boundary/tui-input-boundary.mmd | 2 +- .guardlink/graph/by-feature/dashboard.mmd | 2 +- .../graph/by-feature/mcp-integration.mmd | 2 +- .guardlink/graph/dataflow.mmd | 6 +- .guardlink/graph/index.md | 10 +- .guardlink/graph/threat-graph.mmd | 10 +- .guardlink/model.json | 188 +++++++++++++----- .windsurfrules | 8 +- AGENTS.md | 8 +- CLAUDE.md | 8 +- 64 files changed, 389 insertions(+), 245 deletions(-) create mode 100644 .guardlink/graph/by-asset/report-metadata.threats.mmd diff --git a/.clinerules b/.clinerules index 25213754..02798207 100644 --- a/.clinerules +++ b/.clinerules @@ -77,7 +77,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - #agent-launcher exposed to #config-tamper [medium] (src/agents/prompts.ts:10) - #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) - #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:34) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:42) - #codegraph exposed to #config-tamper [medium] (src/codegraph/index.ts:45) - #codegraph exposed to #prompt-injection [medium] (src/codegraph/index.ts:49) - #init exposed to #data-exposure [low] (src/init/index.ts:12) @@ -111,7 +111,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 171 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 173 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -120,11 +120,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -849 annotations, 20 assets, 16 threats, 14 controls, 125 exposures, 0 confirmed, 127 mitigations, 3 actors, 1 entitlements, 191 flows, 2 features +858 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 193 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` +- `annotation_hash`: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.cursor/rules/guardlink.mdc b/.cursor/rules/guardlink.mdc index 2c8691d5..17cd2063 100644 --- a/.cursor/rules/guardlink.mdc +++ b/.cursor/rules/guardlink.mdc @@ -82,7 +82,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - #agent-launcher exposed to #config-tamper [medium] (src/agents/prompts.ts:10) - #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) - #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:34) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:42) - #codegraph exposed to #config-tamper [medium] (src/codegraph/index.ts:45) - #codegraph exposed to #prompt-injection [medium] (src/codegraph/index.ts:49) - #init exposed to #data-exposure [low] (src/init/index.ts:12) @@ -116,7 +116,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 171 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 173 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -125,11 +125,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -849 annotations, 20 assets, 16 threats, 14 controls, 125 exposures, 0 confirmed, 127 mitigations, 3 actors, 1 entitlements, 191 flows, 2 features +858 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 193 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` +- `annotation_hash`: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index f3b076b6..57b7dd4d 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -131,7 +131,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - #agent-launcher exposed to #config-tamper [medium] (src/agents/prompts.ts:10) - #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) - #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:34) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:42) - #codegraph exposed to #config-tamper [medium] (src/codegraph/index.ts:45) - #codegraph exposed to #prompt-injection [medium] (src/codegraph/index.ts:49) - #init exposed to #data-exposure [low] (src/init/index.ts:12) @@ -165,7 +165,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 171 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 173 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -849 annotations, 20 assets, 16 threats, 14 controls, 125 exposures, 0 confirmed, 127 mitigations, 3 actors, 1 entitlements, 191 flows, 2 features +858 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 193 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` +- `annotation_hash`: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f3b076b6..57b7dd4d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -131,7 +131,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - #agent-launcher exposed to #config-tamper [medium] (src/agents/prompts.ts:10) - #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) - #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:34) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:42) - #codegraph exposed to #config-tamper [medium] (src/codegraph/index.ts:45) - #codegraph exposed to #prompt-injection [medium] (src/codegraph/index.ts:49) - #init exposed to #data-exposure [low] (src/init/index.ts:12) @@ -165,7 +165,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 171 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 173 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -849 annotations, 20 assets, 16 threats, 14 controls, 125 exposures, 0 confirmed, 127 mitigations, 3 actors, 1 entitlements, 191 flows, 2 features +858 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 193 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` +- `annotation_hash`: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/.guardlink/README.md b/.guardlink/README.md index 9582e93c..804d0753 100644 --- a/.guardlink/README.md +++ b/.guardlink/README.md @@ -11,8 +11,8 @@ accepted. This file is generated. Run `guardlink sync` to refresh it; do not edit it by hand. -Current model: 849 annotations · 20 assets · 16 threats · 14 controls · 125 exposures · 191 flows -Content hash: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` — identical hash means identical model. +Current model: 858 annotations · 20 assets · 16 threats · 14 controls · 127 exposures · 193 flows +Content hash: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` — identical hash means identical model. --- diff --git a/.guardlink/graph/MANIFEST.json b/.guardlink/graph/MANIFEST.json index b38cff51..dd76e194 100644 --- a/.guardlink/graph/MANIFEST.json +++ b/.guardlink/graph/MANIFEST.json @@ -1,42 +1,42 @@ { "schema_version": 2, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "generator": "guardlink@2.1.0", "artifacts": [ { "path": ".guardlink/graph/threat-graph.mmd", - "bytes": 11985, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "bytes": 12239, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { - "text_size": 10380, - "edges": 174 + "text_size": 10632, + "edges": 179 } }, { "path": ".guardlink/graph/dataflow.mmd", - "bytes": 14898, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "bytes": 15011, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { - "text_size": 12831, - "edges": 183 + "text_size": 12940, + "edges": 185 } }, { "path": ".guardlink/graph/attack-surface.mmd", - "bytes": 6444, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "bytes": 6630, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { - "text_size": 4778, + "text_size": 4957, "edges": 0 } }, { "path": ".guardlink/graph/by-feature/dashboard.mmd", "bytes": 2301, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 813, @@ -46,7 +46,7 @@ { "path": ".guardlink/graph/by-feature/mcp-integration.mmd", "bytes": 2749, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1235, @@ -56,7 +56,7 @@ { "path": ".guardlink/graph/by-asset/agent-launcher.threats.mmd", "bytes": 2270, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1437, @@ -66,7 +66,7 @@ { "path": ".guardlink/graph/by-asset/agent-launcher.flows.mmd", "bytes": 2258, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1404, @@ -76,7 +76,7 @@ { "path": ".guardlink/graph/by-asset/blame.threats.mmd", "bytes": 2211, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1477, @@ -86,7 +86,7 @@ { "path": ".guardlink/graph/by-asset/blame.flows.mmd", "bytes": 1621, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 803, @@ -96,7 +96,7 @@ { "path": ".guardlink/graph/by-asset/cli.threats.mmd", "bytes": 2185, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1373, @@ -106,7 +106,7 @@ { "path": ".guardlink/graph/by-asset/cli.flows.mmd", "bytes": 1884, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1054, @@ -116,7 +116,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.threats.mmd", "bytes": 1853, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1125, @@ -126,7 +126,7 @@ { "path": ".guardlink/graph/by-asset/codegraph.flows.mmd", "bytes": 2336, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1490, @@ -136,7 +136,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.threats.mmd", "bytes": 2326, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1586, @@ -146,7 +146,7 @@ { "path": ".guardlink/graph/by-asset/dashboard.flows.mmd", "bytes": 2011, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1167, @@ -156,7 +156,7 @@ { "path": ".guardlink/graph/by-asset/diff.threats.mmd", "bytes": 1764, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1044, @@ -166,7 +166,7 @@ { "path": ".guardlink/graph/by-asset/diff.flows.mmd", "bytes": 1559, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 751, @@ -176,7 +176,7 @@ { "path": ".guardlink/graph/by-asset/estate-view.threats.mmd", "bytes": 1555, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 827, @@ -186,7 +186,7 @@ { "path": ".guardlink/graph/by-asset/estate-view.flows.mmd", "bytes": 1869, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1029, @@ -196,7 +196,7 @@ { "path": ".guardlink/graph/by-asset/gate.threats.mmd", "bytes": 1274, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 566, @@ -206,7 +206,7 @@ { "path": ".guardlink/graph/by-asset/gate.flows.mmd", "bytes": 1776, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 960, @@ -216,7 +216,7 @@ { "path": ".guardlink/graph/by-asset/init.threats.mmd", "bytes": 1683, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 967, @@ -226,7 +226,7 @@ { "path": ".guardlink/graph/by-asset/init.flows.mmd", "bytes": 2103, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1263, @@ -236,7 +236,7 @@ { "path": ".guardlink/graph/by-asset/llm-client.threats.mmd", "bytes": 1876, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1056, @@ -246,7 +246,7 @@ { "path": ".guardlink/graph/by-asset/llm-client.flows.mmd", "bytes": 2191, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1342, @@ -256,7 +256,7 @@ { "path": ".guardlink/graph/by-asset/mcp.threats.mmd", "bytes": 1807, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1003, @@ -266,7 +266,7 @@ { "path": ".guardlink/graph/by-asset/mcp.flows.mmd", "bytes": 1883, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1057, @@ -276,7 +276,7 @@ { "path": ".guardlink/graph/by-asset/merge-engine.threats.mmd", "bytes": 1485, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 755, @@ -286,7 +286,7 @@ { "path": ".guardlink/graph/by-asset/merge-engine.flows.mmd", "bytes": 2157, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1299, @@ -296,7 +296,7 @@ { "path": ".guardlink/graph/by-asset/parser.threats.mmd", "bytes": 1716, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 910, @@ -306,7 +306,7 @@ { "path": ".guardlink/graph/by-asset/parser.flows.mmd", "bytes": 2142, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1302, @@ -316,7 +316,7 @@ { "path": ".guardlink/graph/by-asset/report.threats.mmd", "bytes": 1222, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 510, @@ -326,47 +326,57 @@ { "path": ".guardlink/graph/by-asset/report.flows.mmd", "bytes": 1225, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 431, "edges": 4 } }, + { + "path": ".guardlink/graph/by-asset/report-metadata.threats.mmd", + "bytes": 1323, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", + "renderable": true, + "render": { + "text_size": 593, + "edges": 3 + } + }, { "path": ".guardlink/graph/by-asset/report-metadata.flows.mmd", - "bytes": 1852, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "bytes": 1943, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { - "text_size": 1018, - "edges": 15 + "text_size": 1105, + "edges": 16 } }, { "path": ".guardlink/graph/by-asset/sarif.threats.mmd", - "bytes": 1134, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "bytes": 1350, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { - "text_size": 428, - "edges": 1 + "text_size": 638, + "edges": 4 } }, { "path": ".guardlink/graph/by-asset/sarif.flows.mmd", - "bytes": 1797, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "bytes": 1849, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { - "text_size": 983, - "edges": 15 + "text_size": 1033, + "edges": 16 } }, { "path": ".guardlink/graph/by-asset/suggest.threats.mmd", "bytes": 1582, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 860, @@ -376,7 +386,7 @@ { "path": ".guardlink/graph/by-asset/suggest.flows.mmd", "bytes": 2143, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1299, @@ -386,7 +396,7 @@ { "path": ".guardlink/graph/by-asset/tui.threats.mmd", "bytes": 2073, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1349, @@ -396,7 +406,7 @@ { "path": ".guardlink/graph/by-asset/tui.flows.mmd", "bytes": 2052, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1218, @@ -406,7 +416,7 @@ { "path": ".guardlink/graph/by-asset/workspace-config.threats.mmd", "bytes": 1278, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 546, @@ -416,7 +426,7 @@ { "path": ".guardlink/graph/by-asset/workspace-link.flows.mmd", "bytes": 1926, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1078, @@ -426,7 +436,7 @@ { "path": ".guardlink/graph/by-boundary/agent-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1404, @@ -436,7 +446,7 @@ { "path": ".guardlink/graph/by-boundary/llm-api-boundary.mmd", "bytes": 2169, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1342, @@ -446,7 +456,7 @@ { "path": ".guardlink/graph/by-boundary/nvd-api-boundary.mmd", "bytes": 2156, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1342, @@ -456,7 +466,7 @@ { "path": ".guardlink/graph/by-boundary/cli-input-boundary.mmd", "bytes": 2195, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1366, @@ -466,7 +476,7 @@ { "path": ".guardlink/graph/by-boundary/codegraph-boundary.mmd", "bytes": 2370, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1490, @@ -475,18 +485,18 @@ }, { "path": ".guardlink/graph/by-boundary/git-boundary.mmd", - "bytes": 1952, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "bytes": 2046, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { - "text_size": 1140, - "edges": 15 + "text_size": 1230, + "edges": 16 } }, { "path": ".guardlink/graph/by-boundary/mcp-boundary.mmd", "bytes": 2227, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1413, @@ -496,7 +506,7 @@ { "path": ".guardlink/graph/by-boundary/mcp-tool-boundary.mmd", "bytes": 2241, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1413, @@ -506,7 +516,7 @@ { "path": ".guardlink/graph/by-boundary/fs-boundary.mmd", "bytes": 2260, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1421, @@ -516,7 +526,7 @@ { "path": ".guardlink/graph/by-boundary/tui-input-boundary.mmd", "bytes": 2028, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "renderable": true, "render": { "text_size": 1212, @@ -525,8 +535,8 @@ }, { "path": ".guardlink/model.json", - "bytes": 270046, - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f" + "bytes": 273129, + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6" } ] } diff --git a/.guardlink/graph/attack-surface.mmd b/.guardlink/graph/attack-surface.mmd index 179c6885..39cf62e6 100644 --- a/.guardlink/graph/attack-surface.mmd +++ b/.guardlink/graph/attack-surface.mmd @@ -1,6 +1,6 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: attack-surface.mmd -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -9,7 +9,7 @@ %% When it was generated and at which commit: ask git. Those move for reasons %% unrelated to the diagram, so they are reported at emission time, not stored. %% --- -%% DRAWS, BUT IS TOO BIG TO READ. attack-surface.mmd is 72 nodes / 0 edges, past the 12 / 16 legibility budget (72 nodes against 12). +%% DRAWS, BUT IS TOO BIG TO READ. attack-surface.mmd is 74 nodes / 0 edges, past the 12 / 16 legibility budget (74 nodes against 12). %% 12 nodes and 16 edges is what fits a dashboard diagram panel at its %% label size; past it a layered layout grows taller than the panel and %% fitting it shrinks the labels below reading size. Mermaid will draw this @@ -88,58 +88,63 @@ graph TB end subgraph A_sarif["GuardLink.SARIF · ⚠ 1 open"] direction TB - E44["⚠️ Sensitive_Data_Exposure"]:::sev_low + E44["✅ Denial_of_Service"]:::sev_low + E45["⚠️ Sensitive_Data_Exposure"]:::sev_low end subgraph A_suggest["GuardLink.Suggest · ⚠ 1 open"] direction TB - E45["✅ Path_Traversal"]:::sev_high - E46["✅ ReDoS"]:::sev_med - E47["⚠️ Denial_of_Service"]:::sev_low + E46["✅ Path_Traversal"]:::sev_high + E47["✅ ReDoS"]:::sev_med + E48["⚠️ Denial_of_Service"]:::sev_low end subgraph A_blame["GuardLink.Blame · ✅ 100% covered"] direction TB - E48["✅ Sensitive_Data_Exposure"]:::sev_med - E49["✅ Path_Traversal ×3"]:::sev_low - E50["✅ Denial_of_Service ×3"]:::sev_low - E51["✅ ReDoS ×2"]:::sev_low - E52["✅ Command_Injection"]:::sev_low + E49["✅ Sensitive_Data_Exposure"]:::sev_med + E50["✅ Path_Traversal ×3"]:::sev_low + E51["✅ Denial_of_Service ×3"]:::sev_low + E52["✅ ReDoS ×2"]:::sev_low + E53["✅ Command_Injection"]:::sev_low end subgraph A_cli["GuardLink.CLI · ✅ 100% covered"] direction TB - E53["✅ Command_Injection ×2"]:::sev_crit - E54["✅ Path_Traversal"]:::sev_high - E55["✅ Arbitrary_File_Write ×11"]:::sev_high - E56["✅ API_Key_Exposure"]:::sev_high - E57["✅ Insecure_Deserialization ×2"]:::sev_med - E58["✅ ReDoS"]:::sev_low + E54["✅ Command_Injection ×2"]:::sev_crit + E55["✅ Path_Traversal"]:::sev_high + E56["✅ Arbitrary_File_Write ×11"]:::sev_high + E57["✅ API_Key_Exposure"]:::sev_high + E58["✅ Insecure_Deserialization ×2"]:::sev_med + E59["✅ ReDoS"]:::sev_low end subgraph A_dashboard["GuardLink.Dashboard · ✅ 100% covered"] direction TB - E59["✅ Cross_Site_Scripting ×3"]:::sev_high - E60["✅ Arbitrary_File_Write"]:::sev_med - E61["✅ Path_Traversal ×3"]:::sev_med - E62["✅ Sensitive_Data_Exposure"]:::sev_low - E63["✅ Denial_of_Service ×2"]:::sev_low - E64["✅ Command_Injection"]:::sev_low + E60["✅ Cross_Site_Scripting ×3"]:::sev_high + E61["✅ Arbitrary_File_Write"]:::sev_med + E62["✅ Path_Traversal ×3"]:::sev_med + E63["✅ Sensitive_Data_Exposure"]:::sev_low + E64["✅ Denial_of_Service ×2"]:::sev_low + E65["✅ Command_Injection"]:::sev_low end subgraph A_diff["GuardLink.Diff · ✅ 100% covered"] direction TB - E65["✅ Command_Injection ×3"]:::sev_high - E66["✅ Arbitrary_File_Write"]:::sev_med - E67["✅ Path_Traversal"]:::sev_med + E66["✅ Command_Injection ×3"]:::sev_high + E67["✅ Arbitrary_File_Write"]:::sev_med + E68["✅ Path_Traversal"]:::sev_med end subgraph A_gate["GuardLink.Gate · ✅ 100% covered"] direction TB - E68["✅ Arbitrary_File_Write"]:::sev_med + E69["✅ Arbitrary_File_Write"]:::sev_med end subgraph A_estate_view["Workspace.Estate · ✅ 100% covered"] direction TB - E69["✅ Vacuous_Pass"]:::sev_high - E70["✅ Insecure_Deserialization"]:::sev_med + E70["✅ Vacuous_Pass"]:::sev_high + E71["✅ Insecure_Deserialization"]:::sev_med end subgraph A_merge_engine["Workspace.Merge · ✅ 100% covered"] direction TB - E71["✅ Vacuous_Pass"]:::sev_high + E72["✅ Vacuous_Pass"]:::sev_high + end + subgraph A_report_metadata["Workspace.Metadata · ✅ 100% covered"] + direction TB + E73["✅ Sensitive_Data_Exposure"]:::sev_low end classDef sev_crit fill:#3a1010,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.4px classDef sev_high fill:#402019,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.2px diff --git a/.guardlink/graph/by-asset/agent-launcher.flows.mmd b/.guardlink/graph/by-asset/agent-launcher.flows.mmd index 68d659a7..63494ecc 100644 --- a/.guardlink/graph/by-asset/agent-launcher.flows.mmd +++ b/.guardlink/graph/by-asset/agent-launcher.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/agent-launcher.flows.mmd %% scope: PARTIAL — #agent-launcher and its flow neighbourhood, grown until the drawing stopped fitting — 20 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/agent-launcher.threats.mmd b/.guardlink/graph/by-asset/agent-launcher.threats.mmd index bd341d59..027dac05 100644 --- a/.guardlink/graph/by-asset/agent-launcher.threats.mmd +++ b/.guardlink/graph/by-asset/agent-launcher.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/agent-launcher.threats.mmd %% scope: PARTIAL — #agent-launcher only — its own threats and controls, narrowed to high and critical (8 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/blame.flows.mmd b/.guardlink/graph/by-asset/blame.flows.mmd index b6a51e70..93d280cc 100644 --- a/.guardlink/graph/by-asset/blame.flows.mmd +++ b/.guardlink/graph/by-asset/blame.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/blame.flows.mmd %% scope: PARTIAL — #blame and its flow neighbourhood, grown until the drawing stopped fitting — 26 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/blame.threats.mmd b/.guardlink/graph/by-asset/blame.threats.mmd index 8da5eb28..71fb8062 100644 --- a/.guardlink/graph/by-asset/blame.threats.mmd +++ b/.guardlink/graph/by-asset/blame.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/blame.threats.mmd %% scope: PARTIAL — #blame only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/cli.flows.mmd b/.guardlink/graph/by-asset/cli.flows.mmd index 1ee083ee..2d234433 100644 --- a/.guardlink/graph/by-asset/cli.flows.mmd +++ b/.guardlink/graph/by-asset/cli.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/cli.flows.mmd %% scope: PARTIAL — #cli and its flow neighbourhood, grown until the drawing stopped fitting — 25 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/cli.threats.mmd b/.guardlink/graph/by-asset/cli.threats.mmd index c09cfcae..617d4523 100644 --- a/.guardlink/graph/by-asset/cli.threats.mmd +++ b/.guardlink/graph/by-asset/cli.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/cli.threats.mmd %% scope: PARTIAL — #cli only — its own threats and controls, narrowed to high and critical (13 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/codegraph.flows.mmd b/.guardlink/graph/by-asset/codegraph.flows.mmd index 3f550266..2eed83f2 100644 --- a/.guardlink/graph/by-asset/codegraph.flows.mmd +++ b/.guardlink/graph/by-asset/codegraph.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/codegraph.flows.mmd %% scope: PARTIAL — #codegraph and its flow neighbourhood, grown until the drawing stopped fitting — 38 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/codegraph.threats.mmd b/.guardlink/graph/by-asset/codegraph.threats.mmd index a2d41624..105e1c9e 100644 --- a/.guardlink/graph/by-asset/codegraph.threats.mmd +++ b/.guardlink/graph/by-asset/codegraph.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/codegraph.threats.mmd %% scope: PARTIAL — #codegraph only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/dashboard.flows.mmd b/.guardlink/graph/by-asset/dashboard.flows.mmd index 61a8643a..82984205 100644 --- a/.guardlink/graph/by-asset/dashboard.flows.mmd +++ b/.guardlink/graph/by-asset/dashboard.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/dashboard.flows.mmd %% scope: PARTIAL — #dashboard and its flow neighbourhood, grown until the drawing stopped fitting — 11 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/dashboard.threats.mmd b/.guardlink/graph/by-asset/dashboard.threats.mmd index d53f3726..dd6de0dc 100644 --- a/.guardlink/graph/by-asset/dashboard.threats.mmd +++ b/.guardlink/graph/by-asset/dashboard.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/dashboard.threats.mmd %% scope: PARTIAL — #dashboard only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/diff.flows.mmd b/.guardlink/graph/by-asset/diff.flows.mmd index 8bf3973b..383264f2 100644 --- a/.guardlink/graph/by-asset/diff.flows.mmd +++ b/.guardlink/graph/by-asset/diff.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/diff.flows.mmd %% scope: PARTIAL — #diff and its flow neighbourhood, grown until the drawing stopped fitting — 12 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/diff.threats.mmd b/.guardlink/graph/by-asset/diff.threats.mmd index 1b51e02b..2ef8bbb7 100644 --- a/.guardlink/graph/by-asset/diff.threats.mmd +++ b/.guardlink/graph/by-asset/diff.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/diff.threats.mmd %% scope: PARTIAL — #diff only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/estate-view.flows.mmd b/.guardlink/graph/by-asset/estate-view.flows.mmd index 02b98185..c53c8c55 100644 --- a/.guardlink/graph/by-asset/estate-view.flows.mmd +++ b/.guardlink/graph/by-asset/estate-view.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/estate-view.flows.mmd -%% scope: PARTIAL — #estate-view and its flow neighbourhood, grown until the drawing stopped fitting — 22 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% scope: PARTIAL — #estate-view and its flow neighbourhood, grown until the drawing stopped fitting — 23 neighbour(s) one hop out are not shown +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/estate-view.threats.mmd b/.guardlink/graph/by-asset/estate-view.threats.mmd index e05f7edd..d6b50fc3 100644 --- a/.guardlink/graph/by-asset/estate-view.threats.mmd +++ b/.guardlink/graph/by-asset/estate-view.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/estate-view.threats.mmd %% scope: PARTIAL — #estate-view only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/gate.flows.mmd b/.guardlink/graph/by-asset/gate.flows.mmd index 2e66e1dd..13479597 100644 --- a/.guardlink/graph/by-asset/gate.flows.mmd +++ b/.guardlink/graph/by-asset/gate.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/gate.flows.mmd %% scope: PARTIAL — #gate and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/gate.threats.mmd b/.guardlink/graph/by-asset/gate.threats.mmd index bfa26d57..10d00300 100644 --- a/.guardlink/graph/by-asset/gate.threats.mmd +++ b/.guardlink/graph/by-asset/gate.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/gate.threats.mmd %% scope: PARTIAL — #gate only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/init.flows.mmd b/.guardlink/graph/by-asset/init.flows.mmd index 2fbcdfc7..67be66bc 100644 --- a/.guardlink/graph/by-asset/init.flows.mmd +++ b/.guardlink/graph/by-asset/init.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/init.flows.mmd %% scope: PARTIAL — #init and its flow neighbourhood, grown until the drawing stopped fitting — 28 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/init.threats.mmd b/.guardlink/graph/by-asset/init.threats.mmd index 3348f327..9aa45809 100644 --- a/.guardlink/graph/by-asset/init.threats.mmd +++ b/.guardlink/graph/by-asset/init.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/init.threats.mmd %% scope: PARTIAL — #init only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/llm-client.flows.mmd b/.guardlink/graph/by-asset/llm-client.flows.mmd index 67919e9a..cab171ec 100644 --- a/.guardlink/graph/by-asset/llm-client.flows.mmd +++ b/.guardlink/graph/by-asset/llm-client.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/llm-client.flows.mmd %% scope: PARTIAL — #llm-client and its flow neighbourhood, grown until the drawing stopped fitting — 13 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/llm-client.threats.mmd b/.guardlink/graph/by-asset/llm-client.threats.mmd index f2b04465..57ed6e45 100644 --- a/.guardlink/graph/by-asset/llm-client.threats.mmd +++ b/.guardlink/graph/by-asset/llm-client.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/llm-client.threats.mmd %% scope: PARTIAL — #llm-client only — its own threats and controls, narrowed to high and critical (13 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/mcp.flows.mmd b/.guardlink/graph/by-asset/mcp.flows.mmd index d712cc62..1688f2f3 100644 --- a/.guardlink/graph/by-asset/mcp.flows.mmd +++ b/.guardlink/graph/by-asset/mcp.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/mcp.flows.mmd %% scope: PARTIAL — #mcp and its flow neighbourhood, grown until the drawing stopped fitting — 20 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/mcp.threats.mmd b/.guardlink/graph/by-asset/mcp.threats.mmd index f775a8b7..1c16e371 100644 --- a/.guardlink/graph/by-asset/mcp.threats.mmd +++ b/.guardlink/graph/by-asset/mcp.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/mcp.threats.mmd %% scope: PARTIAL — #mcp only — its own threats and controls, narrowed to high and critical (12 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/merge-engine.flows.mmd b/.guardlink/graph/by-asset/merge-engine.flows.mmd index 60e03673..9d9d839e 100644 --- a/.guardlink/graph/by-asset/merge-engine.flows.mmd +++ b/.guardlink/graph/by-asset/merge-engine.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/merge-engine.flows.mmd %% scope: PARTIAL — #merge-engine and its flow neighbourhood, grown until the drawing stopped fitting — 34 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/merge-engine.threats.mmd b/.guardlink/graph/by-asset/merge-engine.threats.mmd index ac6e3d03..3708f6f0 100644 --- a/.guardlink/graph/by-asset/merge-engine.threats.mmd +++ b/.guardlink/graph/by-asset/merge-engine.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/merge-engine.threats.mmd %% scope: PARTIAL — #merge-engine only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/parser.flows.mmd b/.guardlink/graph/by-asset/parser.flows.mmd index 9af3b1d0..79edc5c7 100644 --- a/.guardlink/graph/by-asset/parser.flows.mmd +++ b/.guardlink/graph/by-asset/parser.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/parser.flows.mmd %% scope: PARTIAL — #parser and its flow neighbourhood, grown until the drawing stopped fitting — 12 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/parser.threats.mmd b/.guardlink/graph/by-asset/parser.threats.mmd index 1440e031..71815512 100644 --- a/.guardlink/graph/by-asset/parser.threats.mmd +++ b/.guardlink/graph/by-asset/parser.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/parser.threats.mmd %% scope: PARTIAL — #parser only — its own threats and controls, narrowed to high and critical (30 lower-severity claim(s) omitted to keep it readable) -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/report-metadata.flows.mmd b/.guardlink/graph/by-asset/report-metadata.flows.mmd index 26dd01dd..81464010 100644 --- a/.guardlink/graph/by-asset/report-metadata.flows.mmd +++ b/.guardlink/graph/by-asset/report-metadata.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/report-metadata.flows.mmd %% scope: PARTIAL — #report-metadata and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -15,6 +15,7 @@ graph LR parser["🧩 GuardLink.Parser"] gitrepo["🧩 GitRepo"] report_metadata["🧩 Workspace.Metadata"] + gitconfig["🧩 GitConfig"] threatmodel -- "📡 findAcceptanceDefects" --> parser threatmodel -- "📡 findExpiringAcceptances" --> parser threatmodel -- "📡 computeAnnotationHash" --> parser @@ -29,4 +30,5 @@ graph LR threatmodel -- "📡 classifyClaims" --> parser threatmodel -- "📡 findUnmitigatedPaths" --> parser gitrepo -- "📡 execSync" --> report_metadata - report_metadata -- "📡 populateMetadata" --> threatmodel \ No newline at end of file + report_metadata -- "📡 populateMetadata" --> threatmodel + gitconfig -- "📡 readVersionControl" --> report_metadata \ No newline at end of file diff --git a/.guardlink/graph/by-asset/report-metadata.threats.mmd b/.guardlink/graph/by-asset/report-metadata.threats.mmd new file mode 100644 index 00000000..3b5050ba --- /dev/null +++ b/.guardlink/graph/by-asset/report-metadata.threats.mmd @@ -0,0 +1,21 @@ +%% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . +%% artifact: by-asset/report-metadata.threats.mmd +%% scope: PARTIAL — #report-metadata only — its own threats and controls +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 +%% generator: guardlink@2.1.0 +%% --- +%% This diagram is derived from the annotations in this repository. If +%% annotation_hash above differs from what `guardlink status .` reports, the +%% diagram is STALE — regenerate rather than trusting it. +%% When it was generated and at which commit: ask git. Those move for reasons +%% unrelated to the diagram, so they are reported at emission time, not stored. +%%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 150, "curve": "monotoneX", "htmlLabels": false, "padding": 24}}}%% +graph LR + report_metadata["🔷 Workspace.Metadata"] + data_exposure["🟡 Sensitive_Data_Exposure (cwe:CWE-200)"]:::threat + key_redaction["🛡️ Key_Redaction"]:::control + report_metadata -. exposes .-> data_exposure + key_redaction -- mitigates --> data_exposure + key_redaction -. protects .-> report_metadata + classDef threat fill:#3a1010,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.3px + classDef control fill:#102a24,stroke:#33d49d,color:#f0f0f0,stroke-width:1.3px \ No newline at end of file diff --git a/.guardlink/graph/by-asset/report.flows.mmd b/.guardlink/graph/by-asset/report.flows.mmd index 36983b4f..ee4c3870 100644 --- a/.guardlink/graph/by-asset/report.flows.mmd +++ b/.guardlink/graph/by-asset/report.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/report.flows.mmd %% scope: PARTIAL — #report and its flow neighbourhood, grown until the drawing stopped fitting — 11 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/report.threats.mmd b/.guardlink/graph/by-asset/report.threats.mmd index 26132fa8..622d6b9d 100644 --- a/.guardlink/graph/by-asset/report.threats.mmd +++ b/.guardlink/graph/by-asset/report.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/report.threats.mmd %% scope: PARTIAL — #report only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/sarif.flows.mmd b/.guardlink/graph/by-asset/sarif.flows.mmd index f4ef89d3..5d0f6bea 100644 --- a/.guardlink/graph/by-asset/sarif.flows.mmd +++ b/.guardlink/graph/by-asset/sarif.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/sarif.flows.mmd %% scope: PARTIAL — #sarif and its flow neighbourhood, grown until the drawing stopped fitting — 23 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -15,6 +15,7 @@ graph LR sarif["🧩 GuardLink.SARIF"] sariflog["🧩 SarifLog"] parser["🧩 GuardLink.Parser"] + threatmodel -- "📡 buildSarifContext" --> sarif threatmodel -- "📡 generateSarif" --> sarif sarif -- "📡 return" --> sariflog threatmodel -- "📡 findAcceptanceDefects" --> parser diff --git a/.guardlink/graph/by-asset/sarif.threats.mmd b/.guardlink/graph/by-asset/sarif.threats.mmd index e8d38f27..525b017e 100644 --- a/.guardlink/graph/by-asset/sarif.threats.mmd +++ b/.guardlink/graph/by-asset/sarif.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/sarif.threats.mmd %% scope: PARTIAL — #sarif only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -12,7 +12,12 @@ %%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 150, "curve": "monotoneX", "htmlLabels": false, "padding": 24}}}%% graph LR sarif["🔷 GuardLink.SARIF"] + dos["🟡 Denial_of_Service (cwe:CWE-400)"]:::threat data_exposure["🟡 Sensitive_Data_Exposure (cwe:CWE-200)"]:::threat + resource_limits["🛡️ Resource_Limits"]:::control + sarif -. exposes .-> dos sarif -. exposes .-> data_exposure + resource_limits -- mitigates --> dos + resource_limits -. protects .-> sarif classDef threat fill:#3a1010,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.3px classDef control fill:#102a24,stroke:#33d49d,color:#f0f0f0,stroke-width:1.3px \ No newline at end of file diff --git a/.guardlink/graph/by-asset/suggest.flows.mmd b/.guardlink/graph/by-asset/suggest.flows.mmd index 8e540632..a6f90a19 100644 --- a/.guardlink/graph/by-asset/suggest.flows.mmd +++ b/.guardlink/graph/by-asset/suggest.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/suggest.flows.mmd %% scope: PARTIAL — #suggest and its flow neighbourhood, grown until the drawing stopped fitting — 13 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/suggest.threats.mmd b/.guardlink/graph/by-asset/suggest.threats.mmd index 259e0458..7b108450 100644 --- a/.guardlink/graph/by-asset/suggest.threats.mmd +++ b/.guardlink/graph/by-asset/suggest.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/suggest.threats.mmd %% scope: PARTIAL — #suggest only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/tui.flows.mmd b/.guardlink/graph/by-asset/tui.flows.mmd index 061a1bff..69323c62 100644 --- a/.guardlink/graph/by-asset/tui.flows.mmd +++ b/.guardlink/graph/by-asset/tui.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/tui.flows.mmd %% scope: PARTIAL — #tui and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/tui.threats.mmd b/.guardlink/graph/by-asset/tui.threats.mmd index 0e931265..3c7cfeec 100644 --- a/.guardlink/graph/by-asset/tui.threats.mmd +++ b/.guardlink/graph/by-asset/tui.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/tui.threats.mmd %% scope: PARTIAL — #tui only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/workspace-config.threats.mmd b/.guardlink/graph/by-asset/workspace-config.threats.mmd index f7b694ca..87064e2e 100644 --- a/.guardlink/graph/by-asset/workspace-config.threats.mmd +++ b/.guardlink/graph/by-asset/workspace-config.threats.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/workspace-config.threats.mmd %% scope: PARTIAL — #workspace-config only — its own threats and controls -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-asset/workspace-link.flows.mmd b/.guardlink/graph/by-asset/workspace-link.flows.mmd index a638e724..2819e676 100644 --- a/.guardlink/graph/by-asset/workspace-link.flows.mmd +++ b/.guardlink/graph/by-asset/workspace-link.flows.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-asset/workspace-link.flows.mmd %% scope: PARTIAL — #workspace-link and its flow neighbourhood, grown until the drawing stopped fitting — 37 neighbour(s) one hop out are not shown -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/agent-boundary.mmd b/.guardlink/graph/by-boundary/agent-boundary.mmd index 83ae2559..879b6cd2 100644 --- a/.guardlink/graph/by-boundary/agent-boundary.mmd +++ b/.guardlink/graph/by-boundary/agent-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/agent-boundary.mmd %% scope: PARTIAL — the Trust boundary at process spawn between agent-launcher and agentprocess, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/cli-input-boundary.mmd b/.guardlink/graph/by-boundary/cli-input-boundary.mmd index 53995555..b2a197e2 100644 --- a/.guardlink/graph/by-boundary/cli-input-boundary.mmd +++ b/.guardlink/graph/by-boundary/cli-input-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/cli-input-boundary.mmd %% scope: PARTIAL — the Trust boundary at CLI argument parsing between cli and userinput, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/codegraph-boundary.mmd b/.guardlink/graph/by-boundary/codegraph-boundary.mmd index 50168c23..a604f653 100644 --- a/.guardlink/graph/by-boundary/codegraph-boundary.mmd +++ b/.guardlink/graph/by-boundary/codegraph-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/codegraph-boundary.mmd %% scope: PARTIAL — the Process boundary: an external binary's JSON is parsed defensively and never executed between codegraph and codegraphserver, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/fs-boundary.mmd b/.guardlink/graph/by-boundary/fs-boundary.mmd index 93f7164e..9f37c0f9 100644 --- a/.guardlink/graph/by-boundary/fs-boundary.mmd +++ b/.guardlink/graph/by-boundary/fs-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/fs-boundary.mmd %% scope: PARTIAL — the Trust boundary between parser and disk I/O between parser and filesystem, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/git-boundary.mmd b/.guardlink/graph/by-boundary/git-boundary.mmd index a2cf73a2..e4578471 100644 --- a/.guardlink/graph/by-boundary/git-boundary.mmd +++ b/.guardlink/graph/by-boundary/git-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/git-boundary.mmd %% scope: PARTIAL — the Trust boundary at git command execution between diff and gitrepo, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -23,6 +23,7 @@ graph LR changedfilelist["🧩 ChangedFileList"] mcp["🧩 GuardLink.MCP"] tui["👤 GuardLink.TUI"] + report_metadata["🧩 Workspace.Metadata"] diff -.-|🧱 Trust boundary at git command execution| gitrepo blame -- "📡 formatBlameText" --> cli gitrepo -- "🗄️ execFileSync" --> blame @@ -37,4 +38,5 @@ graph LR diff -- "📡 return" --> changedfilelist gitrepo -- "🗄️ readFileSync" --> mcp gitrepo -- "📡 computeBlame" --> mcp - gitrepo -- "📡 computeBlame" --> tui \ No newline at end of file + gitrepo -- "📡 computeBlame" --> tui + gitrepo -- "📡 execSync" --> report_metadata \ No newline at end of file diff --git a/.guardlink/graph/by-boundary/llm-api-boundary.mmd b/.guardlink/graph/by-boundary/llm-api-boundary.mmd index d7e57d85..c57a7937 100644 --- a/.guardlink/graph/by-boundary/llm-api-boundary.mmd +++ b/.guardlink/graph/by-boundary/llm-api-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/llm-api-boundary.mmd %% scope: PARTIAL — the Trust boundary at external API call between llm-client and llmprovider, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/mcp-boundary.mmd b/.guardlink/graph/by-boundary/mcp-boundary.mmd index 1b2c56a5..d2b1d3e7 100644 --- a/.guardlink/graph/by-boundary/mcp-boundary.mmd +++ b/.guardlink/graph/by-boundary/mcp-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/mcp-boundary.mmd %% scope: PARTIAL — the Trust boundary at MCP protocol between mcp and mcpclient, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd b/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd index 86816dd6..5d4f09b1 100644 --- a/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd +++ b/.guardlink/graph/by-boundary/mcp-tool-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/mcp-tool-boundary.mmd %% scope: PARTIAL — the Trust boundary at tool argument parsing between mcp and mcpclient, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/nvd-api-boundary.mmd b/.guardlink/graph/by-boundary/nvd-api-boundary.mmd index 2bd9a355..ef50f6c7 100644 --- a/.guardlink/graph/by-boundary/nvd-api-boundary.mmd +++ b/.guardlink/graph/by-boundary/nvd-api-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/nvd-api-boundary.mmd %% scope: PARTIAL — the Trust boundary at external API between llm-client and nvd, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-boundary/tui-input-boundary.mmd b/.guardlink/graph/by-boundary/tui-input-boundary.mmd index 6493617e..41d36e21 100644 --- a/.guardlink/graph/by-boundary/tui-input-boundary.mmd +++ b/.guardlink/graph/by-boundary/tui-input-boundary.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-boundary/tui-input-boundary.mmd %% scope: PARTIAL — the Trust boundary at interactive input between tui and userinput, and what flows across it -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-feature/dashboard.mmd b/.guardlink/graph/by-feature/dashboard.mmd index 63a7f2c3..1f6ee2da 100644 --- a/.guardlink/graph/by-feature/dashboard.mmd +++ b/.guardlink/graph/by-feature/dashboard.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-feature/dashboard.mmd %% scope: PARTIAL — @feature "Dashboard" only, not the whole model -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/by-feature/mcp-integration.mmd b/.guardlink/graph/by-feature/mcp-integration.mmd index bac737bc..724881ba 100644 --- a/.guardlink/graph/by-feature/mcp-integration.mmd +++ b/.guardlink/graph/by-feature/mcp-integration.mmd @@ -1,7 +1,7 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: by-feature/mcp-integration.mmd %% scope: PARTIAL — @feature "MCP Integration" only, not the whole model -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If diff --git a/.guardlink/graph/dataflow.mmd b/.guardlink/graph/dataflow.mmd index 4ed3da11..4f67fe28 100644 --- a/.guardlink/graph/dataflow.mmd +++ b/.guardlink/graph/dataflow.mmd @@ -1,6 +1,6 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: dataflow.mmd -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -9,7 +9,7 @@ %% When it was generated and at which commit: ask git. Those move for reasons %% unrelated to the diagram, so they are reported at emission time, not stored. %% --- -%% DRAWS, BUT IS TOO BIG TO READ. dataflow.mmd is 76 nodes / 183 edges, past the 12 / 16 legibility budget (76 nodes against 12, 183 edges against 16). +%% DRAWS, BUT IS TOO BIG TO READ. dataflow.mmd is 76 nodes / 185 edges, past the 12 / 16 legibility budget (76 nodes against 12, 185 edges against 16). %% 12 nodes and 16 edges is what fits a dashboard diagram panel at its %% label size; past it a layered layout grows taller than the panel and %% fitting it shrinks the labels below reading size. Mermaid will draw this @@ -160,6 +160,7 @@ graph LR llmprovider -- "📡 response" --> llm_client llmtoolcall -- "📡 createToolExecutor" --> llm_client llm_client -- "📡 fetch" --> nvd + threatmodel -- "📡 buildSarifContext" --> sarif threatmodel -- "📡 generateSarif" --> sarif sarif -- "📡 return" --> sariflog threatmodel -- "📡 emitArtifacts" --> dashboard @@ -311,4 +312,5 @@ graph LR filesystem -- "📡 resolveReportPaths" --> merge_engine gitrepo -- "📡 execSync" --> report_metadata report_metadata -- "📡 populateMetadata" --> threatmodel + gitconfig -- "📡 readVersionControl" --> report_metadata tagregistry -- "📡 buildOwnerScope" --> merge_engine \ No newline at end of file diff --git a/.guardlink/graph/index.md b/.guardlink/graph/index.md index 10bf534c..91e57283 100644 --- a/.guardlink/graph/index.md +++ b/.guardlink/graph/index.md @@ -32,7 +32,7 @@ a small neighbourhood into a hairball. | [`diff.threats.mmd`](by-asset/diff.threats.mmd) | #diff only — its own threats and controls | | [`diff.flows.mmd`](by-asset/diff.flows.mmd) | #diff and its flow neighbourhood, grown until the drawing stopped fitting — 12 neighbour(s) one hop out are not shown | | [`estate-view.threats.mmd`](by-asset/estate-view.threats.mmd) | #estate-view only — its own threats and controls | -| [`estate-view.flows.mmd`](by-asset/estate-view.flows.mmd) | #estate-view and its flow neighbourhood, grown until the drawing stopped fitting — 22 neighbour(s) one hop out are not shown | +| [`estate-view.flows.mmd`](by-asset/estate-view.flows.mmd) | #estate-view and its flow neighbourhood, grown until the drawing stopped fitting — 23 neighbour(s) one hop out are not shown | | [`gate.threats.mmd`](by-asset/gate.threats.mmd) | #gate only — its own threats and controls | | [`gate.flows.mmd`](by-asset/gate.flows.mmd) | #gate and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown | | [`init.threats.mmd`](by-asset/init.threats.mmd) | #init only — its own threats and controls | @@ -47,6 +47,7 @@ a small neighbourhood into a hairball. | [`parser.flows.mmd`](by-asset/parser.flows.mmd) | #parser and its flow neighbourhood, grown until the drawing stopped fitting — 12 neighbour(s) one hop out are not shown | | [`report.threats.mmd`](by-asset/report.threats.mmd) | #report only — its own threats and controls | | [`report.flows.mmd`](by-asset/report.flows.mmd) | #report and its flow neighbourhood, grown until the drawing stopped fitting — 11 neighbour(s) one hop out are not shown | +| [`report-metadata.threats.mmd`](by-asset/report-metadata.threats.mmd) | #report-metadata only — its own threats and controls | | [`report-metadata.flows.mmd`](by-asset/report-metadata.flows.mmd) | #report-metadata and its flow neighbourhood, grown until the drawing stopped fitting — 24 neighbour(s) one hop out are not shown | | [`sarif.threats.mmd`](by-asset/sarif.threats.mmd) | #sarif only — its own threats and controls | | [`sarif.flows.mmd`](by-asset/sarif.flows.mmd) | #sarif and its flow neighbourhood, grown until the drawing stopped fitting — 23 neighbour(s) one hop out are not shown | @@ -80,8 +81,8 @@ size so you know which you are about to open. | File | What it shows | Size | |---|---|---| -| [`threat-graph.mmd`](threat-graph.mmd) | Every component, the threats declared on it, and the controls that answer them. | 174 edges — **past the readable size** | -| [`dataflow.mmd`](dataflow.mmd) | Every `@flows` between components, with trust boundaries drawn as zones. | 183 edges — **past the readable size** | +| [`threat-graph.mmd`](threat-graph.mmd) | Every component, the threats declared on it, and the controls that answer them. | 179 edges — **past the readable size** | +| [`dataflow.mmd`](dataflow.mmd) | Every `@flows` between components, with trust boundaries drawn as zones. | 185 edges — **past the readable size** | | [`attack-surface.mmd`](attack-surface.mmd) | Exposures per component, worst first. | 0 edges | ## One feature at a time @@ -97,7 +98,6 @@ size so you know which you are about to open. ## Components with no threat diagram -- `report-metadata` — declares no `@exposes`, `@mitigates`, `@confirmed` or `@accepts`, or carries too many to draw legibly even narrowed to high and critical. Its claims, if any, are in `../model.json`. - `workspace-link` — declares no `@exposes`, `@mitigates`, `@confirmed` or `@accepts`, or carries too many to draw legibly even narrowed to high and critical. Its claims, if any, are in `../model.json`. ## Components with no flow diagram @@ -106,6 +106,6 @@ size so you know which you are about to open. --- -Generated by guardlink@2.1.0 from annotation hash `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f`. If that +Generated by guardlink@2.1.0 from annotation hash `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6`. If that differs from what `guardlink status .` reports, everything here is stale — regenerate rather than trusting it. diff --git a/.guardlink/graph/threat-graph.mmd b/.guardlink/graph/threat-graph.mmd index efcd91e1..f1306864 100644 --- a/.guardlink/graph/threat-graph.mmd +++ b/.guardlink/graph/threat-graph.mmd @@ -1,6 +1,6 @@ %% GENERATED FILE — do not edit. Regenerate with: guardlink artifacts . %% artifact: threat-graph.mmd -%% annotation_hash: sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f +%% annotation_hash: sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6 %% generator: guardlink@2.1.0 %% --- %% This diagram is derived from the annotations in this repository. If @@ -9,7 +9,7 @@ %% When it was generated and at which commit: ask git. Those move for reasons %% unrelated to the diagram, so they are reported at emission time, not stored. %% --- -%% DRAWS, BUT IS TOO BIG TO READ. threat-graph.mmd is 47 nodes / 174 edges, past the 12 / 16 legibility budget (47 nodes against 12, 174 edges against 16). +%% DRAWS, BUT IS TOO BIG TO READ. threat-graph.mmd is 48 nodes / 179 edges, past the 12 / 16 legibility budget (48 nodes against 12, 179 edges against 16). %% 12 nodes and 16 edges is what fits a dashboard diagram panel at its %% label size; past it a layered layout grows taller than the panel and %% fitting it shrinks the labels below reading size. Mermaid will draw this @@ -53,6 +53,7 @@ graph LR suggest["🔷 GuardLink.Suggest"] estate_view["🔷 Workspace.Estate"] merge_engine["🔷 Workspace.Merge"] + report_metadata["🔷 Workspace.Metadata"] report["🔷 GuardLink.Report [PII]"] workspace_config["🔷 Workspace.Config"] api_key_exposure["🟠 API_Key_Exposure (cwe:CWE-798)"]:::threat @@ -99,6 +100,7 @@ graph LR llm_client -. exposes .-> api_key_exposure llm_client -. exposes .-> prompt_injection llm_client -. exposes .-> dos + sarif -. exposes .-> dos sarif -. exposes .-> data_exposure dashboard -. exposes .-> arbitrary_write blame -. exposes .-> path_traversal @@ -156,6 +158,7 @@ graph LR estate_view -. exposes .-> vacuous_pass estate_view -. exposes .-> insecure_deser merge_engine -. exposes .-> vacuous_pass + report_metadata -. exposes .-> data_exposure key_redaction -- mitigates --> api_key_exposure key_redaction -. protects .-> agent_launcher path_validation -- mitigates --> path_traversal @@ -176,6 +179,7 @@ graph LR glob_filtering -- mitigates --> path_traversal glob_filtering -. protects .-> llm_client resource_limits -. protects .-> llm_client + resource_limits -. protects .-> sarif path_validation -. protects .-> dashboard path_validation -. protects .-> blame resource_limits -. protects .-> blame @@ -235,6 +239,8 @@ graph LR config_validation -. protects .-> estate_view prefix_ownership -. protects .-> merge_engine fail_closed -. protects .-> merge_engine + key_redaction -- mitigates --> data_exposure + key_redaction -. protects .-> report_metadata yaml_validation -- mitigates --> config_tamper yaml_validation -. protects .-> workspace_config resource_limits -. validates .-> codegraph diff --git a/.guardlink/model.json b/.guardlink/model.json index 4d0e74f5..6be81e26 100644 --- a/.guardlink/model.json +++ b/.guardlink/model.json @@ -1,12 +1,12 @@ { "provenance": { - "annotation_hash": "sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f", + "annotation_hash": "sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6", "generator": "guardlink@2.1.0" }, "version": "1.2.0", "project": "guardlink", - "source_files": 160, - "annotations_parsed": 849, + "source_files": 161, + "annotations_parsed": 858, "annotated_files": [ ".guardlink/definitions.ts", "src/agents/config.ts", @@ -19,6 +19,7 @@ "src/analyze/prompts.ts", "src/analyze/tools.ts", "src/analyzer/index.ts", + "src/analyzer/sarif-context.ts", "src/analyzer/sarif.ts", "src/artifacts/emit.ts", "src/blame/attach.ts", @@ -993,6 +994,16 @@ "line": 14 } }, + { + "asset": "#sarif", + "threat": "#dos", + "control": "#resource-limits", + "description": "Chain walk is capped by depth, by chains collected and by a step budget; at most three chains are emitted per result", + "location": { + "file": "src/analyzer/sarif-context.ts", + "line": 44 + } + }, { "asset": "#dashboard", "threat": "#arbitrary-write", @@ -2093,6 +2104,16 @@ "line": 49 } }, + { + "asset": "#report-metadata", + "threat": "#data-exposure", + "control": "#key-redaction", + "description": "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all", + "location": { + "file": "src/workspace/metadata.ts", + "line": 12 + } + }, { "asset": "#workspace-config", "threat": "#config-tamper", @@ -2365,6 +2386,19 @@ "line": 13 } }, + { + "asset": "#sarif", + "threat": "#dos", + "severity": "low", + "external_refs": [ + "cwe:CWE-400" + ], + "description": "Enumerating upstream @flows chains is combinatorial in a dense flow graph", + "location": { + "file": "src/analyzer/sarif-context.ts", + "line": 43 + } + }, { "asset": "#sarif", "threat": "#data-exposure", @@ -2375,7 +2409,7 @@ "description": "Exposes threat model findings to SARIF consumers", "location": { "file": "src/analyzer/sarif.ts", - "line": 34 + "line": 42 } }, { @@ -3725,6 +3759,19 @@ "file": "src/workspace/merge.ts", "line": 48 } + }, + { + "asset": "#report-metadata", + "threat": "#data-exposure", + "severity": "low", + "external_refs": [ + "cwe:CWE-200" + ], + "description": "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning", + "location": { + "file": "src/workspace/metadata.ts", + "line": 11 + } } ], "confirmed": [], @@ -3984,6 +4031,17 @@ "line": 18 } }, + { + "source": "ThreatModel", + "target": "#sarif", + "mechanism": "buildSarifContext", + "route": null, + "description": "Flows, boundaries, assets and context annotations read from the parsed model", + "location": { + "file": "src/analyzer/sarif-context.ts", + "line": 42 + } + }, { "source": "ThreatModel", "target": "#sarif", @@ -3992,7 +4050,7 @@ "description": "Model input", "location": { "file": "src/analyzer/sarif.ts", - "line": 43 + "line": 53 } }, { @@ -4003,7 +4061,7 @@ "description": "SARIF output", "location": { "file": "src/analyzer/sarif.ts", - "line": 44 + "line": 54 } }, { @@ -4311,7 +4369,7 @@ "description": "Project root; the graph answers with ranked entry points or a status", "location": { "file": "src/cli/index.ts", - "line": 1566 + "line": 1567 } }, { @@ -4322,7 +4380,7 @@ "description": "The model before and after the agent", "location": { "file": "src/cli/index.ts", - "line": 1613 + "line": 1614 } }, { @@ -4333,7 +4391,7 @@ "description": "Which assets sit on an undefended path, for the ranking", "location": { "file": "src/cli/index.ts", - "line": 1748 + "line": 1749 } }, { @@ -4344,7 +4402,7 @@ "description": "cxg findings recorded as confirmations, joined to claims", "location": { "file": "src/cli/index.ts", - "line": 1768 + "line": 1769 } }, { @@ -4355,7 +4413,7 @@ "description": "dashboard --blame; identities land in the page, so blame.identity=hash is the setting for a shared dashboard", "location": { "file": "src/cli/index.ts", - "line": 2927 + "line": 2928 } }, { @@ -4366,7 +4424,7 @@ "description": "dashboard --since : the model at the ref, diffed against this one", "location": { "file": "src/cli/index.ts", - "line": 2930 + "line": 2931 } }, { @@ -5004,7 +5062,7 @@ "description": "Attribution read from blame, log -L and commit trailers for the connected agent", "location": { "file": "src/mcp/server.ts", - "line": 1181 + "line": 1187 } }, { @@ -5821,6 +5879,17 @@ "line": 9 } }, + { + "source": "GitConfig", + "target": "#report-metadata", + "mechanism": "readVersionControl", + "route": null, + "description": "remote.origin.url, HEAD and branch for SARIF versionControlProvenance", + "location": { + "file": "src/workspace/metadata.ts", + "line": 10 + } + }, { "source": "TagRegistry", "target": "#merge-engine", @@ -6063,7 +6132,7 @@ "description": "SARIF output intentionally reveals security findings for CI/CD integration", "location": { "file": "src/analyzer/sarif.ts", - "line": 35 + "line": 43 } }, { @@ -6361,7 +6430,7 @@ "description": "Identity strings printed to the terminal", "location": { "file": "src/cli/index.ts", - "line": 3806 + "line": 3807 } }, { @@ -6478,7 +6547,7 @@ "description": "Author identities, in the configured identity mode (blame.identity in config.json), reach the MCP client", "location": { "file": "src/mcp/server.ts", - "line": 1182 + "line": 1188 } }, { @@ -6900,53 +6969,74 @@ "line": 5 } }, + { + "description": "Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)", + "location": { + "file": "src/analyzer/sarif-context.ts", + "line": 45 + } + }, { "description": "Pure function: transforms ThreatModel to SARIF JSON; no I/O", "location": { "file": "src/analyzer/sarif.ts", - "line": 36 + "line": 44 } }, { "description": "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on", "location": { "file": "src/analyzer/sarif.ts", - "line": 37 + "line": 45 } }, { "description": "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested", "location": { "file": "src/analyzer/sarif.ts", - "line": 38 + "line": 46 } }, { "description": "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)", "location": { "file": "src/analyzer/sarif.ts", - "line": 39 + "line": 47 } }, { "description": "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route", "location": { "file": "src/analyzer/sarif.ts", - "line": 40 + "line": 48 } }, { "description": "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it", "location": { "file": "src/analyzer/sarif.ts", - "line": 41 + "line": 49 } }, { "description": "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used", "location": { "file": "src/analyzer/sarif.ts", - "line": 42 + "line": 50 + } + }, + { + "description": "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed", + "location": { + "file": "src/analyzer/sarif.ts", + "line": 51 + } + }, + { + "description": "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported", + "location": { + "file": "src/analyzer/sarif.ts", + "line": 52 } }, { @@ -7149,161 +7239,161 @@ "description": "ONE bound, applied before any renderer runs. `--intake` used to take the whole queue while --json and the table took -n, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — `-n 3 --intake` printed all 142 on temporal. A per-renderer slice is the shape that drifts; a renderer added later inherits this one", "location": { "file": "src/cli/index.ts", - "line": 1752 + "line": 1753 } }, { "description": "`key` is the claim key, the same one hypotheses-list/v1 carries and `hypothesis confirm`/`--from-scan` join on, so a consumer can address a queued claim instead of joining back to `hypothesis list` on (asset, threat, file, line). That join is positional and GAP-58 proved it unsafe: this population collides at the coarser (asset, threat, file) tier. The schema stays at v1 deliberately — the field is purely additive, nothing v1 carried is renamed or removed, and a consumer tells whether a build has it by the field's presence; bumping would strand consumers pinned to the v1 string for a change that breaks none of them", "location": { "file": "src/cli/index.ts", - "line": 1755 + "line": 1756 } }, { "description": "`total` is the queue length before the bound, carried into all three renderers because a bounded queue that does not say it is bounded reads as the whole of what there is to test — the failure this guards is an operator handing bugb a 10-item plan off a 142-claim queue believing nothing else is outstanding. It is additive exactly as `key` is, so the schema stays at v1: nothing v1 carried is renamed or removed, and a consumer holding a page tells it apart from a whole queue by the field's presence", "location": { "file": "src/cli/index.ts", - "line": 1756 + "line": 1757 } }, { "description": "`defaultVerifier` already returns `human:` (src/parser/verify.ts:172), so prefixing it here recorded `human:human:` — the form the ledger schema and this command's own --by help both say it is not. Do NOT 'repair' older entries that carry the doubled form: `by` is display and provenance metadata, never a join key — upsert and every reader match on the claim key — so pre-fix and post-fix entries coexist in one ledger and nothing stops matching. An explicit --by is passed through verbatim on this path, which is how `--by cxg:login-sqli` names its scheme", "location": { "file": "src/cli/index.ts", - "line": 1763 + "line": 1764 } }, { "description": "Withheld is a fact about a CLAIM, not about a finding. Several findings can join to one claim — a stamped one beside an unstamped one during a cxg stamp rollout, or two templates probing the same exposure — so a per-finding list printed one claim's remediation command twice and, worse, could report a claim as skipped in the very run that wrote it. Keyed by record.key on the same rule `writes` uses, last occurrence kept because that is the entry the ledger ends up holding", "location": { "file": "src/cli/index.ts", - "line": 1771 + "line": 1772 } }, { "description": "THE one calculation of where a line sits once the writes are done, used by every coordinate this command hands a user: the confirmations that landed, the ones already present, and the by-hand commands for both the withheld and the ambiguous. Each done.line is a PRE-write coordinate — descending order guarantees nothing at or above a target had been spliced when it was written — so an insertion at or at a lower number than a pre-write position pushes that position down by one. Only SUCCESSFUL writes are in `done`, so a write that threw shifts nothing, and with no --write it is empty and this is the identity. Two sites computing this independently is the shape that disagrees silently, and this command has already produced that defect twice", "location": { "file": "src/cli/index.ts", - "line": 1774 + "line": 1775 } }, { "description": "Writes are applied per file in DESCENDING line order. Every record.line was resolved from the PRE-write model and writeConfirmedLine splices at line + 1, so an ascending pass shifts each later target in that file down by one: with consecutive @exposes lines the second write lands beneath the wrong claim's @exposes and its guards all pass, writing a confirmation against a claim the probe never tested. Inserting below a line never moves a line above it, so descending order cannot shift a target it has not written yet", "location": { "file": "src/cli/index.ts", - "line": 1778 + "line": 1779 } }, { "description": "ONE CLAIM, ONE WRITE. Two findings can carry the same claim key — the ordinary shape when two cxg templates probe one exposure — and both resolve to the same record and fold into ONE ledger entry, so writing per finding attempted the same insertion twice and the second was refused as a failure. Keyed by record.key, keeping the LAST occurrence: upsert replaces the entry object on every call, so only the final one is the entry the ledger actually holds", "location": { "file": "src/cli/index.ts", - "line": 1779 + "line": 1780 } }, { "description": "An exit code is a claim about what happened, and it has to be true in BOTH directions: honest about an under-write and honest about a success. A confirmation already in the source is the state we were asked to reach — from an earlier run, or a re-import of the same report — so it is separated from a real failure by the TYPE writeConfirmedLine returns, never by matching the wording of an error. Every remaining throw is a failure and still exits non-zero", "location": { "file": "src/cli/index.ts", - "line": 1782 + "line": 1783 } }, { "description": "Reconciled against what the writes actually did. A claim whose confirmation IS in the source — inserted just now or already there — was not withheld, whatever else joined to it coarsely, so it is neither told to the operator as skipped nor counted toward the exit status. The rule it serves is the one the exit check states: the code is a claim about what happened and has to be true in BOTH directions, and reporting a claim as withheld in the run that wrote it is the half that was still false", "location": { "file": "src/cli/index.ts", - "line": 1793 + "line": 1794 } }, { "description": "An inserted @confirmed comes to rest directly above the line it displaced, so it is that line's new position less one — derived from afterWrites rather than counted a second way. Printed in reading order, not in the order the writes went in", "location": { "file": "src/cli/index.ts", - "line": 1795 + "line": 1796 } }, { "description": "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites", "location": { "file": "src/cli/index.ts", - "line": 1798 + "line": 1799 } }, { "description": "--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong", "location": { "file": "src/cli/index.ts", - "line": 1800 + "line": 1801 } }, { "description": "Reported AFTER the writes, at the line the withheld claim occupies once they have landed. Printing pre-write lines here handed the operator a command naming a line that this same run then moved: with consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator writes a confirmation against an exposure the probe never tested — GAP-58 one layer out, in a human's fingers instead of ours. Documenting the hazard one block down was not guarding against it", "location": { "file": "src/cli/index.ts", - "line": 1801 + "line": 1802 } }, { "description": "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so every command printed here is position-dependent and is correct AT THE MOMENT OF PRINTING and no longer — a later write, or an edit, moves the target again. Tracked there, not widened here", "location": { "file": "src/cli/index.ts", - "line": 1802 + "line": 1803 } }, { "description": "The candidates for an ambiguous finding are by-hand targets like the withheld ones, so they are emitted here rather than inside formatImport: that formatter runs BEFORE the writes, and a key-verified write higher up the same file moves an ambiguous candidate exactly as it moves a withheld claim. With consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator records a confirmation against an exposure the probe never tested — GAP-58 through a human's fingers, on our own instruction. Stays on stdout, where it was", "location": { "file": "src/cli/index.ts", - "line": 1814 + "line": 1815 } }, { "description": "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so these are correct AT THE MOMENT OF PRINTING and no longer. That residue is the addressing scheme and is tracked there; a command this run itself invalidated is a different problem and is fixed here", "location": { "file": "src/cli/index.ts", - "line": 1815 + "line": 1816 } }, { "description": "A scan whose findings were not all recorded exits non-zero: ambiguous, stale (the stamped claim is not in the model any more), malformed (a stamp arrived that is not a claim key) and unmatched each need a human, and a silent 0 would read as 'all imported'. A --write that withheld any confirmation is the same fact about the same run, so it exits non-zero too — PARTIALLY written included, not only the all-withheld case. The reader of this exit code is bravos, which orchestrates this loop and never sees the `! skipped` lines on stderr: give 0 two meanings and a half-written run is indistinguishable from a complete one, and the orchestrator proceeds believing the corpus carries confirmations that are not in it", "location": { "file": "src/cli/index.ts", - "line": 1820 + "line": 1821 } }, { "description": "The by-hand path reads the same typed outcome as --from-scan. A claim whose source already carries this confirmation is in the state that was asked for, so it is reported and exits 0; it used to throw into the ✗ handler and exit non-zero on a run that changed nothing and needed to change nothing", "location": { "file": "src/cli/index.ts", - "line": 1831 + "line": 1832 } }, { "description": "The accepted names and containers are INTERPOLATED from the one shared definition, not written out here. A hand-written copy drifts benignly when the definition widens (the help under-advertises) and harmfully when it narrows: the help then advertises a name the reader refuses, which is the one thing this definition exists to make impossible. The no-stamp banner in format.ts interpolates for the same reason, and both are pinned by tests that take the names out of the text and require the reader to accept them", "location": { "file": "src/cli/index.ts", - "line": 1858 + "line": 1859 } }, { "description": "ONE string stating BOTH behaviours, because --write sits on `confirm` and genuinely serves both paths. An author writing against this flag reads --help, not our runtime output: 'inserts the @confirmed line' alone over-promises on the one surface that writes into someone else's repository, and an operator who believes every joined confirmation landed stops checking the ones that did not — which is exactly the knowledge the withholding rule exists to give them. Two per-path wordings were rejected for the reason this branch has already corrected on names, containers, grammars and positions: two descriptions of one thing can disagree, and one string cannot contradict itself", "location": { "file": "src/cli/index.ts", - "line": 1861 + "line": 1862 } }, { "description": "Displays ledger state (absent, corrupt, or counts) computed by the caller; this function performs no I/O of its own", "location": { "file": "src/cli/index.ts", - "line": 3729 + "line": 3730 } }, { "description": "Prints only what the payload already holds; no I/O here", "location": { "file": "src/cli/index.ts", - "line": 3807 + "line": 3808 } }, { @@ -7793,7 +7883,7 @@ "description": "Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository", "location": { "file": "src/mcp/server.ts", - "line": 1183 + "line": 1189 } }, { @@ -8395,7 +8485,7 @@ "description": "Derived query over @flows and @mitigates; every hop it prints is an existing annotation location, so a finding here cannot cite a line that was never written", "location": { "file": "src/tui/commands.ts", - "line": 1076 + "line": 1077 } }, { @@ -8449,7 +8539,7 @@ } ], "coverage": { - "annotation_count": 849, + "annotation_count": 858, "coverage_percent": 86 }, "external_refs": [] diff --git a/.windsurfrules b/.windsurfrules index 25213754..02798207 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -77,7 +77,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - #agent-launcher exposed to #config-tamper [medium] (src/agents/prompts.ts:10) - #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) - #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:34) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:42) - #codegraph exposed to #config-tamper [medium] (src/codegraph/index.ts:45) - #codegraph exposed to #prompt-injection [medium] (src/codegraph/index.ts:49) - #init exposed to #data-exposure [low] (src/init/index.ts:12) @@ -111,7 +111,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 171 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 173 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -120,11 +120,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -849 annotations, 20 assets, 16 threats, 14 controls, 125 exposures, 0 confirmed, 127 mitigations, 3 actors, 1 entitlements, 191 flows, 2 features +858 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 193 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` +- `annotation_hash`: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/AGENTS.md b/AGENTS.md index f3b076b6..57b7dd4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - #agent-launcher exposed to #config-tamper [medium] (src/agents/prompts.ts:10) - #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) - #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:34) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:42) - #codegraph exposed to #config-tamper [medium] (src/codegraph/index.ts:45) - #codegraph exposed to #prompt-injection [medium] (src/codegraph/index.ts:49) - #init exposed to #data-exposure [low] (src/init/index.ts:12) @@ -165,7 +165,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 171 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 173 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -849 annotations, 20 assets, 16 threats, 14 controls, 125 exposures, 0 confirmed, 127 mitigations, 3 actors, 1 entitlements, 191 flows, 2 features +858 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 193 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` +- `annotation_hash`: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. diff --git a/CLAUDE.md b/CLAUDE.md index f3b076b6..57b7dd4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - #agent-launcher exposed to #config-tamper [medium] (src/agents/prompts.ts:10) - #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) - #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:34) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:42) - #codegraph exposed to #config-tamper [medium] (src/codegraph/index.ts:45) - #codegraph exposed to #prompt-injection [medium] (src/codegraph/index.ts:49) - #init exposed to #data-exposure [low] (src/init/index.ts:12) @@ -165,7 +165,7 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, - LLMConfig -> #llm-client via chatCompletion - #llm-client -> LLMProvider via fetch - LLMProvider -> #llm-client via response -- … and 171 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood +- … and 173 more — `guardlink_lookup("flows into X")` for one asset, or `guardlink_graph(from: X)` for a neighbourhood ### Features (filter with `--feature`) @@ -174,11 +174,11 @@ _Full records with descriptions and locations: `guardlink_lookup("asset ")`, ### Model Stats -849 annotations, 20 assets, 16 threats, 14 controls, 125 exposures, 0 confirmed, 127 mitigations, 3 actors, 1 entitlements, 191 flows, 2 features +858 annotations, 20 assets, 16 threats, 14 controls, 127 exposures, 0 confirmed, 129 mitigations, 3 actors, 1 entitlements, 193 flows, 2 features ### Block Freshness -- `annotation_hash`: `sha256-v3:43ad313ae90e5e1a0f300c86060f958ea649cfd57cb5606357f52641404d583f` +- `annotation_hash`: `sha256-v3:a9ec60de2a3a302843cdd616ba5681d1bfdd04fa1ca9364d60c049443e0954d6` Every MCP response carries this same hash. If it differs from the one above, this block predates the current annotations — trust the tool, and run `guardlink sync`. From d920ebcbee0d29a014d1be526230b2c19d29be54 Mon Sep 17 00:00:00 2001 From: Animesh Srivastava Date: Fri, 2 Oct 2026 00:49:45 +0530 Subject: [PATCH 4/4] docs(contributing): state the SARIF export's compatibility rule and where it is tested --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7e61862..0abfa674 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,6 +165,10 @@ Changes to the annotation grammar or ThreatModel schema require discussion in an `docs/SPEC.md` is the definition every reader conforms to, this parser included. Other tools read the same annotations, so a form this parser accepts and the spec does not describe is a form those tools will drop without a word. When you change what the parser accepts or produces for a verb that has a conformance corpus — today `@flows`, together with handler scope (SPEC §3.6) — change three things in the same pull request: the SPEC section, the matching case in `conformance/flows.json`, and the parser. `tests/conformance-flows.test.ts` fails if the corpus and the parser disagree. Adding a case does not need a new corpus `version`. Changing the shape of a case does, and `conformance/README.md` documents that shape. +## SARIF Export Changes + +Other tools key on the SARIF export's result index, `message.text`, rule ids and `partialFingerprints`, and GitHub reopens every alert whose rule id changes. So a change to `guardlink sarif` adds members after the existing ones and never adds, drops, reorders or rewords a result (SPEC §6.7). `tests/sarif-enrichment.test.ts` enforces this by stripping the added members and comparing the rest with `tests/fixtures/sarif-baseline/`. Name any new member in that test's `strip()`, and regenerate a baseline only for an intended change to `results` or `tool` (see the README in that directory). Describe the new shape in SPEC §6 and bump `SARIF_PROFILE_VERSION` in the same pull request. + ## License By contributing, you agree that your contributions will be licensed under the MIT License.