@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +348 -40
  5. package/dist/113.index.js +6 -6
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/220.index.js +5 -3
  9. package/dist/238.index.js +4 -4
  10. package/dist/317.index.js +270 -0
  11. package/dist/384.index.js +1 -1
  12. package/dist/435.index.js +13 -7
  13. package/dist/499.index.js +86 -0
  14. package/dist/513.index.js +7 -3
  15. package/dist/526.index.js +6 -6
  16. package/dist/609.index.js +741 -0
  17. package/dist/637.index.js +1 -1
  18. package/dist/675.index.js +7 -5
  19. package/dist/839.index.js +4 -3
  20. package/dist/905.index.js +1173 -0
  21. package/dist/agentic-security.mjs +56 -56
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/dist/compliance-frameworks/ccpa.json +32 -0
  24. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  25. package/dist/compliance-frameworks/gdpr.json +45 -0
  26. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  27. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  28. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  29. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  30. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  31. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  32. package/package.json +14 -6
  33. package/src/badge.js +2 -1
  34. package/src/dataflow/cross-service-taint.js +2 -1
  35. package/src/dataflow/ifds-precise.js +6 -4
  36. package/src/dataflow/incremental.js +7 -5
  37. package/src/discovery/CLAUDE.md +48 -0
  38. package/src/discovery/confirm.js +47 -0
  39. package/src/discovery/disprove.js +79 -0
  40. package/src/discovery/hunter.js +116 -0
  41. package/src/discovery/index.js +331 -0
  42. package/src/discovery/judge.js +97 -0
  43. package/src/discovery/lenses.js +69 -0
  44. package/src/discovery/llm-invoke.js +120 -0
  45. package/src/discovery/memory.js +163 -0
  46. package/src/discovery/partition.js +92 -0
  47. package/src/engine.js +163 -7
  48. package/src/integrations/tickets.js +7 -6
  49. package/src/ir/ir-stats.js +1 -1
  50. package/src/ir/parser-cpp.js +1 -1
  51. package/src/leaderboard.js +3 -2
  52. package/src/llm-validator/consistency.js +6 -2
  53. package/src/llm-validator/index.js +30 -41
  54. package/src/llm-validator/providers.js +227 -0
  55. package/src/mcp/audit.js +2 -1
  56. package/src/mcp/tools.js +4 -3
  57. package/src/posture/CLAUDE.md +102 -1
  58. package/src/posture/accuracy-scorecard.js +59 -1
  59. package/src/posture/agents-memory.js +5 -3
  60. package/src/posture/auditor-walkthrough.js +9 -7
  61. package/src/posture/auth-posture-import.js +5 -4
  62. package/src/posture/autopilot.js +225 -0
  63. package/src/posture/comparison.js +181 -0
  64. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  65. package/src/posture/compliance-policy.js +7 -9
  66. package/src/posture/custom-rules.js +7 -5
  67. package/src/posture/cve-alert-daemon.js +6 -5
  68. package/src/posture/dep-add-guard.js +2 -1
  69. package/src/posture/deterministic.js +3 -2
  70. package/src/posture/evidence-bundle.js +246 -0
  71. package/src/posture/execution-proof.js +25 -1
  72. package/src/posture/exploitability-probability.js +2 -1
  73. package/src/posture/feature-flags.js +3 -2
  74. package/src/posture/findings-memory.js +3 -3
  75. package/src/posture/fix-history.js +5 -2
  76. package/src/posture/fix-metrics.js +5 -5
  77. package/src/posture/fix-plan.js +2 -1
  78. package/src/posture/fleet.js +0 -0
  79. package/src/posture/grader-calibration.js +3 -4
  80. package/src/posture/intent-context.js +2 -1
  81. package/src/posture/learning.js +4 -3
  82. package/src/posture/license-attributions.js +5 -7
  83. package/src/posture/license-graph.js +2 -1
  84. package/src/posture/license-policy.js +2 -1
  85. package/src/posture/logic-claims.js +266 -0
  86. package/src/posture/model-rescan.js +4 -3
  87. package/src/posture/network-policy-import.js +3 -2
  88. package/src/posture/poc-inprocess.js +404 -2
  89. package/src/posture/pqc-migration-plan.js +7 -5
  90. package/src/posture/pr-augment.js +8 -5
  91. package/src/posture/privacy-framework.js +262 -0
  92. package/src/posture/proof-artifact.js +101 -0
  93. package/src/posture/prove-findings.js +28 -4
  94. package/src/posture/risk-dollars.js +2 -2
  95. package/src/posture/router.js +5 -4
  96. package/src/posture/ruleset-version.js +2 -2
  97. package/src/posture/runtime-correlation.js +2 -1
  98. package/src/posture/sbom-diff.js +12 -3
  99. package/src/posture/sca-policy.js +7 -4
  100. package/src/posture/scan-checkpoint.js +15 -0
  101. package/src/posture/state-dir.js +34 -0
  102. package/src/posture/telemetry-ingest.js +4 -3
  103. package/src/posture/threat-model-auto.js +4 -1
  104. package/src/posture/threat-model-grounding.js +11 -1
  105. package/src/posture/time-to-fix.js +3 -2
  106. package/src/posture/triage-memory.js +3 -2
  107. package/src/posture/waf-ingest.js +6 -5
  108. package/src/posture/watch-mode.js +4 -3
  109. package/src/report/index.js +9 -0
  110. package/src/sast/code-injection-multilang.js +29 -0
  111. 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 = path.join(scanRoot, STATE, 'compliance');
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 = path.join(scanRoot, STATE, 'compliance', id, 'controls.json');
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, `path.join(scanRoot, STATE, '.../x')` never resolves and the
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
- : path.join(scanRoot, STATE, target);
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 = path.join(scanRoot, STATE, 'auditor-walkthroughs');
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
- '.agentic-security/auth-posture.json',
45
- '.agentic-security/auth-posture.yml',
46
- '.agentic-security/auth-posture.yaml',
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 = path.join(root, rel);
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
+ }