@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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 +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/pr-comment.js +3 -1
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/untrusted.js +148 -0
- package/src/util/yaml.js +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,166 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.130.0 — the roadmap's first ten: provable security over orchestration parity
|
|
4
|
+
|
|
5
|
+
A capability roadmap (`docs/ROADMAP.md`) plus its first ten items, derived from a
|
|
6
|
+
survey of the current agentic security-review field. The strategic call: that
|
|
7
|
+
field cannot state a false-positive rate or prove it did not regress, because its
|
|
8
|
+
core is a model call. This project's core is a deterministic engine behind three
|
|
9
|
+
gates, so the work doubles down on provable, measurable, reproducible.
|
|
10
|
+
|
|
11
|
+
- **Execution sandbox** (`src/sandbox/`) — fail-closed confined execution. Verified
|
|
12
|
+
by execution: writes outside the sandbox root blocked, network blocked, wall-clock
|
|
13
|
+
overrun terminated, benign work still succeeds. With no confinement primitive
|
|
14
|
+
available, execution is REFUSED, never run unconfined.
|
|
15
|
+
- **Execution-verified findings** (`posture/execution-proof.js`) — a finding can be
|
|
16
|
+
promoted to `execution-proven` by running its proof-of-concept in the sandbox.
|
|
17
|
+
Proof is a marker file, never an exit code, because the sandbox cannot reliably
|
|
18
|
+
distinguish a denied run from a clean exit. `proof-failed` is a triage signal,
|
|
19
|
+
NOT a false-positive verdict.
|
|
20
|
+
- **Published accuracy scorecard** (`npm run scorecard`) — detection and correct-
|
|
21
|
+
silence rates sliced by language and CWE, every figure with its denominator,
|
|
22
|
+
regeneration-stable. F1 deliberately omitted: no labelled real-world population
|
|
23
|
+
exists to measure precision over, and the document says so.
|
|
24
|
+
- **Determinism attestation** (`posture/attestation.js`) — an order-independent
|
|
25
|
+
signed digest. Cross-machine reproducibility is explicitly NOT claimed.
|
|
26
|
+
- **Fixes must pass your tests** (`posture/test-runner.js`) — `verifyFix` previously
|
|
27
|
+
proved only that the finding disappeared, which deleting the feature also
|
|
28
|
+
achieves. It now runs the project's own suite, and says so when the suite ran
|
|
29
|
+
against unpatched code.
|
|
30
|
+
- **Relevance ranking** (`posture/relevance.js`) — re-ranks by entry-point
|
|
31
|
+
reachability. Recall-preserving: nothing deleted, severity never touched,
|
|
32
|
+
`unreachable` only on positive evidence.
|
|
33
|
+
- **Enforced verification separation** (`posture/verification-separation.js`) — a
|
|
34
|
+
verifier cannot rubber-stamp its own finding.
|
|
35
|
+
- **Resumable scans** (`posture/scan-checkpoint.js`) — opt-in via
|
|
36
|
+
`AGENTIC_SECURITY_RESUME=1`, crash-safe, conservatively invalidated.
|
|
37
|
+
- **Secret redaction** (`llm-validator/redact.js`) — credentials removed before any
|
|
38
|
+
code leaves the machine; ordinary code passes through byte-unchanged.
|
|
39
|
+
|
|
40
|
+
Also: dependencies updated to latest (Babel 7→8 with the removed preset options
|
|
41
|
+
migrated, js-yaml 4→5 with an empty-input shim). One dependency deliberately held
|
|
42
|
+
back: bumping the grammar runtime silently drops all six long-tail language
|
|
43
|
+
grammars, so it stays pinned and the reason is documented.
|
|
44
|
+
|
|
45
|
+
`npm test` 1989/0; cve-replay 199/199; self-scan no drift.
|
|
46
|
+
|
|
47
|
+
## 0.129.0 — closing two taint-engine recall gaps
|
|
48
|
+
|
|
49
|
+
Two defects were found by execution on the merged tree, each silencing real findings across
|
|
50
|
+
every supported language. Both are now fixed, measured, and gated.
|
|
51
|
+
|
|
52
|
+
- **Sinks are matched on assignment right-hand sides** (`dataflow/engine.js`). The engine only
|
|
53
|
+
ever sink-matched in statement position, so `db.query(tainted)` was reported while
|
|
54
|
+
`const rows = db.query(tainted)` was silent — in every language. The sink-matching logic is
|
|
55
|
+
now extracted into shared helpers called from both `case 'call'` and `case 'assign'` rather
|
|
56
|
+
than duplicated, and the pre-existing statement-position path is unchanged (measured control:
|
|
57
|
+
`1/1` before and after; assignment position `0/0 -> 1/1`).
|
|
58
|
+
- **`match.type:'global'` catalog entries are indexed and reachable** (`dataflow/catalog.js`).
|
|
59
|
+
All 10 global entries were unreachable from `matchSource()` — including `$_GET`/`$_POST`/
|
|
60
|
+
`$_REQUEST`, the canonical PHP taint sources, in a language that already had interprocedural
|
|
61
|
+
analysis. A new `GLOBAL_INDEX`, plus lookup-side sigil normalization (`_globalKey()`) so PHP's
|
|
62
|
+
`$` prefix matches sigil-free catalog keys, takes catalog reachability `0/10 -> 10/10` with
|
|
63
|
+
language scoping preserved.
|
|
64
|
+
- **Seven self-scan false positives eliminated at source.** Raising sink recall exposed
|
|
65
|
+
pre-existing catalog imprecision: `py-yaml-load`/`py-pickle-load` matched bare callee `load`
|
|
66
|
+
with no receiver constraint, so ordinary `json.load(fh)` was flagged as unsafe deserialization.
|
|
67
|
+
Each finding was inspected individually and all seven were false positives; the entries are now
|
|
68
|
+
pinned to their receiver. **Nothing was baselined** — `bench/self-scan/BASELINE.json` is
|
|
69
|
+
unchanged and the gate is green on the source fix.
|
|
70
|
+
- **New**: `bench/engine-recall` before/after harness (`npm run bench:engine-recall`) and
|
|
71
|
+
`bench/engine-recall/RESULTS.md`, the full measurement record including what the fixes cost.
|
|
72
|
+
- **Corpus 197 -> 199**: two deep-tier entries, each verified missed-before / found-after against
|
|
73
|
+
its specific fix.
|
|
74
|
+
|
|
75
|
+
Known trade, recorded rather than papered over: pinning the receiver drops `import yaml as y;
|
|
76
|
+
y.load(f)` and `from yaml import load; load(x)`, which are now covered at no layer. A corpus guard
|
|
77
|
+
was attempted and deliberately withheld because it would score `pre:TN`; closing it needs
|
|
78
|
+
import-alias resolution in the Python IR. Separately, `10/10` is catalog reachability, not
|
|
79
|
+
end-to-end recall — only PHP is proven end to end; Ruby's deep engine does not complete those
|
|
80
|
+
flows (pre-existing). Both are documented in `RESULTS.md` §3 and §8.
|
|
81
|
+
|
|
82
|
+
`npm test` 1854/0; cve-replay 199/199; self-scan no drift.
|
|
83
|
+
|
|
84
|
+
## 0.128.2 — compliance attestation accuracy + quieter self-scans
|
|
85
|
+
|
|
86
|
+
Two fixes surfaced while dogfooding the compliance flow on this repo:
|
|
87
|
+
|
|
88
|
+
- **Fixed a compliance-attestation path bug** (`posture/auditor-walkthrough.js`). Three
|
|
89
|
+
evidence checks (`mcp-tools`, `security-fixer`, `pre-edit-bodyguard`) carried a literal
|
|
90
|
+
`.../` placeholder path that `path.join(scanRoot, STATE, '.../x')` could never resolve, so
|
|
91
|
+
they read **"not present" for every project** — falsely dragging OWASP LLM08/LLM09 (and any
|
|
92
|
+
framework mapping to those modules) to "manual/not-present". A `.../` sentinel now resolves
|
|
93
|
+
against the scan root itself. On a self-attestation this flips LLM09 → satisfied and makes
|
|
94
|
+
LLM08 honestly partial.
|
|
95
|
+
- **Repo `ignorePaths` for meaningful self-scans** (`.agentic-security/rules.yml`). Added
|
|
96
|
+
`bench/**` and `scanner/test/fixtures/**` so a repo-root `/scan` no longer counts the ~600
|
|
97
|
+
intentionally-vulnerable benchmark corpora and test fixtures as findings. Safe: `rules.yml`
|
|
98
|
+
is loaded from the exact scan root only, so the cve-replay runner (which scans each
|
|
99
|
+
pre/post fixture as its own root) and the unit tests are unaffected — corpus gate stays
|
|
100
|
+
185/185.
|
|
101
|
+
|
|
102
|
+
`npm test` 1695/0; cve-replay 185/185.
|
|
103
|
+
|
|
104
|
+
## 0.128.1 — patch dependency vulnerabilities (11 Dependabot alerts → 0)
|
|
105
|
+
|
|
106
|
+
Security maintenance. Cleared all 11 open Dependabot alerts by updating the two lockfiles to
|
|
107
|
+
patched versions (all within-major bumps, no breakage):
|
|
108
|
+
|
|
109
|
+
- **`scanner/`** — `js-yaml` 4.1 → 4.3.0 (GHSA-h67p-54hq-rp68, quadratic-complexity DoS via merge
|
|
110
|
+
keys). `js-yaml` is inlined into the shipped bundle, so `dist/agentic-security.mjs` was rebuilt;
|
|
111
|
+
full gate re-run green (`npm test` 1695/0, cve-replay 185/185).
|
|
112
|
+
- **`ide/vscode/`** — `undici` → 7.28.0 (incl. one high), `form-data` → 4.0.6 (high),
|
|
113
|
+
`markdown-it` → 14.3.0, `esbuild` → 0.28.1, `js-yaml` → 4.3.0. All transitive under
|
|
114
|
+
`@vscode/vsce`/`esbuild`; lockfile-only, `npm audit` now reports 0.
|
|
115
|
+
|
|
116
|
+
`npm audit` is clean (0 vulnerabilities) in both packages.
|
|
117
|
+
|
|
118
|
+
## 0.128.0 — the agentic methodology layer + a simpler command surface
|
|
119
|
+
|
|
120
|
+
Two things landed together this release: a set of default-on **methodology annotators** that
|
|
121
|
+
layer agentic-hunter discipline on top of the deterministic engine, and a **consolidation** of
|
|
122
|
+
the command and skill surface so there's less to remember.
|
|
123
|
+
|
|
124
|
+
**Methodology layer (7 additions, all v1, all tested — see `docs/AGENTIC_METHODOLOGY_PRD.md`):**
|
|
125
|
+
|
|
126
|
+
- **Default falsification pass** (`posture/falsification.js`) — for each taint finding it tries
|
|
127
|
+
to *disprove* the finding by locating a context-matched control on the path, and demotes +
|
|
128
|
+
quarantines the ones it can block. Recall-preserving (never removes a finding, never touches
|
|
129
|
+
severity — like the proof gate); genuine cve-replay `pre` vulns still fire (0 false blocks,
|
|
130
|
+
corpus 185/185 intact). Opt out with `AGENTIC_SECURITY_NO_FALSIFICATION=1`.
|
|
131
|
+
- **Attack-surface completeness inventory** (`posture/entrypoint-inventory.js`) — enumerates
|
|
132
|
+
every entry point (HTTP / queue / cron / CLI / env / upload / webhook) with a disposition
|
|
133
|
+
each, on `scan.entrypointInventory`.
|
|
134
|
+
- **Root-cause sweep** (`posture/root-cause-sweep.js`) — from a confirmed finding, searches the
|
|
135
|
+
repo for sibling instances detectors missed, with a `found = candidates + mitigated`
|
|
136
|
+
accounting invariant, on `scan.rootCauseSweep`.
|
|
137
|
+
- **Meta-security hardening** (`util/untrusted.js` + `docs/AGENT_THREAT_MODEL.md`) — a tested
|
|
138
|
+
threat model treating attacker-authored finding text as untrusted input; escaping wired into
|
|
139
|
+
the PR/issue/ticket render paths.
|
|
140
|
+
- **Capability-based model routing** (`posture/model-routing.js`) — stamps `finding.dispatchModel`
|
|
141
|
+
(strongest for crypto/auth/critical, mid for injection, cheapest for low-sev hardening) for
|
|
142
|
+
cost-sensitive subagent dispatch.
|
|
143
|
+
- **Self-improving recall harness** (`bench/realworld-recall/`) — LLM-judged (offline-degrading)
|
|
144
|
+
recall on real repos + a miss-analyzer that names the pipeline stage that dropped a finding
|
|
145
|
+
and proposes the fix. Bench-only; never in the product scan path.
|
|
146
|
+
- **Deterministic fix-honesty gates** (`posture/fix-honesty-gate.js`) — a residual-risk
|
|
147
|
+
hand-wave guard, a cited-file:line requirement for FP/safe verdicts, and FULL/MITIGATION/
|
|
148
|
+
WORKAROUND completeness tiers; the previously-orphaned test loop is now wired into
|
|
149
|
+
`apply_fix` behind `AGENTIC_SECURITY_FIX_RUN_TESTS=1`.
|
|
150
|
+
|
|
151
|
+
**Simpler surface (no functionality removed — everything folds to a mode + alias):**
|
|
152
|
+
|
|
153
|
+
- Commands **12 → 10**: `/ci` folded into `/setup --ci` (+ new `/setup --predeploy`), and
|
|
154
|
+
`/three-agent-review` into `/triage --deep`. Old names still resolve via the
|
|
155
|
+
legacy-alias-redirect hook.
|
|
156
|
+
- Skills **11 → 7**: the four write-time guards merged into `secure-coding-guard`, and the two
|
|
157
|
+
explainers into `security-explain`.
|
|
158
|
+
|
|
159
|
+
**Docs:** README + ARCHITECTURE + HARNESS_COMPATIBILITY refreshed with accurate surface counts
|
|
160
|
+
(17 MCP tools, 10 commands, 7 skills, 5 hook events, 9 sub-agents); the SAST/SCA improvement PRD
|
|
161
|
+
audited and marked (16 of 25 shipped, 9 partial). Full `npm test` green (1695 tests); cve-replay
|
|
162
|
+
corpus 185/185, no drift.
|
|
163
|
+
|
|
3
164
|
## 0.127.0 — cost advisor: an actual choice, not just a tip
|
|
4
165
|
|
|
5
166
|
The model-cost advisor (`hooks/model-cost-advisor.js`) has always been advisory
|
package/bin/agentic-security.js
CHANGED
|
@@ -274,6 +274,21 @@ function renderV3Blocks(scan, flags) {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
// Always-on machine output (R2). Vibecoder gets JSON only; pro gets JSON+SARIF+CSV.
|
|
277
|
+
// SHA-256 of the running bundle, read from the sidecar `npm run build` emits
|
|
278
|
+
// NEXT TO the bundle. Running from source (bin/ + src/) yields 'unavailable'
|
|
279
|
+
// rather than the checkout's dist hash: src and a previously-built dist can
|
|
280
|
+
// disagree, and attesting a bundle that did not produce this run would be a
|
|
281
|
+
// false claim.
|
|
282
|
+
function _bundleSha() {
|
|
283
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
284
|
+
try {
|
|
285
|
+
const raw = fs.readFileSync(path.join(here, 'agentic-security.mjs.sha256'), 'utf8').trim();
|
|
286
|
+
const m = /^([0-9a-f]{64})\b/.exec(raw);
|
|
287
|
+
if (m) return m[1];
|
|
288
|
+
} catch { /* not running from the bundle */ }
|
|
289
|
+
return 'unavailable';
|
|
290
|
+
}
|
|
291
|
+
|
|
277
292
|
async function writeMachineOutput(targetAbs, scan, meta, profile) {
|
|
278
293
|
const stateDir = path.join(targetAbs, '.agentic-security');
|
|
279
294
|
const { isSafeStateDir: _isSafe } = await import('../src/posture/state-dir.js');
|
|
@@ -553,6 +568,24 @@ async function cmdScan(args) {
|
|
|
553
568
|
// Deterministic post-process: stable-sort findings + zero out timing.
|
|
554
569
|
if (isDeterministic()) makeDeterministic(scan, meta);
|
|
555
570
|
|
|
571
|
+
// R4 — determinism as a contract. Bind the PUBLISHED finding set (the same
|
|
572
|
+
// normalization every report format emits) to the engine version, ruleset
|
|
573
|
+
// version and bundle hash that produced it, via an order-independent digest.
|
|
574
|
+
// Runs after every filter above so it attests what actually ships. Metadata
|
|
575
|
+
// only — a failure here must never fail a scan.
|
|
576
|
+
try {
|
|
577
|
+
const { computeRunAttestation } = await import('../src/posture/attestation.js');
|
|
578
|
+
const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
|
|
579
|
+
scan.attestation = computeRunAttestation({
|
|
580
|
+
findings: normalizeFindings(scan),
|
|
581
|
+
engineVersion: PKG_VERSION,
|
|
582
|
+
rulesetVersion: effectiveVersion(targetAbs).version,
|
|
583
|
+
bundleSha: _bundleSha(),
|
|
584
|
+
root: targetAbs,
|
|
585
|
+
sign: true,
|
|
586
|
+
});
|
|
587
|
+
} catch { /* attestation is metadata; never fail a scan over it */ }
|
|
588
|
+
|
|
556
589
|
// R2: Always emit machine-readable artifacts to .agentic-security/.
|
|
557
590
|
await writeMachineOutput(targetAbs, scan, meta, profile);
|
|
558
591
|
|
package/dist/11.index.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
export const id = 11;
|
|
2
|
+
export const ids = [11];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 11:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
// ESM COMPAT FLAG
|
|
9
|
+
__webpack_require__.r(__webpack_exports__);
|
|
10
|
+
|
|
11
|
+
// EXPORTS
|
|
12
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
13
|
+
runProjectLinter: () => (/* binding */ runProjectLinter),
|
|
14
|
+
verifyFix: () => (/* binding */ verifyFix),
|
|
15
|
+
verifyPatch: () => (/* binding */ verifyPatch)
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// EXTERNAL MODULE: external "node:child_process"
|
|
19
|
+
var external_node_child_process_ = __webpack_require__(1421);
|
|
20
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
21
|
+
var external_node_fs_ = __webpack_require__(3024);
|
|
22
|
+
// EXTERNAL MODULE: external "node:path"
|
|
23
|
+
var external_node_path_ = __webpack_require__(6760);
|
|
24
|
+
// EXTERNAL MODULE: ./src/engine.js + 592 modules
|
|
25
|
+
var engine = __webpack_require__(9408);
|
|
26
|
+
;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
|
|
27
|
+
// Deterministic honesty gates on fix / finding output (#7).
|
|
28
|
+
//
|
|
29
|
+
// The project's verification discipline (scanner/CLAUDE.md) exists because
|
|
30
|
+
// several releases shipped broken or false because work was reported as done
|
|
31
|
+
// without confirming the artifact changed. Two of those failure modes are
|
|
32
|
+
// *textual* — they live in the prose an agent emits alongside a fix — and can
|
|
33
|
+
// be caught deterministically, with no LLM and no network:
|
|
34
|
+
//
|
|
35
|
+
// 1. Hand-wave residual-risk prose. "The input is adequately handled",
|
|
36
|
+
// "future work", "tbd", "later" — vague assurances that claim safety
|
|
37
|
+
// without naming a concrete remaining vector. A residual you can't name
|
|
38
|
+
// is a residual you're guessing about; reject the guess.
|
|
39
|
+
//
|
|
40
|
+
// 2. An unbacked "this is a false positive / provably safe" verdict. Marking
|
|
41
|
+
// a finding safe is a coverage *reduction* — it must cite a `file:line`
|
|
42
|
+
// that shows why, exactly like the rules-override gate refuses to silently
|
|
43
|
+
// shrink coverage.
|
|
44
|
+
//
|
|
45
|
+
// Plus a conservative fix-tier classifier so a partial remediation can never be
|
|
46
|
+
// labelled FULL: any workaround-only signal (rate-limit, docs, log-without-
|
|
47
|
+
// reject) is WORKAROUND; anything short of (sink signature changed + all callers
|
|
48
|
+
// routed + a discriminating test) is at most MITIGATION; only the full set with
|
|
49
|
+
// no partial-sanitization caveat earns FULL.
|
|
50
|
+
//
|
|
51
|
+
// Pure functions, no side effects, no throwing — safe to call from a command,
|
|
52
|
+
// a hook, or the MCP verify_fix path.
|
|
53
|
+
|
|
54
|
+
// Vague-assurance phrases that a real residual must never hide behind. Matched
|
|
55
|
+
// case-insensitively with word boundaries so "later" doesn't trip on
|
|
56
|
+
// "collateral" and "tbd" doesn't trip on a longer token.
|
|
57
|
+
const BANNED_RESIDUAL_PHRASES = Object.freeze([
|
|
58
|
+
'adequately handled',
|
|
59
|
+
'adequately handles',
|
|
60
|
+
'properly validated',
|
|
61
|
+
'properly handled',
|
|
62
|
+
'handled properly',
|
|
63
|
+
'handled safely',
|
|
64
|
+
'future work',
|
|
65
|
+
'more work needed',
|
|
66
|
+
'to be done',
|
|
67
|
+
'tbd',
|
|
68
|
+
'later',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// A citation shaped like `file:line` — one or more non-space, non-colon chars,
|
|
72
|
+
// a colon, then digits. Unanchored: it need only appear somewhere in the item.
|
|
73
|
+
const CITATION_RE = /[^\s:]+:\d+/;
|
|
74
|
+
|
|
75
|
+
// Verdicts that assert the finding is not real and therefore demand a citation.
|
|
76
|
+
// Compared after normalizing separators (`_`/space → `-`) and lowercasing, so
|
|
77
|
+
// FALSE_POSITIVE, false-positive, and "provably safe" all land here.
|
|
78
|
+
const FP_VERDICTS = Object.freeze(new Set(['false-positive', 'provably-safe', 'safe']));
|
|
79
|
+
|
|
80
|
+
function _escapeRe(s) {
|
|
81
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reject vague-assurance / hand-wave residual-risk prose.
|
|
86
|
+
*
|
|
87
|
+
* An empty or whitespace-only residual is ok — there is no residual to lie
|
|
88
|
+
* about. A non-empty residual is rejected when it contains any banned phrase;
|
|
89
|
+
* each match yields one violation naming the offending phrase.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} residualText
|
|
92
|
+
* @returns {{ ok: boolean, violations: string[] }}
|
|
93
|
+
*/
|
|
94
|
+
function checkResidualHonesty(residualText) {
|
|
95
|
+
const text = typeof residualText === 'string' ? residualText : '';
|
|
96
|
+
if (text.trim() === '') return { ok: true, violations: [] };
|
|
97
|
+
|
|
98
|
+
const violations = [];
|
|
99
|
+
for (const phrase of BANNED_RESIDUAL_PHRASES) {
|
|
100
|
+
const re = new RegExp(`\\b${_escapeRe(phrase)}\\b`, 'i');
|
|
101
|
+
if (re.test(text)) {
|
|
102
|
+
violations.push(`vague-assurance phrase: "${phrase}"`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { ok: violations.length === 0, violations };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function _isCitation(item) {
|
|
109
|
+
if (typeof item === 'string') return CITATION_RE.test(item);
|
|
110
|
+
if (item && typeof item === 'object' && typeof item.location === 'string') {
|
|
111
|
+
return CITATION_RE.test(item.location);
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _normalizeVerdict(verdict) {
|
|
117
|
+
return String(verdict).trim().toLowerCase().replace(/[_\s]+/g, '-');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Require a file:line citation behind a "this is not real" verdict.
|
|
122
|
+
*
|
|
123
|
+
* For a false-positive / provably-safe / safe verdict (case-insensitive; also
|
|
124
|
+
* accepts FALSE_POSITIVE), at least one evidence item must be a `file:line`
|
|
125
|
+
* citation — either a string matching /[^\s:]+:\d+/ or an object
|
|
126
|
+
* `{ location: "file:line" }`. Any other verdict passes unconditionally.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} verdict
|
|
129
|
+
* @param {Array|string|object} evidence
|
|
130
|
+
* @returns {{ ok: boolean, violations: string[] }}
|
|
131
|
+
*/
|
|
132
|
+
function requireCitedEvidence(verdict, evidence) {
|
|
133
|
+
if (typeof verdict !== 'string' || !FP_VERDICTS.has(_normalizeVerdict(verdict))) {
|
|
134
|
+
return { ok: true, violations: [] };
|
|
135
|
+
}
|
|
136
|
+
const items = Array.isArray(evidence)
|
|
137
|
+
? evidence
|
|
138
|
+
: evidence == null
|
|
139
|
+
? []
|
|
140
|
+
: [evidence];
|
|
141
|
+
if (items.some(_isCitation)) return { ok: true, violations: [] };
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
violations: ['false-positive/safe verdict requires a file:line citation'],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Classify a fix into FULL | MITIGATION | WORKAROUND, conservative-first.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} signals
|
|
152
|
+
* @param {boolean} signals.sinkSignatureChanged
|
|
153
|
+
* @param {boolean} signals.allCallersRouted
|
|
154
|
+
* @param {boolean} signals.testDiscriminates - a test that fails pre-fix, passes post-fix
|
|
155
|
+
* @param {boolean} [signals.rateLimitOnly]
|
|
156
|
+
* @param {boolean} [signals.docsOnly]
|
|
157
|
+
* @param {boolean} [signals.logOnlyNoReject]
|
|
158
|
+
* @param {boolean} [signals.partialSanitization]
|
|
159
|
+
* @returns {'FULL'|'MITIGATION'|'WORKAROUND'}
|
|
160
|
+
*/
|
|
161
|
+
function computeFixTier(signals) {
|
|
162
|
+
const s = signals && typeof signals === 'object' ? signals : {};
|
|
163
|
+
if (s.rateLimitOnly || s.docsOnly || s.logOnlyNoReject) return 'WORKAROUND';
|
|
164
|
+
const complete = s.sinkSignatureChanged && s.allCallersRouted && s.testDiscriminates;
|
|
165
|
+
if (s.partialSanitization || !complete) return 'MITIGATION';
|
|
166
|
+
return 'FULL';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compose the three gates for a single fix's output.
|
|
171
|
+
*
|
|
172
|
+
* ok = residual-honesty ok AND evidence-citation ok, further constrained by the
|
|
173
|
+
* tier/residual consistency invariant:
|
|
174
|
+
* - a FULL tier must NOT carry a residual (a full fix has nothing left);
|
|
175
|
+
* - a non-FULL tier MUST document a residual (say what's still open).
|
|
176
|
+
*
|
|
177
|
+
* @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
|
|
178
|
+
* @returns {{ ok: boolean, tier: string, violations: string[] }}
|
|
179
|
+
*/
|
|
180
|
+
function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
|
|
181
|
+
const tier = computeFixTier(signals);
|
|
182
|
+
const residualCheck = checkResidualHonesty(residual);
|
|
183
|
+
const evidenceCheck = requireCitedEvidence(verdict, evidence);
|
|
184
|
+
|
|
185
|
+
const violations = [...residualCheck.violations, ...evidenceCheck.violations];
|
|
186
|
+
let ok = residualCheck.ok && evidenceCheck.ok;
|
|
187
|
+
|
|
188
|
+
const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
|
|
189
|
+
if (tier === 'FULL' && !residualEmpty) {
|
|
190
|
+
violations.push('FULL tier cannot carry a residual');
|
|
191
|
+
ok = false;
|
|
192
|
+
}
|
|
193
|
+
if (tier !== 'FULL' && residualEmpty) {
|
|
194
|
+
violations.push('non-FULL tier must document a residual');
|
|
195
|
+
ok = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { ok, tier, violations };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERDICTS });
|
|
202
|
+
|
|
203
|
+
;// CONCATENATED MODULE: ./src/posture/fix-verify.js
|
|
204
|
+
// Closed-loop /fix verification (Sentinel-parity FR-L4-4, FR-L4-5).
|
|
205
|
+
//
|
|
206
|
+
// Given a candidate patch (the new file content + the finding stableId being
|
|
207
|
+
// fixed), verify it:
|
|
208
|
+
//
|
|
209
|
+
// 1. The original finding's stableId no longer fires on the patched file.
|
|
210
|
+
// 2. No new findings at severity ≥ medium were introduced by the patch.
|
|
211
|
+
// 3. The project's existing linter (when present) passes on the patched file.
|
|
212
|
+
//
|
|
213
|
+
// If any of those fail, the caller is expected to NOT apply the patch and
|
|
214
|
+
// instead surface a "fix plan" — a numbered list of steps the engineer can
|
|
215
|
+
// follow — rather than dump a broken patch on the user.
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
224
|
+
|
|
225
|
+
// Run a focused re-scan over just the patched file(s) using the in-memory
|
|
226
|
+
// engine. No filesystem write needed — we hand the new content in via the
|
|
227
|
+
// fileContents map.
|
|
228
|
+
async function verifyPatch({
|
|
229
|
+
scanRoot,
|
|
230
|
+
originalFindingStableId,
|
|
231
|
+
files, // { [relPath]: newContent }
|
|
232
|
+
depFileContents = {},
|
|
233
|
+
} = {}) {
|
|
234
|
+
if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
|
|
235
|
+
const fileContents = { ...files };
|
|
236
|
+
let scan;
|
|
237
|
+
try {
|
|
238
|
+
scan = await (0,engine/* runFullScan */.wW)({ fileContents, depFileContents, scanRoot }, () => {});
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return { ok: false, reason: 'rescan-failed', error: e.message };
|
|
241
|
+
}
|
|
242
|
+
const findings = (scan && scan.findings) || [];
|
|
243
|
+
const stillHasOriginal = !!originalFindingStableId &&
|
|
244
|
+
findings.some(f => f.stableId === originalFindingStableId);
|
|
245
|
+
if (stillHasOriginal) {
|
|
246
|
+
return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
|
|
247
|
+
}
|
|
248
|
+
const introducedHighOrAbove = findings.filter(f =>
|
|
249
|
+
(SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
|
|
250
|
+
// Don't count findings on lines outside the patched files — but our
|
|
251
|
+
// fileContents map IS the patched files, so every finding is in-scope.
|
|
252
|
+
return {
|
|
253
|
+
ok: introducedHighOrAbove.length === 0,
|
|
254
|
+
reason: introducedHighOrAbove.length === 0 ? 'verified' : 'introduced-new-findings',
|
|
255
|
+
introduced: introducedHighOrAbove.map(f => ({
|
|
256
|
+
vuln: f.vuln, file: f.file, line: f.line, severity: f.severity,
|
|
257
|
+
stableId: f.stableId,
|
|
258
|
+
})),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Detect which linter the project uses and run it on the patched files.
|
|
263
|
+
// Returns { ok, runner, output } or { ok: true, runner: 'none' } when no
|
|
264
|
+
// linter is configured (silent pass).
|
|
265
|
+
function runProjectLinter(scanRoot, filePaths) {
|
|
266
|
+
if (!scanRoot || !Array.isArray(filePaths) || filePaths.length === 0) {
|
|
267
|
+
return { ok: true, runner: 'none' };
|
|
268
|
+
}
|
|
269
|
+
const has = (p) => { try { return external_node_fs_.existsSync(external_node_path_.join(scanRoot, p)); } catch { return false; } };
|
|
270
|
+
// Pick the linter by config file present in the repo root.
|
|
271
|
+
const jsFiles = filePaths.filter(f => /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(f));
|
|
272
|
+
const pyFiles = filePaths.filter(f => /\.py$/i.test(f));
|
|
273
|
+
const goFiles = filePaths.filter(f => /\.go$/i.test(f));
|
|
274
|
+
const javaFiles = filePaths.filter(f => /\.java$/i.test(f));
|
|
275
|
+
|
|
276
|
+
if (jsFiles.length && (has('.eslintrc') || has('.eslintrc.json') || has('.eslintrc.js') || has('eslint.config.js') || has('eslint.config.mjs'))) {
|
|
277
|
+
return runLinter(scanRoot, 'eslint', ['--no-error-on-unmatched-pattern', ...jsFiles]);
|
|
278
|
+
}
|
|
279
|
+
if (pyFiles.length && (has('pyproject.toml') || has('ruff.toml') || has('.ruff.toml'))) {
|
|
280
|
+
return runLinter(scanRoot, 'ruff', ['check', ...pyFiles]);
|
|
281
|
+
}
|
|
282
|
+
if (pyFiles.length && has('.flake8')) {
|
|
283
|
+
return runLinter(scanRoot, 'flake8', pyFiles);
|
|
284
|
+
}
|
|
285
|
+
if (goFiles.length && (has('.golangci.yml') || has('.golangci.yaml'))) {
|
|
286
|
+
return runLinter(scanRoot, 'golangci-lint', ['run', ...goFiles]);
|
|
287
|
+
}
|
|
288
|
+
if (javaFiles.length && has('checkstyle.xml')) {
|
|
289
|
+
return runLinter(scanRoot, 'checkstyle', ['-c', 'checkstyle.xml', ...javaFiles]);
|
|
290
|
+
}
|
|
291
|
+
return { ok: true, runner: 'none' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function runLinter(cwd, cmd, args) {
|
|
295
|
+
let r;
|
|
296
|
+
try {
|
|
297
|
+
r = (0,external_node_child_process_.spawnSync)(cmd, args, { cwd, encoding: 'utf8', timeout: 60_000 });
|
|
298
|
+
} catch (e) {
|
|
299
|
+
return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing', error: e.message };
|
|
300
|
+
}
|
|
301
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
302
|
+
return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing' };
|
|
303
|
+
}
|
|
304
|
+
if (r.status === null) {
|
|
305
|
+
return { ok: false, runner: cmd, reason: 'timed-out', output: (r.stderr || r.stdout || '').slice(-2000) };
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
ok: r.status === 0,
|
|
309
|
+
runner: cmd,
|
|
310
|
+
exitCode: r.status,
|
|
311
|
+
output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Top-level verify: re-scan + lint. Returns the combined verdict + a
|
|
316
|
+
// human-readable summary string suitable for surfacing to the user.
|
|
317
|
+
// Addition #7 — deterministic honesty gates on fix output. When the caller
|
|
318
|
+
// supplies `fixMeta` ({ residual, verdict, evidence, signals }) — e.g. the
|
|
319
|
+
// security-fixer agent's residual-risk text + completeness signals — the fix's
|
|
320
|
+
// claims are checked mechanically (no hand-wave residual prose, a cited
|
|
321
|
+
// file:line for any FP/safe verdict, and a FULL/MITIGATION/WORKAROUND tier). A
|
|
322
|
+
// dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
|
|
323
|
+
// (the deterministic MCP write path, which has no claims to check) the honesty
|
|
324
|
+
// gate is skipped and behavior is unchanged.
|
|
325
|
+
async function verifyFix({
|
|
326
|
+
scanRoot,
|
|
327
|
+
originalFindingStableId,
|
|
328
|
+
files,
|
|
329
|
+
depFileContents,
|
|
330
|
+
fixMeta,
|
|
331
|
+
} = {}) {
|
|
332
|
+
const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
|
|
333
|
+
const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
|
|
334
|
+
let honesty = null;
|
|
335
|
+
if (fixMeta && typeof fixMeta === 'object') {
|
|
336
|
+
try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
|
|
337
|
+
}
|
|
338
|
+
const ok = rescan.ok && (lint.ok || lint.skipped) && (honesty ? honesty.ok : true);
|
|
339
|
+
const summary = [
|
|
340
|
+
`re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
|
|
341
|
+
`linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
|
|
342
|
+
: lint.skipped ? `${lint.runner} not installed`
|
|
343
|
+
: lint.ok ? `${lint.runner} PASS`
|
|
344
|
+
: `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
|
|
345
|
+
honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
|
|
346
|
+
].filter(Boolean).join('\n');
|
|
347
|
+
return { ok, rescan, lint, honesty, summary };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
/***/ })
|
|
352
|
+
|
|
353
|
+
};
|