@clear-capabilities/agentic-security-scanner 0.144.0 → 0.145.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +251 -0
- package/bin/agentic-security.js +294 -3
- package/dist/113.index.js +11 -3
- package/dist/178.index.js +24 -6
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +22 -0
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +11 -3
- package/dist/637.index.js +27 -5
- package/dist/970.index.js +65 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -8
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +488 -29
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +20 -0
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/posture/CLAUDE.md +121 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +24 -0
- package/src/posture/auditor-walkthrough.js +116 -13
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/util/git-hardening.js +128 -0
|
@@ -366,6 +366,7 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
366
366
|
stableId: finding.stableId || null,
|
|
367
367
|
ruleId: finding.ruleId || finding.cwe || finding.family || null,
|
|
368
368
|
vuln: finding.vuln || finding.title || null,
|
|
369
|
+
findingProvenance: finding.findingProvenance || null,
|
|
369
370
|
});
|
|
370
371
|
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath, attemptOrdinal: entry.attemptOrdinal });
|
|
371
372
|
}
|
package/src/history-scan.js
CHANGED
|
@@ -18,12 +18,16 @@
|
|
|
18
18
|
import { spawnSync } from 'node:child_process';
|
|
19
19
|
import * as fs from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
|
+
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
21
22
|
import { runFullScan } from './engine.js';
|
|
22
23
|
|
|
23
24
|
const MAX_FILES_PER_SCAN = 5000;
|
|
24
25
|
|
|
26
|
+
// `root` is the scan target's repository, not this project's own trusted
|
|
27
|
+
// checkout — hardened per FR-PROV-024 / the second Finding Provenance PRD
|
|
28
|
+
// audit (same exposure class as provenance/git-evidence.js's `_run`).
|
|
25
29
|
function _git(root, args) {
|
|
26
|
-
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
30
|
+
const r = spawnSync('git', hardenGitArgs(args), { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: hardenGitEnv() });
|
|
27
31
|
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -83,7 +87,10 @@ function _listFilesAtRef(root, ref) {
|
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
function _readFileAtRef(root, ref, file) {
|
|
86
|
-
|
|
90
|
+
// `--no-textconv`: this blob-cat form of `show` was verified NOT
|
|
91
|
+
// reachable via a hostile textconv driver in current git — kept for
|
|
92
|
+
// defense-in-depth/uniformity, same as pr-delta.js's equivalent.
|
|
93
|
+
const r = _git(root, ['show', '--no-textconv', `${ref}:${file}`]);
|
|
87
94
|
if (!r.ok) return null;
|
|
88
95
|
return r.stdout;
|
|
89
96
|
}
|
|
@@ -95,7 +102,13 @@ async function _scanAtRef(root, ref) {
|
|
|
95
102
|
const c = _readFileAtRef(root, ref, f);
|
|
96
103
|
if (c != null) fileContents[f] = c;
|
|
97
104
|
}
|
|
98
|
-
|
|
105
|
+
// `provenance:false` — this is a HISTORICAL ref, not "the state of this repo
|
|
106
|
+
// right now". Two things must not happen here: resolving git provenance for
|
|
107
|
+
// findings that already are a point in history (pure waste), and letting
|
|
108
|
+
// updateLifecycle see this partial, historical finding set as the current
|
|
109
|
+
// one — it marks every open stableId NOT in the set as `remediated`, so one
|
|
110
|
+
// per-ref scan would mass-remediate the whole project's real open findings.
|
|
111
|
+
const scan = await runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
|
|
99
112
|
return {
|
|
100
113
|
ref,
|
|
101
114
|
fileCount: Object.keys(fileContents).length,
|
|
@@ -177,8 +190,12 @@ export async function runWhatIf(root, { overlays = [], remove = [] } = {}) {
|
|
|
177
190
|
}
|
|
178
191
|
}
|
|
179
192
|
// Baseline (without overlays) for delta computation.
|
|
180
|
-
|
|
181
|
-
|
|
193
|
+
// Both legs are hypothetical snapshots being differenced against each other,
|
|
194
|
+
// never the repo's current state — `provenance:false` for the same reason as
|
|
195
|
+
// _scanAtRef above (no provenance to resolve, and updateLifecycle must not
|
|
196
|
+
// treat either snapshot as "what is open right now").
|
|
197
|
+
const baseScan = await runFullScan({ fileContents: _baselineFor(fileContents, overlays, remove, root), scanRoot: root, provenance: false }, () => {});
|
|
198
|
+
const whatIfScan = await runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
|
|
182
199
|
const baseIds = new Set((baseScan.findings || []).map(f => f.stableId || f.id));
|
|
183
200
|
const wIds = new Set((whatIfScan.findings || []).map(f => f.stableId || f.id));
|
|
184
201
|
const introduced = (whatIfScan.findings || []).filter(f => !baseIds.has(f.stableId || f.id)).map(_compact);
|
package/src/ir/CLAUDE.md
CHANGED
|
@@ -13,7 +13,7 @@ consumed by `scanner/src/dataflow/` for taint analysis.
|
|
|
13
13
|
| Java | `parser-java.js` | `java-parser` npm package (**async only** — the deep path in `engine.js` therefore awaits `buildProjectIRAsync` when any `.java` file is present, and uses the sync builder otherwise).
|
|
14
14
|
⚠ Three defects made Java taint impossible until v0.136.3+: the sync-only call site; a CST walk looking for `blockStatement` on a `block` (java-parser nests `block → blockStatements → blockStatement`), which emptied every method CFG; and `exprFromCst` missing the `primary → primaryPrefix + primarySuffix` form that models **every** method call. Guarded by `test/java-taint-flow.test.js`. Real parameter names (previously always `params: []`, marked "deferred") plus Spring `@RequestParam`/`@PathVariable`/`@RequestBody`/`@RequestHeader` param annotations (`fn.paramAnnotations`) are extracted in one CST walk over `formalParameterList` (PRD R14(a) Task 5). Varargs parameters (`String... args`) live under a distinct `variableArityParameter` node this walk doesn't extract from — they're gracefully dropped, not corrupted or crashed on. **PRD R9 (partial): `fn.calls` is now populated** via the shared `call-sites.js#callSitesFromCfg` (the same language-agnostic helper `parser-py-cst.js` uses) — Java's CFG nodes (`call`, `assign`, `return`, `if`) already matched the documented contract. This creates real cross-file call-graph edges for Java (`callgraph.js`'s `edges`/`callersOf`/`resolveKnownCallee`, previously always empty for Java) — it does NOT change the generic tainted-call-argument fallback in `engine.js`'s `exprTaint`, which reads CFG expression args directly off `expr.args` and already worked for Java independent of `fn.calls`. However, **same-class (intra-class) method calls do not resolve, in both the bare and `this.`-qualified forms**: `parser-java.js` names functions `"App.buildCmd"` (class-qualified), but a bare call extracts `"buildCmd"` (unqualified) and `callgraph.js`'s name-based resolution cannot match them — this is the most idiomatic Java call shape (private helpers, intra-class delegation) and remains a real gap, documented here as a candidate follow-up PRD item (a per-file bare-tail fallback in `callgraph.js`, mirroring the existing `~bare~`-key collision-refusal pattern, would plausibly fix it without touching `parser-java.js`). `this.buildCmd(id)` is not merely unresolved, it's worse: `parser-java.js:76` lowers any `this.`-qualified call whose prefix isn't a plain FQN to the literal callee string `"unknown"`, so it doesn't fail closed, it fails to a fabricated name. Guarded by `test/parser-java-calls.test.js` (the test's honest caveat discloses this; the fixture shows that edges exist but unresolved) (mirrors `test/parser-rb-calls.test.js`, the identical `fn.calls` wiring, though Ruby's unqualified names make bare-call resolution work there). **PRD R8: `walkStmts` now recurses into `for`/`try`/`switch`/`do`/bare-block bodies** (previously only `if`/`while` were walked — every other braced statement kind silently dropped its body from the CFG, including try-with-resources, the single most idiomatic JDBC shape). A fix round closed two further gaps the initial review found: enhanced-for (`for (x : xs)`) now synthesizes an assign binding the loop variable to the iterated expression, so the variable itself carries taint provenance (mirroring `parser-js.js`'s `ForOfStatement` pattern); and Java 14+ arrow-form `switch` (`case 1 -> …`) is now recognized via a second CST-shape branch. Deferred, not fixed: `forInit`/`forUpdate` clauses of a basic 3-clause `for` loop are still not walked (only the loop body is); `synchronized` blocks and labeled statements are still fully dropped. Guarded by `test/parser-java-control-flow.test.js`. **Measured `bench/layer-recall` impact: unchanged, 1/25 before and after** — the fix is real and directly proven by the dedicated unit tests above, but this corpus's existing 25 Java fixtures happen not to place a sink genuinely inside a braced control-flow body (the ones with `if`/`try` syntax use it as a single-line guard clause ahead of a flat-level sink, not a nested one) — see the PRD R8 status entry for the full explanation and the same finding for C#. **Taint-recall PRD (80%) Tier 4: chained-call CST fix.** `exprFromCst`'s `primaryPrefix` handling previously used `.find(Boolean)` to grab the FIRST `methodInvocationSuffix` in a `primary` node's `primarySuffix[]` array and discarded the rest — so any 2+-level fluent chain (`DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xml)`, `new SpelExpressionParser().parseExpression(expr).getValue()`) resolved to the wrong (inner, usually untainted) call, and the real sink call at the end of the chain was invisible to the taint walker. Rewritten to walk the full `primarySuffix[]` array in source order, dot-joining bare member-access segments (`{Dot, Identifier}` suffixes) into the callee name and accumulating args from every `methodInvocationSuffix` encountered — same outermost-first accumulation convention (`args.concat(soFar)`) as the `_followChain` helper shared by the other five hand-rolled parsers (this PRD's earlier, language-general fix for the identical class of bug: a trailing call/member continuation being silently dropped rather than followed). A companion catalog entry (`java-spel-getvalue`, receiver-scoped to `^parseExpression`, `argIndex: 'all'`) was added because the chain fix shifts the terminal callee segment from `parseExpression` to `getValue` — same "terminal segment shift" pattern documented for Kotlin/Go/Java elsewhere in this PRD. Fixes `CVE-2018-1000632-java-xxe` (DocumentBuilderFactory chain) and `CVE-2018-1273-java-code-injection` (SpelExpressionParser chain). Guarded by two new cases in `test/java-taint-flow.test.js`. **UPDATE (Tier 3 command-injection audit): the `_SHELL_META` gap this note originally flagged is fixed.** `CVE-2016-10033-java-cmdi`'s real-world shape `Runtime.getRuntime().exec("ping " + host)` was blocked by a SEPARATE, language-agnostic core-engine bug (`engine.js`'s `literalSkeletonMatchesFamily`/`_SHELL_META`, requiring a shell metacharacter in the STATIC portion of a CWE-78 concat — backwards for command injection, see `dataflow/CLAUDE.md`), now removed entirely. The corpus fixture also needed a Tier 2 fixture enrichment (`@RequestParam` on the `host` parameter — the committed fixture had no cataloged source at all, a second, independent blocker this note's original git-stash bisection had isolated away from before concluding `_SHELL_META` was the (sole, at the time) remaining issue). Both fixed together; `CVE-2016-10033-java-cmdi` now scores `pre:TP post:TN`. **Taint-recall PRD (80%) Tier 5: a THIRD, independent chain-CST bug — a constructor STARTING a chain.** Found via `CVE-2019-3799-spring-ssrf-shape`'s real-world shape `new URL(url).openStream()`: the chained-call fix above (the `primarySuffix[]` walk) only handles a chain that starts from an FQN/identifier prefix (`prefix?.children?.fqnOrRefType?.[0]`) — a chain whose FIRST segment is itself `new X(args)` takes a completely different CST shape and was never checked, so both the class name and the constructor's own args were silently dropped, leaving only the trailing `.openStream()` call with no receiver. Root-caused via temporary `console.error(Object.keys(prefix?.children))` tracing (the first guess, matching the STANDALONE `new X(args)` handling elsewhere in this file at `unqualifiedClassInstanceCreationExpression` directly off `node`, was wrong — a chain-starting constructor sits one level deeper, under `prefix?.children?.newExpression?.[0]?.children?.unqualifiedClassInstanceCreationExpression?.[0]`). Fixed by extending the same `primaryPrefix` chain-seed logic to also recognize this shape (`ctorPrefix`), extracting the class name and constructor args exactly like the FQN case, so the chain fix now covers both "chain starts on an identifier" and "chain starts on a constructor". Companion catalog fix: `java-url-openstream` was previously keyed to the (now-unreachable-via-chain) bare `callee: 'URL'`; corrected to `callee: 'openStream', receiver: '^URL'` — the same "terminal segment shift" pattern this PRD documents repeatedly elsewhere (Kotlin/Go). Guarded by `test/catalog-path-ssrf-p3.test.js`. |
|
|
15
15
|
| Go | `parser-go.js` | Hand-rolled. **Taint-recall PRD (80%) Tier 4: inline anonymous closures.** Go's dominant HTTP-handler-registration idiom across every framework this file targets (net/http, gin, echo, fiber, chi) is an inline closure passed as the LAST argument to a registration call — `app.Get(path, func(c *fiber.Ctx) error { ... })`, `http.HandleFunc(path, func(w, r) { ... })`. This parser previously had ZERO support for anonymous functions at all: `FUNC_RE` requires a NAME between `func` and `(`, so an inline closure never matched it, and `_lowerExpr`'s generic call-matching regex mis-parsed `func(params) rtype { body }` as a call to something literally named "func" — `matchBalancedCall` correctly captured only `(params)` as that "call"'s own args, and the return-type token plus the ENTIRE closure body were silently discarded (no `.method(...)` continuation follows a return type, so `_followChain` found nothing to recover). This was very likely this PRD's single most consequential Go gap — it made every framework's route-handler BODY invisible to taint analysis regardless of what it did — found via this PRD's Tier 3 command-injection audit, not the parent PRD's original per-language sweep. Fixed by `_extractTrailingClosureArg` detecting a statement's LAST top-level call argument as an inline closure literal and INLINING its body directly into the enclosing function's CFG right after the registration call — a permissive, recall-preserving approximation (the closure genuinely runs later, but what matters for taint is that its statements become real CFG nodes at all), mirroring the Kotlin trailing-lambda / Ruby trailing-block precedent elsewhere in this PRD. The closure's OWN parameter (`c`, `w`, `r`) needs no synthetic taint binding, unlike Kotlin's `.forEach { x -> ... }`: a framework context object isn't itself a source — `c.Query(...)` is recognized by the EXISTING member/call-source catalog matching regardless of which function scope `c` was declared in. Chained calls before the closure arg (`app.Group("/api").Get(path, func(){...})`) are not specially handled — out of scope, matches this file's "handle the dominant shape" precedent elsewhere. Guarded by `test/parser-go.test.js`. Two catalog/engine precision bugs surfaced in the SAME audit, both fixed alongside: `go-os-exec-command`'s `argIndex: 0` checked the WRONG argument (the real dangerous shape is `exec.Command("/bin/sh", "-c", tainted)`, where arg 0 is always the literal interpreter and the tainted content sits later) — widened to `argIndex: 'all'`, gated by a new `match.requireLiteralArg` precision primitive (`catalog.js`/`engine.js`) requiring arg 0 to literally be a shell interpreter, so the safe array-execve form (`exec.Command("ping", "-c", "1", host)`) does not spuriously fire; and a "terminal segment shift" (same pattern as `kt-xpath-evaluate`/`java-spel-getvalue`/`go-r-uquery-get` elsewhere in this PRD) for `exec.Command(...).Output()`/`.Run()`/`.CombinedOutput()`/`.Start()` — chaining a Cmd-execution method directly onto `exec.Command(...)`, the dominant idiom for actually RUNNING the command, collapses the chain into one dotted string whose last segment is no longer "Command". Guarded by `test/catalog-command-injection-p4.test.js`. **Measured `bench/layer-recall` impact: real movement — command-injection (this corpus's dominant Go family) moved from 5/23 → 18/23 across this fix plus the sibling `literalSkeletonMatchesFamily`/CWE-78 engine fix (`dataflow/CLAUDE.md`).** |
|
|
16
|
-
| Ruby | `parser-rb.js` | Hand-rolled. **`DEF_RE` must not let `\s*` cross a newline** — it did, and the body slice then started after the method's first statement, silently dropping it from every method (a one-statement body became empty). Guarded by `test/parser-php-rb.test.js`. ⚠ Also emitted no `fn.calls` at all (every OTHER parser does) — `callgraph.js`'s edges/callersOf/resolveKnownCallee are built entirely from `fn.calls`, so this left dead-code demotion and any interprocedural signal that depends on real call-graph resolution (rather than engine.js's generic tainted-call-argument fallback) permanently blind to Ruby. Fixed by deriving `fn.calls` from the CFG via the shared `call-sites.js#callSitesFromCfg` (the same helper `parser-py-cst.js` uses) — Ruby's node shapes already matched its documented contract. Guarded by `test/parser-rb-calls.test.js`. **Taint-recall PRD (80%): full CFG rebuild.** `_buildCfg` previously only recursed into `if`/`unless`/`while`/`until` bodies — `for`, `case`/`when`/`else`, `begin`/`rescue`/`ensure`, and any trailing block attached to a call (`xs.each do |x| … end`, the dominant Rails/ActiveRecord idiom) were silently dropped entirely, with no CFG node at all. `case`/`when` and `begin`/`rescue`/`ensure` use a recall-preserving model (every arm/clause reachable directly from the construct's own entry point, not modeled as mutually exclusive — same tradeoff `parser-kt.js`'s `_buildWhenArms` already established). A `do` block's opener also had to sit at the START of a line to be recognized at all — `_splitStatements`' depth-tracking now scans the WHOLE line (via `_rbLineDepthDelta`) rather than gating on `_RB_OPENERS.test(line)`, so a trailing `do` (mid-line, the common case) is now correctly consolidated instead of splitting its own body into independent nonsense statements. Trailing blocks recurse unconditionally and bind every named block parameter to the call's receiver (permissive by design — Ruby has no equivalent of Kotlin's implicit-this `apply`/`run` that would need to NOT bind). A recursion-depth guard (60) was added, matching every other R8-style rebuild in this codebase. Three further, independent bugs surfaced during the rebuild's own corpus-fixture debugging, all fixed: (1) `::` (Ruby's module-scope call operator, e.g. `Nokogiri::XML(x)`) was entirely invisible to `matchBalancedCall`'s `[\w.]+` callee regex — normalized to `.` on lowering; (2) a subscript-assignment on a member chain (`response.headers[key] = value`) had no assign-target branch at all and was silently dropped — lowered as a synthetic `<receiver>.[]=(key, value)` call, same shape as `parser-py.helper.py`'s `__setitem__` synthesis; (3) `_lowerExpr` checked "does this start with a quote" BEFORE checking for top-level `+` concatenation, so `"/var/data/" + name` was swallowed whole as one opaque literal — reordered, with a new `_splitTopLevelPlus` helper (string/paren/bracket-aware, so `"a+b"` is not mis-split) fixing it the same way `parser-go.js`'s R3 fix did for the identical ordering bug. Also inherits this PRD's shared chained-call fix (`_followChain`, `matchBalancedCall`-based) and keyword-argument fix from earlier in the same PRD. **Known, deliberately deferred gap:** the brace form of a trailing block (`Nokogiri::XML(xml) { |c| … }`) has its TRIGGER call correctly recognized (unlike before, where the trailing `{ }` corrupted or blocked the match), but the block BODY itself is not recursed into — only the `do...end` form got full body recursion, since it is the dominant multi-line Rails idiom and the single corpus fixture needing brace-form support only needed the trigger call's own argument, not its body. **Also known, NOT Ruby-specific:** a sink nested inside ANOTHER call's own argument (e.g. `render plain: URI.open(tainted).read`) is not independently checkable — `engine.js`'s sink-matching operates at CFG-node granularity (the outer `render` call), not on arbitrary nested sub-expressions; the SAME limitation was found via C#/Go/Kotlin corpus work earlier in this PRD, not something this task introduced or fixed. Guarded by `test/parser-rb-control-flow.test.js`. Two genuine ReDoS regexes were caught and fixed by `bench:self-scan:check` during this task — the trailing-block detector's first version had the `(.+?)\s+do` shape (an unbounded lazy prefix, NOT the "optional group between `\s*`" class every other ReDoS fix in this codebase has been; splitting into alternatives alone did not fix it — the leading capturing group itself had to go, replaced with a direct anchored search for `\bdo\b`), and the SAME detector's heuristic still flagged the replacement's residual "optional group between `\s*`" shape even though it measured linear on its own — split into two alternatives to satisfy the detector too. Measured `bench/layer-recall` impact on dataflow-shaped-subset taint recall: real, substantial movement
|
|
16
|
+
| Ruby | `parser-rb.js` | Hand-rolled. **`DEF_RE` must not let `\s*` cross a newline** — it did, and the body slice then started after the method's first statement, silently dropping it from every method (a one-statement body became empty). Guarded by `test/parser-php-rb.test.js`. ⚠ Also emitted no `fn.calls` at all (every OTHER parser does) — `callgraph.js`'s edges/callersOf/resolveKnownCallee are built entirely from `fn.calls`, so this left dead-code demotion and any interprocedural signal that depends on real call-graph resolution (rather than engine.js's generic tainted-call-argument fallback) permanently blind to Ruby. Fixed by deriving `fn.calls` from the CFG via the shared `call-sites.js#callSitesFromCfg` (the same helper `parser-py-cst.js` uses) — Ruby's node shapes already matched its documented contract. Guarded by `test/parser-rb-calls.test.js`. **Taint-recall PRD (80%): full CFG rebuild.** `_buildCfg` previously only recursed into `if`/`unless`/`while`/`until` bodies — `for`, `case`/`when`/`else`, `begin`/`rescue`/`ensure`, and any trailing block attached to a call (`xs.each do |x| … end`, the dominant Rails/ActiveRecord idiom) were silently dropped entirely, with no CFG node at all. `case`/`when` and `begin`/`rescue`/`ensure` use a recall-preserving model (every arm/clause reachable directly from the construct's own entry point, not modeled as mutually exclusive — same tradeoff `parser-kt.js`'s `_buildWhenArms` already established). A `do` block's opener also had to sit at the START of a line to be recognized at all — `_splitStatements`' depth-tracking now scans the WHOLE line (via `_rbLineDepthDelta`) rather than gating on `_RB_OPENERS.test(line)`, so a trailing `do` (mid-line, the common case) is now correctly consolidated instead of splitting its own body into independent nonsense statements. Trailing blocks recurse unconditionally and bind every named block parameter to the call's receiver (permissive by design — Ruby has no equivalent of Kotlin's implicit-this `apply`/`run` that would need to NOT bind). A recursion-depth guard (60) was added, matching every other R8-style rebuild in this codebase. Three further, independent bugs surfaced during the rebuild's own corpus-fixture debugging, all fixed: (1) `::` (Ruby's module-scope call operator, e.g. `Nokogiri::XML(x)`) was entirely invisible to `matchBalancedCall`'s `[\w.]+` callee regex — normalized to `.` on lowering; (2) a subscript-assignment on a member chain (`response.headers[key] = value`) had no assign-target branch at all and was silently dropped — lowered as a synthetic `<receiver>.[]=(key, value)` call, same shape as `parser-py.helper.py`'s `__setitem__` synthesis; (3) `_lowerExpr` checked "does this start with a quote" BEFORE checking for top-level `+` concatenation, so `"/var/data/" + name` was swallowed whole as one opaque literal — reordered, with a new `_splitTopLevelPlus` helper (string/paren/bracket-aware, so `"a+b"` is not mis-split) fixing it the same way `parser-go.js`'s R3 fix did for the identical ordering bug. Also inherits this PRD's shared chained-call fix (`_followChain`, `matchBalancedCall`-based) and keyword-argument fix from earlier in the same PRD. **Known, deliberately deferred gap:** the brace form of a trailing block (`Nokogiri::XML(xml) { |c| … }`) has its TRIGGER call correctly recognized (unlike before, where the trailing `{ }` corrupted or blocked the match), but the block BODY itself is not recursed into — only the `do...end` form got full body recursion, since it is the dominant multi-line Rails idiom and the single corpus fixture needing brace-form support only needed the trigger call's own argument, not its body. **Also known, NOT Ruby-specific:** a sink nested inside ANOTHER call's own argument (e.g. `render plain: URI.open(tainted).read`) is not independently checkable — `engine.js`'s sink-matching operates at CFG-node granularity (the outer `render` call), not on arbitrary nested sub-expressions; the SAME limitation was found via C#/Go/Kotlin corpus work earlier in this PRD, not something this task introduced or fixed. Guarded by `test/parser-rb-control-flow.test.js`. Two genuine ReDoS regexes were caught and fixed by `bench:self-scan:check` during this task — the trailing-block detector's first version had the `(.+?)\s+do` shape (an unbounded lazy prefix, NOT the "optional group between `\s*`" class every other ReDoS fix in this codebase has been; splitting into alternatives alone did not fix it — the leading capturing group itself had to go, replaced with a direct anchored search for `\bdo\b`), and the SAME detector's heuristic still flagged the replacement's residual "optional group between `\s*`" shape even though it measured linear on its own — split into two alternatives to satisfy the detector too. Measured `bench/layer-recall` impact on dataflow-shaped-subset taint recall: real, substantial movement — Ruby's own family entries moved from mostly-0% to 8/10 taint-detected. **Taint-recall PRD (80%) Tier 3: backtick shell-execution operator.** Found in the command-injection audit: `` `finger #{user}` `` (Kernel#`, equivalent to `%x{...}`) is a completely distinct syntax from a double-quoted string, but had NO recognizer at all — it fell through every `_lowerExpr` branch to `{kind:'unknown'}`, silently dropping the shell command (and any interpolated taint inside it) entirely. Lowered to a synthetic call (`__ruby_backtick_exec__`, an identifier real Ruby code can never actually name a dotted method) carrying the interpolated command as its sole argument, so a normal callee-keyed catalog sink (`rb-backtick-exec`) targets it exactly like any other call-shaped sink. A SEPARATE gap surfaced testing this: a bare backtick expression as its OWN statement (not assigned to a variable — the LAST expression of a `do...end` block, which Ruby implicitly returns, is exactly this shape) matched none of `_lowerStmt`'s branches and was dropped even after the `_lowerExpr` fix; `_lowerStmt` now delegates to `_lowerExpr` directly for this shape. Guarded by `test/catalog-command-injection-p4.test.js`. |
|
|
17
17
|
| PHP | `parser-php.js` | Regex-based, hand-rolled. ⚠ **PRD R8 was this codebase's hardest single task — a genuine 3-fix-round debugging saga, all substantially about line-number precision, not detection shape.** The core fix flushes the statement splitter on a closing `}` (previously only on `;`), and adds `try`/`switch` recognizers plus a recursion guard, so statements inside `if`/`while`/`foreach`/`try`/`switch` bodies are now real CFG nodes instead of being dropped or folded into a bogus call node — this alone also resolved a pre-existing bug where `if`/`while`/`foreach` bodies were already being mis-split even before R8 touched them. Round 1 fixed the naive `}`-flush breaking `if`/`else` and multi-clause `try` (via a continuation-keyword lookahead) and switch/case's first-statement drop (via a `:`-based flush, careful to exclude `::` so PHP 8.1 enum cases and `case Foo::BAR:` class-constant labels aren't false-positived) — but round 1's own line-tracking approach was then found wrong for comment-bearing bodies and multi-line headers, a regression the round itself introduced. Round 2 fixed that (comment-skip handlers were discarding newlines uncounted; use exact `_countNewlines`-based computation everywhere) but its own re-review found the overall "exact line" property still failed, due to a *different*, genuinely pre-existing bug in the function-body's own base-line computation (wrong for Allman-brace style, multi-line signatures, blank-line-preceded functions) plus a genuine new regression from round 1 (a comment between `}` and `else`/`catch`/`finally` dropped that continuation's body entirely). Round 3 fixed both and was confirmed clean by an 8-shape holistic sweep. Also fixed along the way: dead `finally` support (greedy regex capture bug), unrecognized `try{}finally{}` with no `catch`, and the `::` case-label false-positive — via a hand-rolled balanced-brace scanner replacing the fragile regex approach. **Known, deliberately deferred gaps** (full list + grouping in the PRD R8 status entry): the PHP 8 `match` expression is unmodeled (same class as Java's arrow-switch); `if`/`else`'s pre-existing greedy-capture bug still drops the else-body's first statement; a heredoc containing a bare `}` loses its sink entirely (a real regression from R8's original commit, not the fix rounds); `elseif`/`else if` chains are still fully unsupported (pre-existing). Guarded by `test/parser-php-control-flow.test.js`. **Measured `bench/layer-recall` impact: unchanged, 1/23 before and after — the corpus's own `1/23 → 2/23` movement is real but belongs to R14(b) (PHP `<module>` top-level lowering), not this task**, confirmed by commit-swap A/B testing (the pre-R8 parser still reproduces 2/23; the pre-R14(b) parser reproduces only 1/23). None of this corpus's `pre/` PHP fixtures place a sink genuinely inside a braced control-flow body — same explanation as the other three languages' rows.
|
|
18
18
|
|
|
19
19
|
**Taint-recall PRD (80%) Tier 4: comment-unawareness + `"literal" . $var` concat mis-parse.** Two PREVIOUSLY-DEFERRED gaps from the R8 list above, closed together (found while working the same gap class for Kotlin, below): (1) `_extractBody` (the function-BODY brace-matcher, a SEPARATE code path from `_splitStatements`'s own comment handling) had ZERO comment awareness — an apostrophe inside ANY comment ("don't", "it's") toggled its string-tracking state exactly like a real string literal, corrupting brace-depth for everything after it; depending on what followed, this either made `_extractBody` return `null` (silently dropping the **entire file's** IR — a single failed top-level function match corrupts every span downstream) or extracted the wrong body. Fixed by making `_extractBody` skip all three PHP comment forms (`//`, `#`, `/* */`) — the same fix `_splitStatements` already had for two of the three; `#`-comments (PHP's third form, previously invisible to `_splitStatements` too) are now handled there as well, careful to exclude `#[...]` PHP 8 attribute syntax from being mistaken for a comment. (2) `"literal" . $var` — arguably the single most common real-world PHP SQL-injection shape — was swallowed whole into one opaque `literal` node by TWO stacked bugs in `_lowerExpr`, each the same "unanchored prefix" defect class: the double-quoted-interpolation branch (`/^"/.test(s) && s.includes('$')`) matched on the WHOLE concat expression merely because it started with `"` and contained a `$` *anywhere* (in `$var`, outside the string, after the `.`), slicing off the wrong first/last characters as if the entire expression were one interpolated string; once that was fixed, the plain string-literal fallback (`/^"/.test(s)`) did the identical unanchored check one layer down and caught what the first fix now let through. Both are now anchored (`/^"(?:[^"\\]|\\.)*"$/`, full match required) so a concat correctly falls through to the `.`-splitting branch instead of either misreading the trailing ` . $var` as string content or swallowing the whole expression as an opaque literal. Guarded by 6 new cases in `test/parser-php-control-flow.test.js`, including an end-to-end `runScan` test proving real `$_GET`-to-PDO-sink taint flow through this exact concat shape. **Measured `bench/layer-recall` impact: real movement, PHP taint recall 2/23 (9%) → 7/23 (30%)** — the largest single per-language jump measured in this PRD, consistent with how common the `"literal" . $var` idiom is in real PHP code. **Taint-recall PRD (80%) Tier 3: PHP carried zero XSS sink entries.** `echo`/`print` are PHP LANGUAGE CONSTRUCTS, not function calls (`echo "<div>" . $_GET['q'] . "</div>";` has no `(` immediately after the keyword), so the statement-form call regex never matched them — the entire echoed expression, including any reflected taint, was silently dropped. `_lowerStmt` now recognizes `echo`/`print` directly and lowers to a synthetic call (`__php_echo__`, args split on top-level commas since `echo` accepts multiple comma-separated expressions), targeted by a new `php-echo-xss` sink — same synthetic-callee convention as Ruby's `__ruby_backtick_exec__` below. Guarded by `test/catalog-xss-p4.test.js`. |
|
package/src/lsp/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import * as path from 'node:path';
|
|
|
18
18
|
import * as readline from 'node:readline';
|
|
19
19
|
import { runScan } from '../runScan.js';
|
|
20
20
|
import { resetCustomRulesBudget } from '../posture/custom-rules.js';
|
|
21
|
+
import { withStateWritesDisabled } from '../posture/state-dir.js';
|
|
21
22
|
import { redactFinding } from '../mcp/redact.js';
|
|
22
23
|
import { _remediationOf } from '../report/index.js';
|
|
23
24
|
|
|
@@ -156,7 +157,34 @@ async function scanFile(uri) {
|
|
|
156
157
|
// whose source and sink are connected only through a call. Scoped to
|
|
157
158
|
// exactly the saved file (fileContents has one entry), so this does not
|
|
158
159
|
// turn every keystroke's save into a full-project deep scan.
|
|
159
|
-
|
|
160
|
+
// withStateWritesDisabled, for the same reason mcp/tools.js's scan_diff
|
|
161
|
+
// wraps its own partial-set scan (FR-704). This is a DIAGNOSTIC surface: it
|
|
162
|
+
// runs on every file save, against the user's real project root, with a
|
|
163
|
+
// fileContents map holding exactly one file. Without the wrapper,
|
|
164
|
+
// runFullScan's state writers fire on every keystroke-save — dpia.md,
|
|
165
|
+
// ropa.md, privacy-framework.json, threat-model.json and the rest, written
|
|
166
|
+
// into the user's tree by an editor plugin they never asked to mutate
|
|
167
|
+
// anything.
|
|
168
|
+
//
|
|
169
|
+
// The provenance lifecycle store makes that actively destructive rather
|
|
170
|
+
// than merely noisy: updateLifecycle marks every open stableId ABSENT from
|
|
171
|
+
// the finding set it is handed as `remediated`, and this set is one file's
|
|
172
|
+
// worth of findings. Every save would remediate the whole project, and the
|
|
173
|
+
// next real scan would reintroduce it.
|
|
174
|
+
//
|
|
175
|
+
// Chosen over forwarding `provenance:false` through runScan because that
|
|
176
|
+
// would fix only the lifecycle half and leave the other state writers
|
|
177
|
+
// firing. The flag is process-global (see its KNOWN LIMITATION), which is
|
|
178
|
+
// harmless here: this server is a read-only surface whose every scan wants
|
|
179
|
+
// writes off, so overlapping saves can only ever agree, and the `finally`
|
|
180
|
+
// restores the prior value either way. exceptCategories:['provenance-cache']
|
|
181
|
+
// (M2 §2.4) is the one deliberate exception — every OTHER write this scan
|
|
182
|
+
// would make stays suppressed, but the provenance disk cache stays live so
|
|
183
|
+
// repeated saves of the same file are not each paying the full uncached
|
|
184
|
+
// resolution cost.
|
|
185
|
+
const { scan } = await withStateWritesDisabled(() =>
|
|
186
|
+
runScan(_rootDir, { fileContents, depFileContents, deep: true, deepInCi: true }),
|
|
187
|
+
{ exceptCategories: ['provenance-cache'] });
|
|
160
188
|
// Stage 6 correctness audit: this only ever read scan.findings (the SAST
|
|
161
189
|
// channel). scan.secrets and scan.logicVulns are separate arrays on the
|
|
162
190
|
// raw runScan() result — normalizeFindings is what merges all four
|
|
@@ -298,7 +326,26 @@ export function startLspServer() {
|
|
|
298
326
|
}
|
|
299
327
|
|
|
300
328
|
// Allow direct invocation as a bin entry: `node lsp/server.js`.
|
|
301
|
-
|
|
329
|
+
//
|
|
330
|
+
// `import.meta.url === file://${process.argv[1]}` looks equivalent but is
|
|
331
|
+
// NOT: when this script is invoked through a symlink (exactly what
|
|
332
|
+
// `npm install -g`, `npx`, and `node_modules/.bin/<name>` all do for a
|
|
333
|
+
// package's `bin` entries — and `agentic-security-lsp` IS one of this
|
|
334
|
+
// package's bin entries), Node resolves `import.meta.url` to the symlink's
|
|
335
|
+
// realpath while `process.argv[1]` stays the symlink path as invoked, so the
|
|
336
|
+
// two never match, the guard is always false, and the server silently exits
|
|
337
|
+
// with no output — an editor would see the language server start and
|
|
338
|
+
// immediately die with nothing on stderr to explain it. `import.meta.main` is
|
|
339
|
+
// resolved correctly through a symlink. It was added in Node v24.2.0
|
|
340
|
+
// (backported to v22.18.0) and is currently Stability 1.0 (early development)
|
|
341
|
+
// per Node's own docs — NOT stable, and NOT available on v20.11. Concretely:
|
|
342
|
+
// it is `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
|
|
343
|
+
// `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
|
|
344
|
+
// reproduce this exact bug on a plain non-symlinked invocation under those two
|
|
345
|
+
// point releases. The `??` fallback covers that gap without bumping the
|
|
346
|
+
// engines floor. Identical to bin/agentic-security.js's guard, deliberately —
|
|
347
|
+
// see the long-form note there.
|
|
348
|
+
if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
|
|
302
349
|
startLspServer();
|
|
303
350
|
}
|
|
304
351
|
|
package/src/mcp/tools.js
CHANGED
|
@@ -26,6 +26,10 @@ import { withStateWritesDisabled } from '../posture/state-dir.js';
|
|
|
26
26
|
import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
|
|
27
27
|
import { redactString, redactFinding } from './redact.js';
|
|
28
28
|
import { _remediationOf, normalizeFindings } from '../report/index.js';
|
|
29
|
+
// Git-origin provenance (Finding Provenance M0/M1). Distinct from
|
|
30
|
+
// `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
|
|
31
|
+
// (Sigstore/SLSA attestation) — see report/index.js's import comment.
|
|
32
|
+
import { redactFindingProvenance } from '../posture/provenance/schema.js';
|
|
29
33
|
|
|
30
34
|
// Lazy-loaded: these transitively pull in npm packages (@babel/core and
|
|
31
35
|
// friends) that aren't available in the plugin-cache install path
|
|
@@ -571,6 +575,21 @@ export const explain_finding = {
|
|
|
571
575
|
epssScore: typeof f.epssScore === 'number' ? f.epssScore : null,
|
|
572
576
|
epssPercentile: typeof f.epssPercentile === 'number' ? f.epssPercentile : null,
|
|
573
577
|
exploitedNow: !!f.exploitedNow,
|
|
578
|
+
// Which commit introduced this finding. `includeEmail` stays at its
|
|
579
|
+
// DEFAULT (false) unconditionally — unlike the JSON report there is no
|
|
580
|
+
// operator-set env escape for it here, because the consumer is an
|
|
581
|
+
// agent that has no business receiving a committer's email address.
|
|
582
|
+
// `pseudonymize`, by contrast, IS read back from the same env var
|
|
583
|
+
// report/index.js's `_normalizedProvenance` reads
|
|
584
|
+
// (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors) —
|
|
585
|
+
// fix-round item 4: an operator who set that policy was still getting
|
|
586
|
+
// raw committer names (and, via providerEnrichment, raw reviewer
|
|
587
|
+
// logins/CODEOWNERS lines) through this MCP surface because this call
|
|
588
|
+
// passed no options object at all, silently defeating their policy at
|
|
589
|
+
// this one output boundary while report/index.js honoured it.
|
|
590
|
+
findingProvenance: f.findingProvenance ? redactFindingProvenance(f.findingProvenance, {
|
|
591
|
+
pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
|
|
592
|
+
}) : null,
|
|
574
593
|
};
|
|
575
594
|
},
|
|
576
595
|
};
|
|
@@ -775,6 +794,7 @@ export const apply_fix = {
|
|
|
775
794
|
const entry = await applyFixHistory({
|
|
776
795
|
scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content, fileExisted,
|
|
777
796
|
findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
|
|
797
|
+
findingProvenance: f.findingProvenance || null,
|
|
778
798
|
});
|
|
779
799
|
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
|
|
780
800
|
}
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
// mode to behave differently from advisory, that is a deliberate, separate
|
|
43
43
|
// decision -- not something to guess at here.
|
|
44
44
|
|
|
45
|
+
import { isProvenanceHealthy } from '../posture/provenance/schema.js';
|
|
46
|
+
|
|
45
47
|
export const ASSURANCE_MODES = Object.freeze(['advisory', 'standard', 'strict']);
|
|
46
48
|
export const DEFAULT_ASSURANCE_MODE = 'standard';
|
|
47
49
|
|
|
@@ -56,7 +58,7 @@ function _isValidMode(mode) {
|
|
|
56
58
|
* ok:false only ever happens in strict mode; advisory/standard always ok:true
|
|
57
59
|
* (they report, they do not gate).
|
|
58
60
|
*/
|
|
59
|
-
export function evaluateAssuranceMode(mode, scanHealth) {
|
|
61
|
+
export function evaluateAssuranceMode(mode, scanHealth, findings = []) {
|
|
60
62
|
const effectiveMode = _isValidMode(mode) ? mode : DEFAULT_ASSURANCE_MODE;
|
|
61
63
|
const conditions = Array.isArray(scanHealth?.conditions) ? scanHealth.conditions : [];
|
|
62
64
|
|
|
@@ -85,6 +87,67 @@ export function evaluateAssuranceMode(mode, scanHealth) {
|
|
|
85
87
|
conditions,
|
|
86
88
|
};
|
|
87
89
|
}
|
|
90
|
+
|
|
91
|
+
// M2 §2.5: strict cares about overall scan completeness, which now
|
|
92
|
+
// explicitly includes PROVENANCE completeness, not just detector/analyzer
|
|
93
|
+
// completeness. A finding whose findingProvenance status is outside
|
|
94
|
+
// ['complete','uncommitted'] — including a finding with NO
|
|
95
|
+
// findingProvenance at all, e.g. --no-provenance was used — means strict
|
|
96
|
+
// cannot vouch for this scan's provenance the same way it already refuses
|
|
97
|
+
// to vouch for a scan with a failed analyzer.
|
|
98
|
+
//
|
|
99
|
+
// KNOWN INTERACTION: scan.secrets/scan.logicVulns are unconditionally
|
|
100
|
+
// stamped not_available today (M0+M1 deliberately deferred real origin
|
|
101
|
+
// resolution for those two channels — see the M2/M3/M4 design spec's
|
|
102
|
+
// §2.6). Any real secret or logic finding therefore fails strict mode
|
|
103
|
+
// until that resolution work lands. This is the literal, intended
|
|
104
|
+
// consequence of "never false certainty" applied to strict's own
|
|
105
|
+
// definition, not an oversight — a strict-mode operator with secrets
|
|
106
|
+
// findings should expect this until M3+ closes that gap.
|
|
107
|
+
//
|
|
108
|
+
// This list is INCOMPLETE without scan.supplyChain, and the omission
|
|
109
|
+
// matters more than the secrets/logic one above because it hits nearly
|
|
110
|
+
// every real project. engine.js stamps every supplyChain entry
|
|
111
|
+
// not_available too (see the loop over `supplyChain` right after the
|
|
112
|
+
// `annotateGitProvenance` calls), and that bucket covers three distinct
|
|
113
|
+
// populations, not one:
|
|
114
|
+
//
|
|
115
|
+
// - transitive `vulnerable_dep` findings: a genuine, if currently
|
|
116
|
+
// unresolved, DEFERRAL — same shape as secrets/logicVulns above. The
|
|
117
|
+
// vulnerable version was never declared in this repo's own manifests,
|
|
118
|
+
// so there is no local commit to walk yet, but one could exist to
|
|
119
|
+
// resolve in a later phase.
|
|
120
|
+
// - `unpinned_dep` / `no_lockfile` findings: a CATEGORY ERROR, not a
|
|
121
|
+
// deferral. These describe an ABSENT state (a version range with no
|
|
122
|
+
// pin, a manifest with no lockfile) — there is no "commit that
|
|
123
|
+
// introduced a missing lockfile" for any future resolver to find,
|
|
124
|
+
// because the finding is about the absence of an event, not an event
|
|
125
|
+
// itself. No amount of future engineering work makes these resolvable.
|
|
126
|
+
//
|
|
127
|
+
// Direct `vulnerable_dep` findings DO go through real origin resolution
|
|
128
|
+
// (`resolveDirectSCAOrigin`, gated on `isDirect`) and are not part of this
|
|
129
|
+
// limitation.
|
|
130
|
+
//
|
|
131
|
+
// Net effect: because `unpinned_dep`/`no_lockfile` findings are a category
|
|
132
|
+
// error rather than a deferral, `--assurance strict` will fail on nearly
|
|
133
|
+
// any real project that has a `package.json` (or equivalent manifest)
|
|
134
|
+
// today — an unpinned or unlocked dependency is common, and this check has
|
|
135
|
+
// no way to ever resolve one. This is a known, disclosed limitation of the
|
|
136
|
+
// current implementation, not a bug, and it is not something this check
|
|
137
|
+
// should route around: exempting these finding types from the strict-mode
|
|
138
|
+
// gate was considered and deliberately deferred to a future milestone
|
|
139
|
+
// rather than done here, so strict mode keeps refusing to vouch for
|
|
140
|
+
// provenance it cannot actually speak to.
|
|
141
|
+
const badProvenance = (Array.isArray(findings) ? findings : []).filter((f) => !isProvenanceHealthy(f?.findingProvenance));
|
|
142
|
+
if (badProvenance.length > 0) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
mode: 'strict',
|
|
146
|
+
reason: `strict mode requires complete finding provenance; ${badProvenance.length} finding(s) have status outside [complete, uncommitted]`,
|
|
147
|
+
conditions,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
88
151
|
return { ok: true, mode: 'strict', reason: null, conditions };
|
|
89
152
|
}
|
|
90
153
|
|
|
@@ -46,7 +46,14 @@ export const FINDING_SCHEMA_VERSION = 1;
|
|
|
46
46
|
// sets to a real (non-null) value today; `optional` fields are legitimately
|
|
47
47
|
// null on many findings (e.g. a finding no annotator has enriched yet).
|
|
48
48
|
export const FINDING_FIELD_GROUPS = {
|
|
49
|
-
|
|
49
|
+
// `findingProvenance` is REQUIRED, not optional, and that is deliberate:
|
|
50
|
+
// posture/provenance/coordinator.js guarantees every finding it sees leaves
|
|
51
|
+
// with a TERMINAL provenance object, expressing every failure mode as a
|
|
52
|
+
// status ('not_available', 'uncommitted', 'budget_exhausted', 'error')
|
|
53
|
+
// rather than as an absent field. So a missing findingProvenance never means
|
|
54
|
+
// "provenance didn't apply here" — it means the finding escaped annotation
|
|
55
|
+
// entirely, which is exactly the condition this group exists to surface.
|
|
56
|
+
identity: { required: ['id', 'kind', 'vuln', 'findingProvenance'], optional: ['stableId'] },
|
|
50
57
|
location: { required: ['file', 'line'], optional: ['snippet'] },
|
|
51
58
|
classification: { required: ['severity'], optional: ['cwe', 'owaspLlm', 'family', 'parser', 'tags', 'description'] },
|
|
52
59
|
confidence: { required: [], optional: ['confidence', 'confidenceTier', 'calibrated_confidence', 'calibration_reason'] },
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -351,6 +351,127 @@ Driver: `scripts/comparison.mjs`, over the CVE-replay corpus.
|
|
|
351
351
|
|
|
352
352
|
**State artifact registry (assurance-hardening PRD FR-701/FR-703)** — `artifact-registry.js`. The registry `cmdReset` (bin/agentic-security.js) now iterates instead of two hardcoded WIPE/WIPE_DIRS Sets. Every known `.agentic-security/` artifact is classified `generated` (scanner-written, safe to delete on reset) or `operator-config` (hand- or agent-authored input, never deleted) — built from an audit of every `statePath()`/`stateDir()` call site, not guessed from filenames; several looked generated by name but turned out to be inputs (`.agentic-security/logic-claims.json`, `.agentic-security/exploit-history.jsonl`, `.agentic-security/cve-alerts.json`, `.agentic-security/network-policy.json`, `.agentic-security/current-intent.md` — see the module's own header for the evidence behind each). Guarded by a completeness test (`test/artifact-registry-completeness.test.js`) that scans for every `statePath()`/`stateDir()` literal and fails if one isn't registered — a `no-dead-modules.test.js`-style drift guard, not a snapshot.
|
|
353
353
|
|
|
354
|
+
## Finding provenance — `provenance/` (20 modules)
|
|
355
|
+
|
|
356
|
+
The only SUBDIRECTORY under `posture/`, because it is a pipeline rather than an
|
|
357
|
+
annotator: twenty small modules that together answer "which commit introduced
|
|
358
|
+
this finding, and how sure are we?" Everything outside the subdirectory sees one
|
|
359
|
+
function, `annotateGitProvenance(findings, ctx)` from `coordinator.js`, wired in
|
|
360
|
+
`engine.js` after every finding has been appended.
|
|
361
|
+
|
|
362
|
+
**Read the naming rule before you touch anything here.** The exported function is
|
|
363
|
+
`annotateGitProvenance` — NOT `annotateProvenance` (taken by
|
|
364
|
+
`sca/sigstore-verify.js`, build attestations) and NOT `annotateFindingProvenance`
|
|
365
|
+
(taken by `posture/provenance.js`, parser-corroboration signals). `engine.js`
|
|
366
|
+
imports all three; either alternative name is a duplicate binding, and the second
|
|
367
|
+
takes a findings array as its first argument exactly like this one, so a wrong
|
|
368
|
+
import would RUN rather than fail. The field is `finding.findingProvenance`,
|
|
369
|
+
never bare `.provenance` — `finding.provenance` and `supplyChainEntry.provenance`
|
|
370
|
+
are both pre-existing unrelated fields.
|
|
371
|
+
|
|
372
|
+
**The pipeline**, in call order — all LIVE-WIRED into `engine.js`'s scan unless noted:
|
|
373
|
+
|
|
374
|
+
| Module | Answers |
|
|
375
|
+
|---|---|
|
|
376
|
+
| `coordinator.js` | the integration point — budget, cache, per-finding dispatch, the terminal-status guarantee |
|
|
377
|
+
| `git-evidence.js` | the only Git wrapper (`getRepoState`, `blameLine`, `candidateCommitsForLine`, `getBlobAtCommit`, `commitMeta`) |
|
|
378
|
+
| `origin-resolver.js` | which commit introduced a SAST finding |
|
|
379
|
+
| `dag-walk.js` | (M3 §3.1) non-first-parent DAG walk + revert/cherry-pick detection for `--provenance deep` |
|
|
380
|
+
| `predicate-replay.js` | was this finding's condition true at commit X (calls `runFullScan` on that commit's blobs) |
|
|
381
|
+
| `sca-origin.js` | which commit moved a directly-declared dependency version into an advisory's vulnerable range |
|
|
382
|
+
| `transitive-sca.js` | (M3 §3.2) the same question for a TRANSITIVE dependency, re-deriving lockfile ancestry per historical commit |
|
|
383
|
+
| `branch-entry.js` | which branch/PR merge brought the origin commit into the current branch |
|
|
384
|
+
| `evidence-attribution.js` | the path:line:commit triples for source / sink / manifest |
|
|
385
|
+
| `confidence.js` | HIGH / MEDIUM / LOW plus the reasons behind it |
|
|
386
|
+
| `lifecycle.js` | the introduce / remediate / reintroduce ledger |
|
|
387
|
+
| `cache.js` | per-(HEAD, stableId, ruleset, boundary, mode) memo under its own top-level `.agentic-security/provenance-cache/` (split out from `provenance/` so it can carry a `'cache'` retentionClass the permanent lifecycle ledger must not get — see artifact-registry.js) |
|
|
388
|
+
| `schema.js` | the status/method/role/confidence enums, `emptyProvenance`, `redactFindingProvenance`, `isProvenanceHealthy` |
|
|
389
|
+
| `validate.js` | shape assertion for tests |
|
|
390
|
+
| `missing-control-resolver.js` | (M3 §3.3, FR-PROV-017) when a previously-observed safeguard disappeared — **wired into `coordinator.js`**: `resolveMissingControlOrigin` calls `resolveMissingControl` for any finding with `missingControlCandidate:true` (today, `sast/rate-limit.js`'s findings) |
|
|
391
|
+
| `providers/config.js`, `providers/github.js`, `providers/gitlab.js` | (M3 §3.4, FR-PROV-022) GitHub/GitLab PR-metadata + CODEOWNERS fetch, config resolved from `.agentic-security/provenance-providers.yml` / token env vars — **wired into `coordinator.js`**: `resolveProviderConfig` is resolved once per scan in `annotateGitProvenance`, and `fetchPRMetadata`/`fetchCodeowners` are called per `complete`-status finding (capped, see `MAX_PROVIDER_ENRICHMENTS_PER_SCAN`), landing on `findingProvenance.providerEnrichment` |
|
|
392
|
+
| `repo-lineage.js` | (M4 §4.2) loads + fully verifies an operator-declared `.agentic-security/repo-lineage.json` cross-repo link (local clones only, no remote fetch) — used by `origin-resolver.js`'s root-commit case, not a standalone-unwired module |
|
|
393
|
+
| `ai-authorship.js` | (M4 §4.3) extensible AI-authorship verifier registry (`registerAIAuthorshipVerifier`/`resolveAIAuthorship`), defaults to `{status:'unknown', verifier:null}` with nothing registered (today's real state) — wired into `origin-resolver.js`'s `originFrom`, so every SAST `findingOrigin` carries `aiAuthorship`; scoped to SAST only, not direct/transitive SCA origins |
|
|
394
|
+
|
|
395
|
+
**Four invariants, each with a test that fails if you relax it:**
|
|
396
|
+
|
|
397
|
+
- **Terminal status, always.** After `annotateGitProvenance` returns, every
|
|
398
|
+
finding carries a `findingProvenance` with one of `complete` / `partial` /
|
|
399
|
+
`uncommitted` / `not_available` / `budget_exhausted` / `error`. There is no
|
|
400
|
+
path — missing git binary, malformed finding, downstream throw — that leaves
|
|
401
|
+
the field absent. `engine.js` additionally backstops every channel OUTSIDE
|
|
402
|
+
the `_runAnnotator` wrapper, because that wrapper swallows throws —
|
|
403
|
+
`findings` and `supplyChain` with a full not_available/error catch-all as
|
|
404
|
+
before; since Task 11, `secrets` and blameable `logicVulns` go through REAL
|
|
405
|
+
resolution (real stableIds backfilled, real `annotateGitProvenance` calls
|
|
406
|
+
made), so their outside-the-wrapper coverage narrowed to a defensive
|
|
407
|
+
catch-all for whatever the real call somehow didn't reach, plus the 3
|
|
408
|
+
synthetic-line `logicVulns` producers (`license-policy:`/`deploy-platform:`/
|
|
409
|
+
`stack-playbook:`), which stay on a permanent, principled not_available —
|
|
410
|
+
never routed through `resolveOrigin` at all, not merely deferred.
|
|
411
|
+
- **Never false certainty.** A shallow clone cannot reach `complete`; an
|
|
412
|
+
unverifiable parent boundary degrades to `partial` with its reason carried
|
|
413
|
+
through. `origin-resolver.js` decides this on the `shallow` flag of the
|
|
414
|
+
repoState object, and it must come from the REAL `getRepoState()` — pass it a
|
|
415
|
+
stub and the guarantee is gone.
|
|
416
|
+
- **The lifecycle ledger only closes findings on a COMPLETE scan.** `applyScan`'s
|
|
417
|
+
remediation pass turns absence into the claim "this was fixed," which is sound
|
|
418
|
+
only if the scan looked everywhere. `runScan.js` computes `completeScan` (false
|
|
419
|
+
for `--changed-since`/`--pr` and for caller-supplied `fileContents`) and threads
|
|
420
|
+
it through `runFullScan` to `updateLifecycle`. `updateLifecycle` is also gated on
|
|
421
|
+
the `scanRoot` being **a directory that exists** — not merely truthy.
|
|
422
|
+
`resolveProjectRoot` honours a caller-supplied scanRoot only when it resolves to
|
|
423
|
+
a real directory; for `null`, for a typo'd path, or for a file, it falls back to
|
|
424
|
+
walking up from the PROCESS CWD. Both doors led to the same corruption: a scan
|
|
425
|
+
that never looked at your project writing your project's ledger, and then —
|
|
426
|
+
finding nothing while still claiming `completeScan` — remediating every open
|
|
427
|
+
finding in it. `agentic-security scan ./typo` is the reachable form. This repo's
|
|
428
|
+
own checkout accumulated a 1.1 MB ledger of spurious events that way.
|
|
429
|
+
- **One budget for the whole scan.** `engine.js` computes ONE `deadlineAt` and
|
|
430
|
+
passes it to all five of its `annotateGitProvenance` calls (SAST findings,
|
|
431
|
+
direct SCA deps, transitive SCA deps per Task 7, then secrets and blameable
|
|
432
|
+
logicVulns per Task 11); a caller-supplied `deadlineAt`/`perFindingBudgetMs`
|
|
433
|
+
wins over the coordinator's own computation. Inside, each finding gets
|
|
434
|
+
`max(2s, remaining/count)` so one deep-history finding cannot starve the rest.
|
|
435
|
+
`budget_exhausted` is the one result that is **never cached** — it is a property
|
|
436
|
+
of the run, not the repository, and caching it would pin a timeout in place
|
|
437
|
+
until HEAD moved.
|
|
438
|
+
|
|
439
|
+
**Re-entrancy brake.** `predicate-replay.js` calls `runFullScan` back on historical
|
|
440
|
+
blobs, so every internal re-scan must pass `provenance:false` or the pass recurses
|
|
441
|
+
without bound. Present callers: `history-scan.js` (×3), `pr-delta.js`,
|
|
442
|
+
`fix-verify.js`, `compare.js`; `lsp/server.js` uses the wider
|
|
443
|
+
`withStateWritesDisabled`.
|
|
444
|
+
|
|
445
|
+
**Privacy.** Author emails are collected but redacted by `redactFindingProvenance`
|
|
446
|
+
at every output boundary (`report/index.js`, `mcp/tools.js`) unless
|
|
447
|
+
`AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL=1` / `--include-author-email`. Separately,
|
|
448
|
+
`AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1` / `--pseudonymize-authors` (PRD Section 8)
|
|
449
|
+
replaces `authorName` with a stable `Contributor-XXXXXXXX` pseudonym instead of
|
|
450
|
+
withholding it — `redactFindingProvenance` applies the same treatment to
|
|
451
|
+
`providerEnrichment.reviewers`/`codeowners` (FR-PROV-022's PR-reviewer logins and
|
|
452
|
+
raw CODEOWNERS lines), not just `findingOrigin`. Both `report/index.js` and
|
|
453
|
+
`mcp/tools.js` read the env var per call to build the redaction options
|
|
454
|
+
(`mcp/tools.js` deliberately never reads `AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL`
|
|
455
|
+
itself — an agent caller gets no raw email regardless of that flag); the
|
|
456
|
+
`auditor-walkthrough.js` narrative reads it too, for the one `earliestOrigin`
|
|
457
|
+
field that bypasses `redactFindingProvenance` entirely (see that module's own
|
|
458
|
+
comment on why).
|
|
459
|
+
|
|
460
|
+
**At rest, `provenance/cache.js` stores the UNREDACTED record, on purpose.**
|
|
461
|
+
Redaction is a read-time/output-time concern — the same cached record gets
|
|
462
|
+
replayed back out through `redactFindingProvenance` differently per output
|
|
463
|
+
call (default vs. `--include-author-email` vs. `--pseudonymize-authors`), which
|
|
464
|
+
only works if the cache holds one raw, policy-independent copy. Pre-redacting
|
|
465
|
+
at write time would freeze whichever policy was active when the entry was
|
|
466
|
+
cached, breaking that per-call flexibility for every later reader (second
|
|
467
|
+
independent Finding Provenance PRD audit). The accepted mitigation is a
|
|
468
|
+
permissions floor, not encryption: every `cacheSet` chmods the entry file to
|
|
469
|
+
`0600` and the `provenance-cache/` directory to `0700` (same posture as
|
|
470
|
+
`integrity.js`'s per-install HMAC key). This defeats other local users/processes
|
|
471
|
+
reading the cache; it does not defeat root or the same OS user. See
|
|
472
|
+
`cache.js`'s own header for the full tradeoff writeup, including why
|
|
473
|
+
encryption-at-rest was considered and deferred.
|
|
474
|
+
|
|
354
475
|
## Gotchas
|
|
355
476
|
|
|
356
477
|
- The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
|
|
@@ -123,6 +123,18 @@ export function aggregateCorpus(detail) {
|
|
|
123
123
|
* — measured THIS run
|
|
124
124
|
* committed { corpusBaseline, proofCorpus } — read from committed files,
|
|
125
125
|
* labelled as such in the output, never used to derive a rate
|
|
126
|
+
* scan optional — a scan-shaped object (`{findings, secrets,
|
|
127
|
+
* supplyChain}`, trimmed to just those arrays) from a run over
|
|
128
|
+
* a full (non-shallow) Git clone, used ONLY to compute
|
|
129
|
+
* provenanceCoverage below. `scripts/scorecard.mjs` passes
|
|
130
|
+
* `selfScan.provenanceScan` — the self-scan harness
|
|
131
|
+
* (bench/self-scan/measure.mjs) already runs a real
|
|
132
|
+
* `runScan()` over this project's own full git clone with
|
|
133
|
+
* provenance resolution on by default, so this reuses that
|
|
134
|
+
* run's already-computed `findingProvenance` rather than
|
|
135
|
+
* performing a second scan. Still optional: a caller with no
|
|
136
|
+
* such scan renders "not measured this run" rather than a
|
|
137
|
+
* fabricated rate. See PRD Success Metrics.
|
|
126
138
|
*/
|
|
127
139
|
export function buildScorecard(inputs) {
|
|
128
140
|
const corpus = aggregateCorpus(inputs.corpusDetail);
|
|
@@ -163,6 +175,14 @@ export function buildScorecard(inputs) {
|
|
|
163
175
|
byTier: corpus.byTier,
|
|
164
176
|
},
|
|
165
177
|
selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
|
|
178
|
+
// PRD Success Metrics: "Provenance coverage >=95% complete or uncommitted
|
|
179
|
+
// for P0-supported findings in full Git clones." `inputs.scan` is
|
|
180
|
+
// optional (see the JSDoc above) — absent when no caller yet supplies a
|
|
181
|
+
// real scan, in which case this reports "not measured" rather than a
|
|
182
|
+
// fabricated 0/0.
|
|
183
|
+
provenanceCoverage: inputs.scan
|
|
184
|
+
? { measuredThisRun: true, ...computeProvenanceCoverage(inputs.scan) }
|
|
185
|
+
: { measuredThisRun: false },
|
|
166
186
|
taintRecall: (() => {
|
|
167
187
|
const lr = inputs.layerRecall;
|
|
168
188
|
if (!lr) {
|
|
@@ -249,6 +269,28 @@ export function buildScorecard(inputs) {
|
|
|
249
269
|
};
|
|
250
270
|
}
|
|
251
271
|
|
|
272
|
+
// PRD Success Metrics: "Provenance coverage >=95% complete or uncommitted
|
|
273
|
+
// for P0-supported findings in full Git clones." P0-supported scope per
|
|
274
|
+
// the PRD's own Release Scope table: code (SAST), secrets, IaC/config,
|
|
275
|
+
// direct dependency findings. Secrets now get real origin resolution
|
|
276
|
+
// (Task 11 -- `engine.js` calls `annotateGitProvenance` on `scan.secrets`
|
|
277
|
+
// with a real per-pattern-backfilled stableId, the same as SAST findings),
|
|
278
|
+
// so this metric no longer has a structural reason to read lower for the
|
|
279
|
+
// secrets share of the denominator than for any other P0-scoped channel.
|
|
280
|
+
export function computeProvenanceCoverage(scan) {
|
|
281
|
+
const p0Findings = [
|
|
282
|
+
...(scan.findings || []),
|
|
283
|
+
...(scan.secrets || []),
|
|
284
|
+
...(scan.supplyChain || []).filter((s) => s.type === 'vulnerable_dep' && s.isDirect),
|
|
285
|
+
];
|
|
286
|
+
const d = p0Findings.length;
|
|
287
|
+
const n = p0Findings.filter((f) => {
|
|
288
|
+
const status = f.findingProvenance?.status;
|
|
289
|
+
return status === 'complete' || status === 'uncommitted';
|
|
290
|
+
}).length;
|
|
291
|
+
return { n, d };
|
|
292
|
+
}
|
|
293
|
+
|
|
252
294
|
function rateRow(r) {
|
|
253
295
|
return `| ${r.key} | ${r.entries} | ${formatRate(r.detection.n, r.detection.d)} | ${formatRate(r.silence.n, r.silence.d)} |`;
|
|
254
296
|
}
|
|
@@ -470,6 +512,24 @@ export function renderScorecardMarkdown(m) {
|
|
|
470
512
|
}
|
|
471
513
|
L.push('Per-file counts are in `docs/scorecard.json`.');
|
|
472
514
|
L.push('');
|
|
515
|
+
if (m.provenanceCoverage && m.provenanceCoverage.measuredThisRun) {
|
|
516
|
+
L.push('## Provenance coverage');
|
|
517
|
+
L.push('');
|
|
518
|
+
L.push('PRD Success Metric: **>=95% of P0-scoped findings (SAST + secrets + direct**');
|
|
519
|
+
L.push('**dependency findings) resolve to `complete` or `uncommitted` git provenance**');
|
|
520
|
+
L.push('in a full (non-shallow) clone. Transitive dependency findings are excluded —');
|
|
521
|
+
L.push('the PRD\'s Release Scope table names direct dependency findings only.');
|
|
522
|
+
L.push('');
|
|
523
|
+
L.push('| P0-scoped findings — complete/uncommitted provenance |');
|
|
524
|
+
L.push('| --- |');
|
|
525
|
+
L.push(`| ${formatRate(m.provenanceCoverage.n, m.provenanceCoverage.d)} |`);
|
|
526
|
+
L.push('');
|
|
527
|
+
L.push('Secrets, SAST, and direct-dependency findings all resolve through the same');
|
|
528
|
+
L.push('git-origin resolution pipeline, so a gap in this rate reflects the clone');
|
|
529
|
+
L.push('itself (shallow history, uncommitted lines the pipeline could not blame) —');
|
|
530
|
+
L.push('not a channel this measurement structurally cannot yet cover.');
|
|
531
|
+
L.push('');
|
|
532
|
+
}
|
|
473
533
|
// PRD F12.6 — the honest scorecard publishes the LIMITS too, not only the
|
|
474
534
|
// rates. Three claims this project makes are only meaningful with their
|
|
475
535
|
// caveat attached, and each caveat was invisible before this section:
|