@clear-capabilities/agentic-security-scanner 0.133.0 → 0.136.2
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 +299 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +348 -40
- package/dist/113.index.js +6 -6
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +270 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +13 -7
- package/dist/499.index.js +86 -0
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +14 -6
- package/src/badge.js +2 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/incremental.js +7 -5
- package/src/discovery/CLAUDE.md +48 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +331 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +120 -0
- package/src/discovery/memory.js +163 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +163 -7
- package/src/integrations/tickets.js +7 -6
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +30 -41
- package/src/llm-validator/providers.js +227 -0
- package/src/mcp/audit.js +2 -1
- package/src/mcp/tools.js +4 -3
- package/src/posture/CLAUDE.md +102 -1
- package/src/posture/accuracy-scorecard.js +59 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/auditor-walkthrough.js +9 -7
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-policy.js +7 -9
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/evidence-bundle.js +246 -0
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/exploitability-probability.js +2 -1
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-rescan.js +4 -3
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +404 -2
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +28 -4
- package/src/posture/risk-dollars.js +2 -2
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +11 -1
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +9 -0
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sca/dep-confusion.js +2 -1
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// nist-csf-2 NIST Cybersecurity Framework 2.0
|
|
10
10
|
// nist-ai-600-1 NIST AI Risk Management Framework, GenAI profile
|
|
11
|
+
// nist-privacy-1-1 NIST Privacy Framework 1.1 (see posture/privacy-framework.js
|
|
12
|
+
// for the assessment + remediation layer over it)
|
|
11
13
|
// owasp-asvs-5 OWASP Application Security Verification Standard 5.0
|
|
12
14
|
// owasp-llm-top-10 OWASP Top 10 for LLM Applications 2025
|
|
13
15
|
// eu-ai-act EU AI Act (Regulation 2024/1689)
|
|
@@ -32,9 +34,8 @@
|
|
|
32
34
|
import * as fs from 'node:fs';
|
|
33
35
|
import * as path from 'node:path';
|
|
34
36
|
|
|
37
|
+
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
35
38
|
const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
|
|
36
|
-
const STATE = '.agentic-security';
|
|
37
|
-
|
|
38
39
|
function _readJson(fp) {
|
|
39
40
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
40
41
|
}
|
|
@@ -52,7 +53,7 @@ export function listFrameworks(scanRoot) {
|
|
|
52
53
|
}
|
|
53
54
|
} catch {}
|
|
54
55
|
if (scanRoot) {
|
|
55
|
-
const projDir =
|
|
56
|
+
const projDir = statePath(scanRoot, 'compliance');
|
|
56
57
|
if (fs.existsSync(projDir)) {
|
|
57
58
|
try {
|
|
58
59
|
for (const sub of fs.readdirSync(projDir)) {
|
|
@@ -73,7 +74,7 @@ export function listFrameworks(scanRoot) {
|
|
|
73
74
|
*/
|
|
74
75
|
export function loadFramework(scanRoot, id) {
|
|
75
76
|
if (scanRoot) {
|
|
76
|
-
const projFp =
|
|
77
|
+
const projFp = statePath(scanRoot, 'compliance', id, 'controls.json');
|
|
77
78
|
if (fs.existsSync(projFp)) return _readJson(projFp);
|
|
78
79
|
}
|
|
79
80
|
for (const fn of fs.readdirSync(BUNDLED_DIR)) {
|
|
@@ -164,11 +165,11 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
164
165
|
// A '.../' sentinel marks a source-relative artifact (project source,
|
|
165
166
|
// e.g. a hook or agent file) — resolve it against the scan root itself.
|
|
166
167
|
// Everything else is a runtime artifact under the STATE dir. Without
|
|
167
|
-
// this, `
|
|
168
|
+
// this, `statePath(scanRoot, '.../x')` never resolves and the
|
|
168
169
|
// control falsely reads "not present" for every project.
|
|
169
170
|
const resolved = !target ? null
|
|
170
171
|
: target.startsWith('.../') ? path.join(scanRoot, target.slice(4))
|
|
171
|
-
:
|
|
172
|
+
: statePath(scanRoot, target);
|
|
172
173
|
const label = target ? target.replace(/^\.\.\.\//, '') : '(unmapped)';
|
|
173
174
|
if (resolved && fs.existsSync(resolved)) {
|
|
174
175
|
obs.push(`✓ ${mod}: ${label} present.`);
|
|
@@ -251,7 +252,8 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
|
|
|
251
252
|
* Persist the walkthrough at .agentic-security/auditor-walkthroughs/<id>.md
|
|
252
253
|
*/
|
|
253
254
|
export function persistWalkthrough(scanRoot, fw, body) {
|
|
254
|
-
const dir =
|
|
255
|
+
const dir = statePath(scanRoot, 'auditor-walkthroughs');
|
|
256
|
+
if (!stateWritesEnabled()) return null;
|
|
255
257
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
256
258
|
const fp = path.join(dir, `${fw.id}.md`);
|
|
257
259
|
try { fs.writeFileSync(fp, body); } catch {}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import * as fs from 'node:fs';
|
|
28
28
|
import * as path from 'node:path';
|
|
29
29
|
|
|
30
|
+
import { statePath } from './state-dir.js';
|
|
30
31
|
const KNOWN_GOOD = new Set([
|
|
31
32
|
'session+csrf', 'session+admin', 'session+claim', 'session',
|
|
32
33
|
'jwt+verify', 'oauth+pkce', 'mtls',
|
|
@@ -41,15 +42,15 @@ const FAMILIES_GATEABLE_BY_AUTH = new Set([
|
|
|
41
42
|
]);
|
|
42
43
|
|
|
43
44
|
const CANDIDATE_PATHS = [
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
'
|
|
45
|
+
'auth-posture.json',
|
|
46
|
+
'auth-posture.yml',
|
|
47
|
+
'auth-posture.yaml',
|
|
47
48
|
];
|
|
48
49
|
|
|
49
50
|
export function loadAuthPosture(scanRoot) {
|
|
50
51
|
const root = scanRoot || process.cwd();
|
|
51
52
|
for (const rel of CANDIDATE_PATHS) {
|
|
52
|
-
const fp =
|
|
53
|
+
const fp = statePath(root, rel);
|
|
53
54
|
if (!fs.existsSync(fp)) continue;
|
|
54
55
|
try {
|
|
55
56
|
const text = fs.readFileSync(fp, 'utf8');
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// PRD Epic 4 — the autonomous loop.
|
|
2
|
+
//
|
|
3
|
+
// Every stage already existed as a separate command. What did not exist was the
|
|
4
|
+
// chain: scan → prove → validate → fix → RE-VERIFY, with state between stages
|
|
5
|
+
// and a gate before anything is written. This module is that chain and nothing
|
|
6
|
+
// else — it owns no analysis, and each stage is injected, so the orchestration
|
|
7
|
+
// can be tested without running an engine or calling a model.
|
|
8
|
+
//
|
|
9
|
+
// THE RULE THAT MAKES IT SAFE TO AUTOMATE: a fix is applied only if the PoC
|
|
10
|
+
// that proved the bug no longer fires AND the test suite still passes. That is
|
|
11
|
+
// `VERIFIED_FIXED`. Anything else is `NEEDS_REVIEW` and is NOT written. A loop
|
|
12
|
+
// that applies patches on the strength of "the scanner stopped complaining"
|
|
13
|
+
// automates the failure mode where a cosmetic edit silences a detector while
|
|
14
|
+
// the vulnerability remains — the re-scan proves the DETECTOR went quiet, and
|
|
15
|
+
// only re-running the exploit proves the hole is shut.
|
|
16
|
+
//
|
|
17
|
+
// GATES ARE ON BY DEFAULT. `apply` requires an explicit opt-in. Autonomy plus
|
|
18
|
+
// write access is the combination that turns a bad patch into a bad commit, and
|
|
19
|
+
// the default must be the one that cannot.
|
|
20
|
+
//
|
|
21
|
+
// RESUMABLE, AND HONEST ABOUT WHAT IT SKIPPED. Each stage records its outcome
|
|
22
|
+
// per finding; a resumed run replays completed stages from state rather than
|
|
23
|
+
// re-running them. A stage that was skipped is reported as skipped — never
|
|
24
|
+
// folded into "nothing to do", which is how an interrupted run reads as a clean
|
|
25
|
+
// one.
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
const SCHEMA = 'agentic-security/autopilot@1';
|
|
31
|
+
|
|
32
|
+
export const STAGES = Object.freeze(['scan', 'prove', 'validate', 'fix', 'reverify']);
|
|
33
|
+
|
|
34
|
+
// Outcomes a finding can end a run in. Closed set: a new outcome must be
|
|
35
|
+
// declared here, because the report groups on it and an unknown value would
|
|
36
|
+
// silently vanish from every count.
|
|
37
|
+
export const OUTCOMES = Object.freeze([
|
|
38
|
+
'VERIFIED_FIXED', // patch applied (or ready): PoC no longer fires, tests pass
|
|
39
|
+
'NEEDS_REVIEW', // a fix exists but did not re-verify — NOT applied
|
|
40
|
+
'NO_FIX', // nothing was synthesised
|
|
41
|
+
'UNPROVEN', // no PoC fired, so no fix was attempted at this tier
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export function loadAutopilotState(stateFile) {
|
|
45
|
+
try {
|
|
46
|
+
const j = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
47
|
+
if (j && j.schema === SCHEMA) return j;
|
|
48
|
+
} catch { /* absent -> clean */ }
|
|
49
|
+
return { schema: SCHEMA, stages: {}, findings: {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _save(stateFile, state) {
|
|
53
|
+
if (!stateFile) return;
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
56
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
57
|
+
} catch { /* state is an optimisation; losing it must not fail a run */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run the loop.
|
|
62
|
+
*
|
|
63
|
+
* Every stage is injected. `apply` defaults to false: the loop produces
|
|
64
|
+
* VERIFIED_FIXED patches and does not write them unless told to.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} stages
|
|
67
|
+
* scan() -> { findings: [] }
|
|
68
|
+
* prove(finding) -> { proofTier, proofEvidence, poc }
|
|
69
|
+
* validate(finding) -> { verdict: 'upheld'|'refuted'|'undecided' }
|
|
70
|
+
* synthesizeFix(finding) -> { patch: {file: content} } | null
|
|
71
|
+
* verifyFix(finding, patch) -> { ok, pocStillFires, testsPass, reason }
|
|
72
|
+
* applyFix(finding, patch) -> void (only called when apply === true)
|
|
73
|
+
*/
|
|
74
|
+
export async function runAutopilot({
|
|
75
|
+
stages = {}, stateFile = null, resume = true, apply = false,
|
|
76
|
+
onStage = () => {}, severities = ['critical', 'high'], maxFindings = Infinity,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const required = ['scan'];
|
|
79
|
+
for (const r of required) {
|
|
80
|
+
if (typeof stages[r] !== 'function') return { ok: false, reason: `no ${r} stage supplied` };
|
|
81
|
+
}
|
|
82
|
+
const state = resume && stateFile ? loadAutopilotState(stateFile) : { schema: SCHEMA, stages: {}, findings: {} };
|
|
83
|
+
const skipped = [];
|
|
84
|
+
|
|
85
|
+
// ── scan ────────────────────────────────────────────────────────────────
|
|
86
|
+
let findings;
|
|
87
|
+
if (resume && state.stages.scan) {
|
|
88
|
+
findings = state.stages.scan.findings || [];
|
|
89
|
+
skipped.push('scan');
|
|
90
|
+
} else {
|
|
91
|
+
const r = await stages.scan();
|
|
92
|
+
findings = r?.findings || [];
|
|
93
|
+
state.stages.scan = { findings };
|
|
94
|
+
_save(stateFile, state);
|
|
95
|
+
}
|
|
96
|
+
onStage({ stage: 'scan', count: findings.length });
|
|
97
|
+
|
|
98
|
+
// Only the severities asked for reach the expensive stages. Reported, so a
|
|
99
|
+
// reader can see what was in scope rather than assuming everything was.
|
|
100
|
+
const all = findings.filter(f => severities.includes(String(f.severity || '').toLowerCase()));
|
|
101
|
+
const outOfScope = findings.length - all.length;
|
|
102
|
+
// Every stage costs a sandboxed process and possibly a test-suite run, so the
|
|
103
|
+
// count is bounded. Reported, never silent: findings past the cap were NOT
|
|
104
|
+
// examined, and a run that quietly stopped at N would read as a run that
|
|
105
|
+
// found only N.
|
|
106
|
+
const inScope = all.slice(0, Number.isFinite(maxFindings) ? Math.max(0, maxFindings) : all.length);
|
|
107
|
+
const capped = all.length - inScope.length;
|
|
108
|
+
|
|
109
|
+
const results = [];
|
|
110
|
+
for (const f of inScope) {
|
|
111
|
+
const key = f.stableId || `${f.file}:${f.line}:${f.vuln}`;
|
|
112
|
+
const prior = resume ? state.findings[key] : null;
|
|
113
|
+
if (prior?.outcome) { results.push(prior); continue; }
|
|
114
|
+
|
|
115
|
+
const rec = { key, file: f.file, line: f.line, vuln: f.vuln, severity: f.severity };
|
|
116
|
+
|
|
117
|
+
// ── prove ─────────────────────────────────────────────────────────────
|
|
118
|
+
let proved = null;
|
|
119
|
+
if (typeof stages.prove === 'function') {
|
|
120
|
+
proved = await stages.prove(f).catch(e => ({ error: String(e?.message || e) }));
|
|
121
|
+
rec.proofTier = proved?.proofTier || null;
|
|
122
|
+
}
|
|
123
|
+
const isProven = rec.proofTier === 'execution-proven';
|
|
124
|
+
|
|
125
|
+
// ── validate ──────────────────────────────────────────────────────────
|
|
126
|
+
if (typeof stages.validate === 'function') {
|
|
127
|
+
const v = await stages.validate(f).catch(() => null);
|
|
128
|
+
rec.validation = v?.verdict || null;
|
|
129
|
+
// A refuted finding is NOT dropped — same recall-preserving rule the rest
|
|
130
|
+
// of the engine follows. It is recorded and skipped for fixing.
|
|
131
|
+
if (rec.validation === 'refuted') {
|
|
132
|
+
rec.outcome = 'NEEDS_REVIEW';
|
|
133
|
+
rec.reason = 'an independent verifier refuted this finding; not fixed automatically';
|
|
134
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
135
|
+
onStage({ stage: 'validate', key, verdict: 'refuted' });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fixing is gated on proof at this tier. An unproven finding may still be
|
|
141
|
+
// real — it is reported UNPROVEN, not dismissed.
|
|
142
|
+
if (!isProven) {
|
|
143
|
+
rec.outcome = 'UNPROVEN';
|
|
144
|
+
rec.reason = proved?.proofEvidence?.reason || 'no proof-of-concept demonstrated this finding';
|
|
145
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── fix ───────────────────────────────────────────────────────────────
|
|
150
|
+
let patch = null;
|
|
151
|
+
if (typeof stages.synthesizeFix === 'function') {
|
|
152
|
+
patch = await stages.synthesizeFix(f).catch(() => null);
|
|
153
|
+
}
|
|
154
|
+
if (!patch || !patch.patch) {
|
|
155
|
+
rec.outcome = 'NO_FIX';
|
|
156
|
+
rec.reason = 'no patch was synthesised';
|
|
157
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── re-verify ─────────────────────────────────────────────────────────
|
|
162
|
+
// The gate. Without a verifier we cannot claim VERIFIED_FIXED, so we do
|
|
163
|
+
// not — an unverifiable patch is NEEDS_REVIEW even if it looks right.
|
|
164
|
+
if (typeof stages.verifyFix !== 'function') {
|
|
165
|
+
rec.outcome = 'NEEDS_REVIEW';
|
|
166
|
+
rec.reason = 'no verifyFix stage supplied — a patch that cannot be re-verified is never applied';
|
|
167
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const v = await stages.verifyFix(f, patch).catch(e => ({ ok: false, reason: String(e?.message || e) }));
|
|
171
|
+
rec.pocStillFires = v?.pocStillFires === true;
|
|
172
|
+
rec.testsPass = v?.testsPass !== false;
|
|
173
|
+
|
|
174
|
+
if (v?.ok && !rec.pocStillFires && rec.testsPass) {
|
|
175
|
+
rec.outcome = 'VERIFIED_FIXED';
|
|
176
|
+
if (apply && typeof stages.applyFix === 'function') {
|
|
177
|
+
try { await stages.applyFix(f, patch); rec.applied = true; }
|
|
178
|
+
catch (e) { rec.applied = false; rec.outcome = 'NEEDS_REVIEW'; rec.reason = `apply failed: ${e.message}`; }
|
|
179
|
+
} else {
|
|
180
|
+
rec.applied = false;
|
|
181
|
+
rec.reason = apply ? 'no applyFix stage supplied' : 'gates on: patch is ready but was not written';
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
rec.outcome = 'NEEDS_REVIEW';
|
|
185
|
+
rec.reason = rec.pocStillFires
|
|
186
|
+
? 'the proof-of-concept still fires against the patch — the vulnerability is not fixed'
|
|
187
|
+
: (!rec.testsPass ? 'the project test suite fails with this patch'
|
|
188
|
+
: (v?.reason || 'the patch did not re-verify'));
|
|
189
|
+
}
|
|
190
|
+
results.push(rec);
|
|
191
|
+
state.findings[key] = rec;
|
|
192
|
+
_save(stateFile, state);
|
|
193
|
+
onStage({ stage: 'reverify', key, outcome: rec.outcome });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { ok: true, results, skipped, outOfScope, capped, summary: summarizeAutopilot(results, outOfScope, capped) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function summarizeAutopilot(results, outOfScope = 0, capped = 0) {
|
|
200
|
+
const byOutcome = Object.fromEntries(OUTCOMES.map(o => [o, 0]));
|
|
201
|
+
for (const r of results) if (r.outcome in byOutcome) byOutcome[r.outcome]++;
|
|
202
|
+
return {
|
|
203
|
+
considered: results.length,
|
|
204
|
+
outOfScope,
|
|
205
|
+
capped,
|
|
206
|
+
byOutcome,
|
|
207
|
+
applied: results.filter(r => r.applied).length,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One line. Leads with what was NOT fixed, because that is the actionable part. */
|
|
212
|
+
export function renderAutopilotSummary(s) {
|
|
213
|
+
if (!s) return null;
|
|
214
|
+
const b = s.byOutcome;
|
|
215
|
+
const bits = [`${s.considered} finding(s) in scope`];
|
|
216
|
+
if (b.VERIFIED_FIXED) bits.push(`${b.VERIFIED_FIXED} VERIFIED_FIXED (${s.applied} applied)`);
|
|
217
|
+
if (b.NEEDS_REVIEW) bits.push(`${b.NEEDS_REVIEW} NEEDS_REVIEW — not applied`);
|
|
218
|
+
if (b.NO_FIX) bits.push(`${b.NO_FIX} with no patch`);
|
|
219
|
+
if (b.UNPROVEN) bits.push(`${b.UNPROVEN} unproven (not dismissed — no PoC fired)`);
|
|
220
|
+
if (s.outOfScope) bits.push(`${s.outOfScope} below the severity floor and not considered`);
|
|
221
|
+
if (s.capped) bits.push(`${s.capped} in scope but NOT examined (per-run cap) — unexamined, not clean`);
|
|
222
|
+
return bits.join('; ') + '.';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const _internals = { SCHEMA };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// PRD Epic 7.2 — head-to-head comparison scoring.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS SHIPS WITHOUT A SINGLE PARTICIPANT NAME IN IT. A benchmark whose
|
|
4
|
+
// competitors are hard-coded by the vendor being measured is marketing with a
|
|
5
|
+
// methodology section. This repository publishes the HARNESS and the answer
|
|
6
|
+
// key; the operator supplies the participants. Nothing here — no constant, no
|
|
7
|
+
// default config, no example — names any tool, and the report renders whatever
|
|
8
|
+
// labels the operator chose. That is not a limitation working around a rule; a
|
|
9
|
+
// comparison anyone can re-run against tools of their own choosing is the only
|
|
10
|
+
// kind worth publishing, and the only kind a reader has reason to believe.
|
|
11
|
+
//
|
|
12
|
+
// THE ONE FAILURE MODE THIS MODULE EXISTS TO PREVENT. Two tools scored over
|
|
13
|
+
// different subsets of a corpus are not comparable, and the difference is
|
|
14
|
+
// invisible in the output: a tool that crashed on the 40 hardest entries and
|
|
15
|
+
// was scored over the remaining 170 looks like it beat one that completed all
|
|
16
|
+
// 210. So every rate here is computed over the INTERSECTION of entries every
|
|
17
|
+
// participant completed, that intersection is reported alongside each
|
|
18
|
+
// participant's own completion count, and a participant that completed nothing
|
|
19
|
+
// in common with the others is refused rather than shown with an empty score.
|
|
20
|
+
//
|
|
21
|
+
// MATCHING IS CWE-ONLY, ON PURPOSE. Our own corpus entries carry a `vuln_match`
|
|
22
|
+
// phrase in this engine's wording; scoring an external tool against our
|
|
23
|
+
// phrasing would score it on vocabulary. CWE is the one identifier every
|
|
24
|
+
// participant can be expected to emit, so it is the only key used, and it is
|
|
25
|
+
// applied identically to every participant including this engine. A participant
|
|
26
|
+
// that reports no CWE at all is scored as reporting nothing — stated in the
|
|
27
|
+
// output rather than silently counted as a miss.
|
|
28
|
+
|
|
29
|
+
/** Verdict for one participant on one corpus entry. */
|
|
30
|
+
export const OUTCOMES = Object.freeze(['tp', 'fn', 'fp', 'tn']);
|
|
31
|
+
|
|
32
|
+
function _cweSet(findings) {
|
|
33
|
+
const s = new Set();
|
|
34
|
+
for (const f of findings || []) {
|
|
35
|
+
const raw = f && (f.cwe ?? f.CWE ?? f.ruleId ?? '');
|
|
36
|
+
for (const m of String(raw).matchAll(/CWE[-_ ]?(\d+)/gi)) s.add(`CWE-${m[1]}`);
|
|
37
|
+
}
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Score one participant over the entries it completed.
|
|
43
|
+
*
|
|
44
|
+
* @param {object[]} entries [{id, cwe}]
|
|
45
|
+
* @param {object} results entryId -> {pre: findings[], post: findings[]} | {error}
|
|
46
|
+
*/
|
|
47
|
+
export function scoreParticipant(entries, results) {
|
|
48
|
+
const per = new Map();
|
|
49
|
+
let noCwe = 0;
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const r = results?.[e.id];
|
|
52
|
+
if (!r || r.error || !Array.isArray(r.pre) || !Array.isArray(r.post)) continue;
|
|
53
|
+
|
|
54
|
+
const want = String(e.cwe || '').toUpperCase();
|
|
55
|
+
const pre = _cweSet(r.pre);
|
|
56
|
+
const post = _cweSet(r.post);
|
|
57
|
+
if (!pre.size && (r.pre || []).length) noCwe++;
|
|
58
|
+
|
|
59
|
+
// pre/ is the vulnerable tree: reporting the CWE is a true positive.
|
|
60
|
+
// post/ is the fixed tree: reporting it again is a false positive.
|
|
61
|
+
per.set(e.id, {
|
|
62
|
+
detected: pre.has(want),
|
|
63
|
+
falsePositive: post.has(want),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return { per, completed: per.size, noCwe };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _rates(tp, fn, fp, tn) {
|
|
70
|
+
const precision = tp + fp > 0 ? tp / (tp + fp) : null;
|
|
71
|
+
const recall = tp + fn > 0 ? tp / (tp + fn) : null;
|
|
72
|
+
const f1 = precision !== null && recall !== null && precision + recall > 0
|
|
73
|
+
? (2 * precision * recall) / (precision + recall) : null;
|
|
74
|
+
return { tp, fn, fp, tn, precision, recall, f1 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compare every participant over the entries ALL of them completed.
|
|
79
|
+
*
|
|
80
|
+
* @param {object[]} entries [{id, cwe}]
|
|
81
|
+
* @param {object[]} participants [{id, results}]
|
|
82
|
+
* @returns {object} {ok, reason?, intersection, scores[], skippedEntries[]}
|
|
83
|
+
*/
|
|
84
|
+
export function compareParticipants(entries, participants) {
|
|
85
|
+
if (!Array.isArray(entries) || !entries.length) return { ok: false, reason: 'no corpus entries' };
|
|
86
|
+
if (!Array.isArray(participants) || participants.length < 2) {
|
|
87
|
+
return { ok: false, reason: 'a comparison needs at least two participants' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const scored = participants.map((p) => ({ ...p, ...scoreParticipant(entries, p.results) }));
|
|
91
|
+
|
|
92
|
+
// The intersection. This is the whole point: rates over anything else are
|
|
93
|
+
// rates over different exams.
|
|
94
|
+
let common = null;
|
|
95
|
+
for (const s of scored) {
|
|
96
|
+
const ids = new Set(s.per.keys());
|
|
97
|
+
common = common === null ? ids : new Set([...common].filter((id) => ids.has(id)));
|
|
98
|
+
}
|
|
99
|
+
if (!common || common.size === 0) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: 'no corpus entry was completed by every participant — there is nothing they can be compared on',
|
|
103
|
+
completion: Object.fromEntries(scored.map((s) => [s.id, s.completed])),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const scores = scored.map((s) => {
|
|
108
|
+
let tp = 0, fn = 0, fp = 0, tn = 0;
|
|
109
|
+
for (const id of common) {
|
|
110
|
+
const v = s.per.get(id);
|
|
111
|
+
if (v.detected) tp++; else fn++;
|
|
112
|
+
if (v.falsePositive) fp++; else tn++;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
id: s.id,
|
|
116
|
+
...(_rates(tp, fn, fp, tn)),
|
|
117
|
+
completed: s.completed,
|
|
118
|
+
notCompleted: entries.length - s.completed,
|
|
119
|
+
noCwe: s.noCwe,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
corpusSize: entries.length,
|
|
126
|
+
intersection: common.size,
|
|
127
|
+
// Named so a reader can check the exam rather than trust the grade.
|
|
128
|
+
scoredEntryIds: [...common].sort(),
|
|
129
|
+
scores: scores.sort((a, b) => (b.f1 ?? -1) - (a.f1 ?? -1)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pct = (v) => (v === null || v === undefined ? 'n/a' : `${(v * 100).toFixed(1)}%`);
|
|
134
|
+
|
|
135
|
+
/** Markdown. Discloses the exam before the grades, never after. */
|
|
136
|
+
export function renderComparison(cmp) {
|
|
137
|
+
if (!cmp || !cmp.ok) {
|
|
138
|
+
return `# Comparison\n\nNOT SCORED: ${cmp?.reason || 'unknown reason'}\n`;
|
|
139
|
+
}
|
|
140
|
+
const out = [];
|
|
141
|
+
out.push('# Head-to-head comparison');
|
|
142
|
+
out.push('');
|
|
143
|
+
out.push(`Scored over the **${cmp.intersection} of ${cmp.corpusSize}** corpus entries that *every*`);
|
|
144
|
+
out.push('participant completed. Entries any participant failed to complete are excluded from');
|
|
145
|
+
out.push('every score, including this engine\'s — a rate computed over a different subset is a');
|
|
146
|
+
out.push('rate for a different exam.');
|
|
147
|
+
out.push('');
|
|
148
|
+
out.push('Matching is by CWE only. Participants report findings in their own vocabulary, so');
|
|
149
|
+
out.push('scoring against any one tool\'s phrasing would measure vocabulary rather than');
|
|
150
|
+
out.push('detection. The same rule is applied to every participant.');
|
|
151
|
+
out.push('');
|
|
152
|
+
out.push('| Participant | F1 | Precision | Recall | TP | FN | FP | Corpus completed |');
|
|
153
|
+
out.push('|---|---|---|---|---|---|---|---|');
|
|
154
|
+
for (const s of cmp.scores) {
|
|
155
|
+
out.push(`| ${s.id} | ${pct(s.f1)} | ${pct(s.precision)} | ${pct(s.recall)} | ${s.tp} | ${s.fn} | ${s.fp} | ${s.completed}/${cmp.corpusSize} |`);
|
|
156
|
+
}
|
|
157
|
+
out.push('');
|
|
158
|
+
const incomplete = cmp.scores.filter((s) => s.notCompleted > 0);
|
|
159
|
+
if (incomplete.length) {
|
|
160
|
+
out.push('## Entries not completed');
|
|
161
|
+
out.push('');
|
|
162
|
+
out.push('A participant that could not run on an entry is UNSCORED there, never scored as a');
|
|
163
|
+
out.push('miss. Counting a crash as a false negative would penalise a tool for a harness');
|
|
164
|
+
out.push('problem; counting it as a pass would reward it for one.');
|
|
165
|
+
out.push('');
|
|
166
|
+
for (const s of incomplete) out.push(`- **${s.id}** — ${s.notCompleted} entr(y/ies) not completed`);
|
|
167
|
+
out.push('');
|
|
168
|
+
}
|
|
169
|
+
const noCwe = cmp.scores.filter((s) => s.noCwe > 0);
|
|
170
|
+
if (noCwe.length) {
|
|
171
|
+
out.push('## Findings carrying no CWE');
|
|
172
|
+
out.push('');
|
|
173
|
+
for (const s of noCwe) {
|
|
174
|
+
out.push(`- **${s.id}** — ${s.noCwe} entr(y/ies) where findings were reported but none carried a CWE,`);
|
|
175
|
+
out.push(' so they could not be matched. This depresses that participant\'s recall for a');
|
|
176
|
+
out.push(' reporting-format reason rather than a detection one.');
|
|
177
|
+
}
|
|
178
|
+
out.push('');
|
|
179
|
+
}
|
|
180
|
+
return out.join('\n') + '\n';
|
|
181
|
+
}
|