@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Cross-run discovery memory — PRD Phase 3 / C4.
|
|
2
|
+
//
|
|
3
|
+
// WHAT WAS MISSING
|
|
4
|
+
// ----------------
|
|
5
|
+
// `judge.js` dedupes a hunt against `last-scan.json` and the triage ledger, so
|
|
6
|
+
// it knows what the RULE ENGINE found and what a human dismissed. It has never
|
|
7
|
+
// known what a PREVIOUS HUNT found. Two consequences, both bad:
|
|
8
|
+
//
|
|
9
|
+
// 1. Every run re-proposes, re-confirms and re-refutes the same candidates.
|
|
10
|
+
// That is three LLM calls per candidate per run, spent to rediscover
|
|
11
|
+
// something already judged — the exact waste the Phase 0 budget exists to
|
|
12
|
+
// bound, being incurred deliberately.
|
|
13
|
+
// 2. A second run cannot be *additive*. Without a record of what was already
|
|
14
|
+
// examined, "hunt again" means "hunt the same thing again" rather than
|
|
15
|
+
// "hunt what we missed".
|
|
16
|
+
//
|
|
17
|
+
// This is that record. It turns a sequence of independent runs into a campaign.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IS AND IS NOT REMEMBERED
|
|
20
|
+
// -----------------------------
|
|
21
|
+
// Remembered: every candidate ever JUDGED, with the verdict and the run that
|
|
22
|
+
// produced it. Also every focus area ever hunted, so coverage can become a plan
|
|
23
|
+
// instead of a report.
|
|
24
|
+
//
|
|
25
|
+
// NOT remembered: refuted candidates as if they were settled forever. A
|
|
26
|
+
// refutation is a majority opinion from three prompts on one day, not a proof.
|
|
27
|
+
// It suppresses re-reporting, and `--forget-refuted` exists precisely because a
|
|
28
|
+
// verdict made by a weaker model, or before a sanitiser was removed, must be
|
|
29
|
+
// re-openable. A memory you cannot clear is a memory that eventually lies.
|
|
30
|
+
//
|
|
31
|
+
// THE PRECEDENT THIS FOLLOWS
|
|
32
|
+
// --------------------------
|
|
33
|
+
// `judge.js` deliberately suppresses only `fp` triage verdicts and re-reports
|
|
34
|
+
// `tp` ones, because a prior true positive that is still in the code is still a
|
|
35
|
+
// bug. The same asymmetry holds here: a candidate previously judged FRESH is
|
|
36
|
+
// re-reported (it was never fixed), while one previously REFUTED is held back
|
|
37
|
+
// until something changes.
|
|
38
|
+
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as path from 'node:path';
|
|
41
|
+
|
|
42
|
+
import { stateWritesEnabled } from '../posture/state-dir.js';
|
|
43
|
+
const MEMORY_SCHEMA = 'agentic-security/discovery-memory@1';
|
|
44
|
+
export const MEMORY_FILE = path.join('.agentic-security', 'discovery-memory.json');
|
|
45
|
+
|
|
46
|
+
function emptyMemory() {
|
|
47
|
+
return { schema: MEMORY_SCHEMA, runs: 0, candidates: {}, areas: {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A stable identity for a candidate across runs. */
|
|
51
|
+
export function memoryKey(candidate) {
|
|
52
|
+
// Location + family, matching judge.js's PRIMARY duplicate key. Deliberately
|
|
53
|
+
// NOT stableId: that is location-fuzzy by design and collides across distinct
|
|
54
|
+
// findings in one file, which is tolerable for a single scan's dedupe and
|
|
55
|
+
// corrosive when it accumulates across every run ever made.
|
|
56
|
+
const file = candidate?.file ?? '?';
|
|
57
|
+
const line = candidate?.line ?? '?';
|
|
58
|
+
const family = candidate?.family ?? candidate?.lens ?? '?';
|
|
59
|
+
return `${file}:${line}:${family}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read the memory. Anything unreadable or unrecognised yields an empty one. */
|
|
63
|
+
export function loadMemory(scanRoot) {
|
|
64
|
+
try {
|
|
65
|
+
const doc = JSON.parse(fs.readFileSync(path.join(scanRoot, MEMORY_FILE), 'utf8'));
|
|
66
|
+
if (doc?.schema !== MEMORY_SCHEMA) return emptyMemory();
|
|
67
|
+
return { ...emptyMemory(), ...doc };
|
|
68
|
+
} catch {
|
|
69
|
+
// A corrupt memory must degrade to "remember nothing", never to a crash and
|
|
70
|
+
// never to a partially-trusted record. Re-hunting is cheap next to acting on
|
|
71
|
+
// a half-read ledger.
|
|
72
|
+
return emptyMemory();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Persist. Failure is non-fatal — the run still produced its report. */
|
|
77
|
+
export function saveMemory(scanRoot, memory) {
|
|
78
|
+
try {
|
|
79
|
+
const p = path.join(scanRoot, MEMORY_FILE);
|
|
80
|
+
if (!stateWritesEnabled()) return;
|
|
81
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
82
|
+
fs.writeFileSync(p, JSON.stringify(memory, null, 2) + '\n');
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Should this candidate be held back because a previous run already judged it?
|
|
91
|
+
*
|
|
92
|
+
* Only a REFUTED verdict suppresses. Everything else — fresh, duplicate,
|
|
93
|
+
* suppressed-by-triage — is re-evaluated, because those states are about the
|
|
94
|
+
* code and the code may have changed.
|
|
95
|
+
*/
|
|
96
|
+
export function previouslyRefuted(memory, candidate) {
|
|
97
|
+
const rec = memory?.candidates?.[memoryKey(candidate)];
|
|
98
|
+
return Boolean(rec && rec.verdict === 'refuted');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fold this run's outcome into the memory. Returns a NEW memory object. */
|
|
102
|
+
export function rememberRun(memory, { fresh = [], refutedCandidates = [], areas = [], at }) {
|
|
103
|
+
const next = {
|
|
104
|
+
...emptyMemory(),
|
|
105
|
+
...memory,
|
|
106
|
+
candidates: { ...(memory?.candidates || {}) },
|
|
107
|
+
areas: { ...(memory?.areas || {}) },
|
|
108
|
+
};
|
|
109
|
+
next.runs = (memory?.runs || 0) + 1;
|
|
110
|
+
const stamp = at || new Date().toISOString();
|
|
111
|
+
|
|
112
|
+
for (const f of fresh) {
|
|
113
|
+
next.candidates[memoryKey(f)] = { verdict: 'fresh', run: next.runs, at: stamp };
|
|
114
|
+
}
|
|
115
|
+
for (const c of refutedCandidates) {
|
|
116
|
+
next.candidates[memoryKey(c)] = { verdict: 'refuted', run: next.runs, at: stamp };
|
|
117
|
+
}
|
|
118
|
+
for (const a of areas) {
|
|
119
|
+
const prev = next.areas[a.id] || { hunts: 0 };
|
|
120
|
+
next.areas[a.id] = {
|
|
121
|
+
label: a.label,
|
|
122
|
+
hunts: prev.hunts + (a.hunted ? 1 : 0),
|
|
123
|
+
lastRun: a.hunted ? next.runs : (prev.lastRun ?? null),
|
|
124
|
+
files: a.files ?? prev.files ?? null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Turn the memory into a PLAN: which areas have never been successfully hunted.
|
|
132
|
+
*
|
|
133
|
+
* This is the half that makes a second run additive rather than repetitive. A
|
|
134
|
+
* coverage report says what happened; this says what to do next.
|
|
135
|
+
*/
|
|
136
|
+
export function nextWavePlan(memory, areas) {
|
|
137
|
+
const unhunted = [];
|
|
138
|
+
const stale = [];
|
|
139
|
+
for (const a of areas || []) {
|
|
140
|
+
const rec = memory?.areas?.[a.id];
|
|
141
|
+
if (!rec || rec.hunts === 0) unhunted.push(a.label || a.id);
|
|
142
|
+
else if (rec.lastRun !== memory.runs) stale.push(a.label || a.id);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
unhunted,
|
|
146
|
+
stale,
|
|
147
|
+
// Stated as a sentence because this lands in a report a human reads, and
|
|
148
|
+
// "3 areas" without saying which ones is not actionable.
|
|
149
|
+
summary: unhunted.length
|
|
150
|
+
? `${unhunted.length} focus area(s) have NEVER been successfully hunted: ${unhunted.slice(0, 5).join(', ')}` +
|
|
151
|
+
(unhunted.length > 5 ? `, +${unhunted.length - 5} more` : '')
|
|
152
|
+
: 'every focus area has been hunted at least once',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Drop refuted verdicts so they can be re-examined. */
|
|
157
|
+
export function forgetRefuted(memory) {
|
|
158
|
+
const candidates = {};
|
|
159
|
+
for (const [k, v] of Object.entries(memory?.candidates || {})) {
|
|
160
|
+
if (v?.verdict !== 'refuted') candidates[k] = v;
|
|
161
|
+
}
|
|
162
|
+
return { ...emptyMemory(), ...memory, candidates };
|
|
163
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Split the codebase into disjoint focus areas so parallel hunters cannot
|
|
3
|
+
// converge on the same code.
|
|
4
|
+
//
|
|
5
|
+
// WHY THE CALL GRAPH AND NOT DIRECTORIES: a directory split hands one
|
|
6
|
+
// subsystem to several hunters whenever a feature spans folders, and hands
|
|
7
|
+
// unrelated code to one hunter whenever a folder is a grab bag. Weakly-
|
|
8
|
+
// connected components over call edges group code that actually talks to
|
|
9
|
+
// itself, which is the unit a hunter can reason about end to end.
|
|
10
|
+
//
|
|
11
|
+
// FILES, NOT FUNCTIONS, ARE THE ATOM. A hunter reads whole files. If two
|
|
12
|
+
// components share a file they are merged, otherwise the same source lands in
|
|
13
|
+
// two hunters' context and the convergence this module exists to prevent
|
|
14
|
+
// comes straight back.
|
|
15
|
+
import * as crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export function focusAreaId(files) {
|
|
18
|
+
const canon = [...new Set(files || [])].sort().join('\n');
|
|
19
|
+
return crypto.createHash('sha256').update(canon).digest('hex').slice(0, 12);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Union-find over file paths.
|
|
23
|
+
function makeDSU() {
|
|
24
|
+
const parent = new Map();
|
|
25
|
+
const find = (x) => {
|
|
26
|
+
if (!parent.has(x)) parent.set(x, x);
|
|
27
|
+
let r = x;
|
|
28
|
+
while (parent.get(r) !== r) r = parent.get(r);
|
|
29
|
+
while (parent.get(x) !== r) { const n = parent.get(x); parent.set(x, r); x = n; }
|
|
30
|
+
return r;
|
|
31
|
+
};
|
|
32
|
+
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); };
|
|
33
|
+
return { find, union };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function labelFor(files) {
|
|
37
|
+
if (files.length === 1) return files[0];
|
|
38
|
+
const parts = files[0].split('/');
|
|
39
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
40
|
+
const prefix = parts.slice(0, i).join('/') + '/';
|
|
41
|
+
if (files.every(f => f.startsWith(prefix))) return prefix;
|
|
42
|
+
}
|
|
43
|
+
return files[0] + ` (+${files.length - 1})`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function partitionCallGraph(callGraph, opts = {}) {
|
|
47
|
+
const fns = callGraph?.functions;
|
|
48
|
+
if (!fns || typeof fns.get !== 'function' || fns.size === 0) return [];
|
|
49
|
+
const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
|
|
50
|
+
|
|
51
|
+
const dsu = makeDSU();
|
|
52
|
+
for (const fn of fns.values()) if (fn?.file) dsu.find(fn.file);
|
|
53
|
+
for (const e of callGraph.edges || []) {
|
|
54
|
+
const a = fns.get(e?.caller)?.file;
|
|
55
|
+
const b = fns.get(e?.callee)?.file;
|
|
56
|
+
if (a && b) dsu.union(a, b);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const filesByRoot = new Map();
|
|
60
|
+
for (const fn of fns.values()) {
|
|
61
|
+
if (!fn?.file) continue;
|
|
62
|
+
const root = dsu.find(fn.file);
|
|
63
|
+
if (!filesByRoot.has(root)) filesByRoot.set(root, new Set());
|
|
64
|
+
filesByRoot.get(root).add(fn.file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fnsByFile = new Map();
|
|
68
|
+
for (const fn of fns.values()) {
|
|
69
|
+
if (!fn?.file) continue;
|
|
70
|
+
if (!fnsByFile.has(fn.file)) fnsByFile.set(fn.file, []);
|
|
71
|
+
fnsByFile.get(fn.file).push(fn.qid);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const build = (files, label) => {
|
|
75
|
+
const sorted = [...files].sort();
|
|
76
|
+
const functions = sorted.flatMap(f => (fnsByFile.get(f) || [])).sort();
|
|
77
|
+
return { id: focusAreaId(sorted), label: label ?? labelFor(sorted), files: sorted, functions, size: functions.length };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let areas = [...filesByRoot.values()].map(s => build(s));
|
|
81
|
+
// Deterministic ranking: biggest first, ties broken by id so two runs on the
|
|
82
|
+
// same graph produce the same order.
|
|
83
|
+
areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
|
|
84
|
+
|
|
85
|
+
if (areas.length > maxAreas) {
|
|
86
|
+
const kept = areas.slice(0, maxAreas - 1);
|
|
87
|
+
const tail = areas.slice(maxAreas - 1);
|
|
88
|
+
kept.push(build(tail.flatMap(a => a.files), 'misc'));
|
|
89
|
+
areas = kept;
|
|
90
|
+
}
|
|
91
|
+
return areas;
|
|
92
|
+
}
|
package/src/engine.js
CHANGED
|
@@ -71,6 +71,8 @@ import { scanWebhook } from './sast/webhook.js';
|
|
|
71
71
|
import { scanClientSide } from './sast/client-side.js';
|
|
72
72
|
import { scanPromptFirewall } from './sast/prompt-firewall.js';
|
|
73
73
|
import { scanLlmRedteam } from './posture/llm-redteam.js';
|
|
74
|
+
import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
|
|
75
|
+
import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
|
|
74
76
|
import { scanContainer } from './sca/container.js';
|
|
75
77
|
import { detectDepConfusion } from './sca/dep-confusion.js';
|
|
76
78
|
import { loadLicensePolicy, evaluateLicensePolicy } from './posture/license-policy.js';
|
|
@@ -142,6 +144,7 @@ import { scanIacReachability } from './posture/iac-reachability.js';
|
|
|
142
144
|
import { scanIamPolicies } from './posture/iam-policy.js';
|
|
143
145
|
import { scanContainerRuntime } from './posture/container-runtime.js';
|
|
144
146
|
import { scanBusinessLogic as scanBusinessLogicV2 } from './posture/business-logic.js';
|
|
147
|
+
import { ingestLogicClaims } from './posture/logic-claims.js';
|
|
145
148
|
import { annotateNarration } from './posture/flow-narration.js';
|
|
146
149
|
import { applyPathConstraints } from './posture/path-predicates.js';
|
|
147
150
|
// Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
|
|
@@ -584,7 +587,16 @@ const SANITIZER_PATTERNS=[{regex:/(?:escape|escapeHtml|htmlspecialchars|encodeUR
|
|
|
584
587
|
{regex:/(?:re\.escape|preg_quote|Regexp\.escape)\s*\(/g,type:"Regex Escaping"}];
|
|
585
588
|
const ROUTE_PATTERNS=[{regex:/(?:app|router)\s*\.\s*(get|post|put|patch|delete|all|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/g,fw:"Express",mI:1,pI:2},{regex:/@(?:app|blueprint|bp)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*methods\s*=\s*\[([^\]]+)\])?/g,fw:"Flask",pI:1,mtI:2},{regex:/path\s*\(\s*['"]([^'"]+)['"]/g,fw:"Django",pI:1},{regex:/@(?:app|router)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"FastAPI",mI:1,pI:2},{regex:/Route\s*::\s*(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Laravel",mI:1,pI:2},{regex:/router\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Koa/Express",mI:1,pI:2},{regex:/\[Http(Get|Post|Put|Delete|Patch)\s*\(\s*["']?([^"'\]]*)/g,fw:"ASP.NET",mI:1,pI:2},{regex:/['"`](\/api\/[a-zA-Z0-9\/:_\-{}]+)['"`]/g,fw:"API",pI:1}];
|
|
586
589
|
const AUTH_PATTERNS=[/(?:authenticate|isAuthenticated|requireAuth|passport\.authenticate|jwt\.verify|verifyToken|authMiddleware|checkAuth|protect|authorize)\s*[\(,]/gi,/(?:middleware|use)\s*\(\s*(?:auth|jwt|token|session)/gi,/(?:isAuthorized|expressJwt|security\.isAuthorized|denyAll)\s*[\(]/gi,/passport\.(?:authenticate|initialize|session)\s*\(/gi];
|
|
587
|
-
|
|
590
|
+
// `.agentic-security` is OUR OWN OUTPUT and must never be scanned input.
|
|
591
|
+
// (NON_MUTATING_SCAN_PRD S4.) A scan writes threat-model.json,
|
|
592
|
+
// exploit-bundles.json and scan-history.json into the tree it scanned, and those
|
|
593
|
+
// files contain CWE identifiers. Without this, the second scan of any directory
|
|
594
|
+
// reads the first scan's conclusions as source code — measured on the
|
|
595
|
+
// independent benchmark as 220 polluted trees and 544 state files carrying
|
|
596
|
+
// `CWE-` strings, which silently turned an accuracy measurement into the engine
|
|
597
|
+
// grading itself. Scanning our own state is never useful and is exactly how
|
|
598
|
+
// output becomes input.
|
|
599
|
+
const IGNORE_DIRS=new Set(["node_modules",".git","__pycache__","vendor","dist","build",".next","venv","env",".venv","target","bin","obj",".cache","coverage","bower_components","tests","test","__tests__","spec","mocks",".agentic-security"]);
|
|
588
600
|
const CODE_EXTS=new Set(["js","jsx","ts","tsx","mjs","cjs","py","rb","php","java","go","cs","rs","vue","svelte","html","htm","ejs","hbs","pug","erb","twig","graphql","gql","kt","scala","swift","dart","ex","exs","tf","tfvars","dockerfile","c","cc","cpp","cxx","h","hh","hpp","hxx","sol"]);
|
|
589
601
|
// Feat-2: IaC manifest filenames that aren't extension-based.
|
|
590
602
|
const IAC_FILENAMES = new Set(['Dockerfile', 'Containerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Chart.yaml']);
|
|
@@ -2293,6 +2305,80 @@ function _resetSuppressions(){ _suppressionLog.length = 0; }
|
|
|
2293
2305
|
function _pfrMetaOnly(ta){ if(!ta||typeof ta!=='object')return {}; const o={}; for(const k of Object.keys(ta)){ if(k==='findings'||k==='sources'||k==='sinks'||k==='sanitizers')continue; o[k]=ta[k]; } return o; }
|
|
2294
2306
|
function _getSuppressions(){ return [..._suppressionLog]; }
|
|
2295
2307
|
|
|
2308
|
+
// ── inline suppression pragma ───────────────────────────────────────────────
|
|
2309
|
+
//
|
|
2310
|
+
// `// agentic-security-ignore: <rule-id>` on the offending line. This is
|
|
2311
|
+
// documented in the root CLAUDE.md and `pr-comment.js` tells every reviewer to
|
|
2312
|
+
// use it — and until now NOTHING implemented it. A suppression mechanism that
|
|
2313
|
+
// silently does nothing is worse than not having one: a developer writes the
|
|
2314
|
+
// pragma, sees the finding again, and concludes the scanner is noisy rather
|
|
2315
|
+
// than that the pragma is dead.
|
|
2316
|
+
//
|
|
2317
|
+
// MATCHED ON THE LINE, AND ON THE RULE. A bare pragma with no rule id
|
|
2318
|
+
// suppresses every finding on that line; with an id it suppresses only findings
|
|
2319
|
+
// whose id, vuln or CWE contains it. Line-scoped rather than file-scoped on
|
|
2320
|
+
// purpose — a file-wide opt-out is how a whole module quietly leaves coverage.
|
|
2321
|
+
//
|
|
2322
|
+
// EVERY SUPPRESSION IS LOGGED to the same ledger custom rules use, so
|
|
2323
|
+
// `--include-suppressed` and the suppression summary show them. A suppression
|
|
2324
|
+
// nobody can see is indistinguishable from a finding that never fired.
|
|
2325
|
+
const _IGNORE_PRAGMA_RE = /(?:\/\/|#|\/\*|<!--)\s*agentic-security-ignore\s*:?\s*([^\n*]*?)\s*(?:\*\/|-->)?\s*$/;
|
|
2326
|
+
|
|
2327
|
+
function _pragmaOnLine(content, line){
|
|
2328
|
+
if (typeof content !== 'string' || !Number.isInteger(line) || line < 1) return null;
|
|
2329
|
+
const lines = content.split('\n');
|
|
2330
|
+
if (line > lines.length) return null;
|
|
2331
|
+
const m = lines[line - 1].match(_IGNORE_PRAGMA_RE);
|
|
2332
|
+
if (!m) return null;
|
|
2333
|
+
return { rule: (m[1] || '').trim() };
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
function _pragmaSuppresses(pragma, f){
|
|
2337
|
+
if (!pragma) return false;
|
|
2338
|
+
if (!pragma.rule) return true; // bare pragma: this line, any rule
|
|
2339
|
+
const want = pragma.rule.toLowerCase();
|
|
2340
|
+
const hay = `${f.id || ''} ${f.vuln || ''} ${f.cwe || ''} ${f.family || ''}`.toLowerCase();
|
|
2341
|
+
return hay.includes(want);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// Filter one findings array in place. Returns the number removed.
|
|
2345
|
+
// Filter one findings array in place against inline pragmas. Returns the count.
|
|
2346
|
+
//
|
|
2347
|
+
// CALLED TWICE per scan, deliberately: once after the cross-file passes and
|
|
2348
|
+
// again after deep-mode IR findings are appended. See both call sites.
|
|
2349
|
+
//
|
|
2350
|
+
// KNOWN LIMITATION — a finding with no integer `line` can never be suppressed.
|
|
2351
|
+
// The guard below skips it, because a line-scoped pragma has nothing to match
|
|
2352
|
+
// against. This is not hypothetical: `struct:` detectors emit findings with no
|
|
2353
|
+
// `line` property at all (the line survives only inside the id string, e.g.
|
|
2354
|
+
// `struct:app.js:22:Mass_Assignment`), so a false positive from one of those
|
|
2355
|
+
// cannot be silenced by a pragma and has to be fixed at the source instead.
|
|
2356
|
+
//
|
|
2357
|
+
// A file-scoped fallback was considered and REJECTED. Widening a line pragma to
|
|
2358
|
+
// a whole file would silently suppress findings the author never looked at, and
|
|
2359
|
+
// silent over-suppression in a security tool is worse than the gap it closes.
|
|
2360
|
+
// The real fix is for struct detectors to carry a `line`; that changes finding
|
|
2361
|
+
// output repo-wide, moves the self-scan baseline, and belongs in its own change.
|
|
2362
|
+
function _applyIgnorePragmas(arr, fc){
|
|
2363
|
+
if (!Array.isArray(arr)) return 0;
|
|
2364
|
+
let removed = 0;
|
|
2365
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
2366
|
+
const f = arr[i];
|
|
2367
|
+
const file = f && (f.file || f.sink?.file);
|
|
2368
|
+
const line = f && Number(f.line ?? f.sink?.line);
|
|
2369
|
+
if (!file || !Number.isInteger(line)) continue;
|
|
2370
|
+
const pragma = _pragmaOnLine(fc[file], line);
|
|
2371
|
+
if (!_pragmaSuppresses(pragma, f)) continue;
|
|
2372
|
+
_suppressionLog.push({
|
|
2373
|
+
vuln: f.vuln, file, line, snippet: f.snippet || '',
|
|
2374
|
+
reason: `inline pragma: agentic-security-ignore${pragma.rule ? ': ' + pragma.rule : ''}`,
|
|
2375
|
+
});
|
|
2376
|
+
arr.splice(i, 1);
|
|
2377
|
+
removed++;
|
|
2378
|
+
}
|
|
2379
|
+
return removed;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2296
2382
|
// FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
|
|
2297
2383
|
// at scan root. Mutates SOURCE/SINK/SANITIZER pattern arrays in place when active;
|
|
2298
2384
|
// snapshot lengths from the first call so subsequent scans can restore baseline.
|
|
@@ -2326,7 +2412,7 @@ async function _loadCustomRules(scanRoot){
|
|
|
2326
2412
|
_customIgnorePaths = [];
|
|
2327
2413
|
let raw = null, parsedObj = null;
|
|
2328
2414
|
for (const ext of ['rules.yml', 'rules.yaml', 'rules.json']) {
|
|
2329
|
-
const p =
|
|
2415
|
+
const p = statePath(scanRoot, ext);
|
|
2330
2416
|
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
2331
2417
|
try {
|
|
2332
2418
|
if (ext.endsWith('.json')) parsedObj = JSON.parse(raw);
|
|
@@ -7894,6 +7980,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7894
7980
|
// AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
|
|
7895
7981
|
if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
|
|
7896
7982
|
let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
|
|
7983
|
+
// Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
|
|
7984
|
+
// finding that exists BY THIS POINT — the pattern detectors, the cross-file
|
|
7985
|
+
// passes, and the logic and secrets buckets, which are the ones a developer
|
|
7986
|
+
// is most likely to want silenced on a specific line.
|
|
7987
|
+
//
|
|
7988
|
+
// It does NOT cover deep-mode IR findings: those are appended much further
|
|
7989
|
+
// down (search `finalFindings.push(...irFindings)`), so a second pass runs
|
|
7990
|
+
// there. For years this call carried a comment claiming it ran "after every
|
|
7991
|
+
// cross-file pass has appended", which was false for the deep path — a
|
|
7992
|
+
// correctly-formed pragma on the exact line of an ir-taint finding did
|
|
7993
|
+
// nothing, silently, in the mode the CLI actually uses.
|
|
7994
|
+
try{ _applyIgnorePragmas(finalFindings, fc); _applyIgnorePragmas(aLogic, fc); _applyIgnorePragmas(aSecrets, fc); }catch(_){}
|
|
7897
7995
|
// #1 — centralized SSRF/path guard recognition: drop CWE-918/CWE-22 findings
|
|
7898
7996
|
// on code hardened by a host allow/deny check or a path containment guard,
|
|
7899
7997
|
// regardless of which detector emitted them. Opt out: AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1.
|
|
@@ -7995,6 +8093,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7995
8093
|
// can tell "didn't run" from "ran cleanly." The array is surfaced as
|
|
7996
8094
|
// scan.annotatorErrors in the report; an empty array means clean.
|
|
7997
8095
|
let _executionProofSummary = null, _vulnHistory = null;
|
|
8096
|
+
let _logicClaims = null;
|
|
7998
8097
|
const _annotatorErrors = [];
|
|
7999
8098
|
const _runAnnotator = (phase, fn) => {
|
|
8000
8099
|
try { return fn(); }
|
|
@@ -8123,7 +8222,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8123
8222
|
if (r && r.piiFields) {
|
|
8124
8223
|
try {
|
|
8125
8224
|
const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
|
|
8126
|
-
|
|
8225
|
+
_safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
|
|
8127
8226
|
} catch (_) {}
|
|
8128
8227
|
}
|
|
8129
8228
|
});
|
|
@@ -8372,6 +8471,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8372
8471
|
f.validator_verdict = 'unvalidated';
|
|
8373
8472
|
}
|
|
8374
8473
|
finalFindings.push(...irFindings);
|
|
8474
|
+
// Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
|
|
8475
|
+
// findings land here, long after pass 1 ran, so without this an
|
|
8476
|
+
// `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
|
|
8477
|
+
// what the CLI uses outside CI and taint findings are the ones users most
|
|
8478
|
+
// want to silence, so the documented feature did nothing in the case that
|
|
8479
|
+
// mattered most.
|
|
8480
|
+
//
|
|
8481
|
+
// Re-running over the already-filtered array is safe and does not
|
|
8482
|
+
// double-log: pass 1's removals are gone from `finalFindings`, so only the
|
|
8483
|
+
// newly-appended IR findings can match here, and each suppression reaches
|
|
8484
|
+
// the ledger exactly once.
|
|
8485
|
+
try{ _applyIgnorePragmas(finalFindings, fc); }catch(_){}
|
|
8375
8486
|
// Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
|
|
8376
8487
|
try {
|
|
8377
8488
|
for (const sc of supplyChain) {
|
|
@@ -8465,6 +8576,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8465
8576
|
catch (e) { _annotatorErrors.push({ phase: '_enrichWithScorecard', err: String((e && e.message) || e) }); }
|
|
8466
8577
|
// 0.8.0 Feat-10: license policy
|
|
8467
8578
|
try{const lp=loadLicensePolicy(scanRoot);if(lp){const lv=evaluateLicensePolicy(annotatedComponents,lp);aLogic.push(...lv);}}catch(_){}
|
|
8579
|
+
// PRD Epic 6: business-logic claims from a reviewing agent, put through the
|
|
8580
|
+
// deterministic refutation lenses before they are allowed anywhere near the
|
|
8581
|
+
// report. A claim citing a file that was never scanned, misquoting the code,
|
|
8582
|
+
// or contradicted by the handler it names comes back quarantined. Refuted
|
|
8583
|
+
// claims are KEPT — the tier's contract is recall-preserving, same as
|
|
8584
|
+
// falsification's — so the reader can see what the reviewer said and why no
|
|
8585
|
+
// second party could corroborate it.
|
|
8586
|
+
try {
|
|
8587
|
+
if (scanRoot) {
|
|
8588
|
+
const raw = fs.readFileSync(statePath(scanRoot, 'logic-claims.json'), 'utf8');
|
|
8589
|
+
const parsed = JSON.parse(raw);
|
|
8590
|
+
const incoming = Array.isArray(parsed) ? parsed : (parsed && parsed.claims) || [];
|
|
8591
|
+
if (incoming.length) {
|
|
8592
|
+
const r = ingestLogicClaims(incoming, { fileContents: fc });
|
|
8593
|
+
_logicClaims = r.summary;
|
|
8594
|
+
aLogic.push(...r.claims);
|
|
8595
|
+
}
|
|
8596
|
+
}
|
|
8597
|
+
} catch(_) { /* absent or unreadable → the tier simply contributes nothing */ }
|
|
8468
8598
|
// Phase 4 / Item 7 of the SCA improvement plan: load sca-policy.yml and
|
|
8469
8599
|
// apply accept-risk / SLA / major-version-freeze rules. supplyChain
|
|
8470
8600
|
// findings get suppressed/tagged in place.
|
|
@@ -8651,6 +8781,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8651
8781
|
// Each is opt-in via env var. They produce machine-readable artifacts
|
|
8652
8782
|
// (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
|
|
8653
8783
|
// sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
|
|
8784
|
+
let _privacyFramework = null;
|
|
8654
8785
|
let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
|
|
8655
8786
|
_complianceReport = null, _exploitBundles = null, _pqcPlan = null,
|
|
8656
8787
|
_licenseGraph = null, _attributions = null, _taxonomySummary = null;
|
|
@@ -8718,6 +8849,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8718
8849
|
if (_pqcPlan) persistPqcPlan(scanRoot, _pqcPlan);
|
|
8719
8850
|
} catch (_) {}
|
|
8720
8851
|
}
|
|
8852
|
+
// NIST Privacy Framework 1.1 assessment.
|
|
8853
|
+
//
|
|
8854
|
+
// The ASSESSMENT is default-on and lands on `scan.privacyFramework`, like
|
|
8855
|
+
// every other posture artifact. Its FINDINGS are opt-in
|
|
8856
|
+
// (AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1), because appending them to
|
|
8857
|
+
// scan.findings would change every severity count, gate verdict and
|
|
8858
|
+
// baseline in every downstream consumer — a compliance opinion should not
|
|
8859
|
+
// silently become a build failure for projects that never asked for it.
|
|
8860
|
+
// Turn them on and they flow through triage and /fix like any finding.
|
|
8861
|
+
if (process.env.AGENTIC_SECURITY_NO_PRIVACY_FRAMEWORK !== '1') {
|
|
8862
|
+
try {
|
|
8863
|
+
_privacyFramework = assessPrivacyFramework(scanRoot, {
|
|
8864
|
+
findings: finalFindings, components: annotatedComponents,
|
|
8865
|
+
// filesScanned feeds the vacuous-satisfaction guard: a clean signal
|
|
8866
|
+
// from a run that read no files is not evidence of compliance.
|
|
8867
|
+
filesScanned: files.length,
|
|
8868
|
+
});
|
|
8869
|
+
if (_privacyFramework) persistPrivacyFramework(scanRoot, _privacyFramework);
|
|
8870
|
+
if (_privacyFramework && process.env.AGENTIC_SECURITY_PRIVACY_FRAMEWORK === '1') {
|
|
8871
|
+
finalFindings.push(..._privacyFramework.findings);
|
|
8872
|
+
}
|
|
8873
|
+
} catch (_) {}
|
|
8874
|
+
}
|
|
8721
8875
|
// Exploit bundles — per-family PoC + Jest + pytest + remediation for
|
|
8722
8876
|
// top-N critical/high findings.
|
|
8723
8877
|
if (process.env.AGENTIC_SECURITY_NO_EXPLOIT_BUNDLES !== '1') {
|
|
@@ -8726,9 +8880,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8726
8880
|
if (bundles.size) {
|
|
8727
8881
|
_exploitBundles = {};
|
|
8728
8882
|
for (const [id, b] of bundles) _exploitBundles[id] = b;
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8883
|
+
// Through the seam, so `--no-state` withholds the artifact. The
|
|
8884
|
+
// bundles stay on the scan result either way — a read-only scan must
|
|
8885
|
+
// report the same thing, it just must not leave it behind.
|
|
8886
|
+
_safeWriteState(_statePath(scanRoot, 'exploit-bundles.json'),
|
|
8887
|
+
JSON.stringify(_exploitBundles, null, 2));
|
|
8732
8888
|
}
|
|
8733
8889
|
} catch (_) {}
|
|
8734
8890
|
}
|
|
@@ -8762,7 +8918,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8762
8918
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
8763
8919
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
8764
8920
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
8765
|
-
return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,vulnHistory:_vulnHistory,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
8921
|
+
return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
8766
8922
|
|
|
8767
8923
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
8768
8924
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -19,17 +19,18 @@ import * as cp from 'node:child_process';
|
|
|
19
19
|
import { buildJiraIssue } from './index.js';
|
|
20
20
|
import { escapeMarkdown } from '../util/untrusted.js';
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
import { statePath } from '../posture/state-dir.js';
|
|
23
|
+
function _ticketsPath(scanRoot) {
|
|
24
|
+
return statePath(scanRoot, 'tickets.json');
|
|
24
25
|
}
|
|
25
26
|
export function readState(scanRoot) {
|
|
26
|
-
const fp =
|
|
27
|
+
const fp = _ticketsPath(scanRoot);
|
|
27
28
|
if (!fs.existsSync(fp)) return {};
|
|
28
29
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return {}; }
|
|
29
30
|
}
|
|
30
31
|
function writeState(scanRoot, state) {
|
|
31
|
-
fs.mkdirSync(path.dirname(
|
|
32
|
-
fs.writeFileSync(
|
|
32
|
+
fs.mkdirSync(path.dirname(_ticketsPath(scanRoot)), { recursive: true });
|
|
33
|
+
fs.writeFileSync(_ticketsPath(scanRoot), JSON.stringify(state, null, 2));
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function findingTitle(f) {
|
|
@@ -148,7 +149,7 @@ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
|
148
149
|
|
|
149
150
|
export async function syncTickets({ scanRoot, provider, severity = 'high', repo, teamId, dryRun = false }) {
|
|
150
151
|
const minRank = SEV_RANK[severity] ?? 3;
|
|
151
|
-
const lastScanPath =
|
|
152
|
+
const lastScanPath = statePath(scanRoot, 'last-scan.json');
|
|
152
153
|
if (!fs.existsSync(lastScanPath)) return { ok: false, error: 'no last-scan.json — run a scan first' };
|
|
153
154
|
const last = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
|
|
154
155
|
const allFindings = [...(last.findings || []), ...(last.secrets || []), ...(last.supplyChain || [])];
|
package/src/ir/ir-stats.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// language, how many of the files we claim to support did we actually turn
|
|
5
5
|
// into IR? That is the difference between recognising an extension and
|
|
6
6
|
// supporting a language, and it is the headline metric of the proof corpus
|
|
7
|
-
// bench (
|
|
7
|
+
// bench (the Proof Corpus PRD §5.4 (removed post-implementation)).
|
|
8
8
|
//
|
|
9
9
|
// Enable by setting AGENTIC_SECURITY_IR_STATS to an output path. The sidecar
|
|
10
10
|
// deliberately contains NO timestamp so two runs over identical input produce
|
package/src/ir/parser-cpp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// C / C++ IR frontend.
|
|
2
2
|
//
|
|
3
3
|
// Hand-rolled, following the parser-cs.js / parser-go.js template. See
|
|
4
|
-
//
|
|
4
|
+
// the Proof Corpus PRD §6.3 (removed post-implementation) for why this is not tree-sitter or libclang:
|
|
5
5
|
// the build excludes the tree-sitter deps from the bundle, and libclang would
|
|
6
6
|
// require native bindings plus a compile database we deliberately never build.
|
|
7
7
|
//
|
package/src/leaderboard.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as fs from 'node:fs';
|
|
|
27
27
|
import * as path from 'node:path';
|
|
28
28
|
import { summarizeForBadge } from './badge.js';
|
|
29
29
|
|
|
30
|
+
import { statePath } from './posture/state-dir.js';
|
|
30
31
|
// Grade thresholds. Critical findings dominate; high/medium contribute
|
|
31
32
|
// secondarily. These numbers are heuristic — calibrate against the
|
|
32
33
|
// public leaderboard corpus once data lands.
|
|
@@ -90,7 +91,7 @@ function _deltaTrend(history) {
|
|
|
90
91
|
*/
|
|
91
92
|
export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic-security.dev/badge' } = {}) {
|
|
92
93
|
if (!repo) throw new Error('leaderboardRowFor: repo slug is required');
|
|
93
|
-
const lastScanPath =
|
|
94
|
+
const lastScanPath = statePath(scanRoot || '.', 'last-scan.json');
|
|
94
95
|
let scan = null;
|
|
95
96
|
try { scan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8')); } catch {}
|
|
96
97
|
const summary = summarizeForBadge(scan);
|
|
@@ -98,7 +99,7 @@ export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic
|
|
|
98
99
|
const topCwe = _topCwe(scan);
|
|
99
100
|
|
|
100
101
|
// Optional scan history for the trend signal.
|
|
101
|
-
const historyPath =
|
|
102
|
+
const historyPath = statePath(scanRoot || '.', 'scan-history.jsonl');
|
|
102
103
|
let history = [];
|
|
103
104
|
if (fs.existsSync(historyPath)) {
|
|
104
105
|
try {
|
|
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
|
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
import { validateOne } from './index.js';
|
|
26
26
|
|
|
27
|
+
import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
|
|
27
28
|
// Build a deterministic test finding from the project's last-scan.json,
|
|
28
29
|
// or accept a hand-crafted one. Returns a clone safe to mutate per trial.
|
|
29
30
|
export function makeTrialFinding(template) {
|
|
@@ -60,9 +61,12 @@ export async function measureConsistency({
|
|
|
60
61
|
// If useCache is false, we want each trial to bypass the cache; we
|
|
61
62
|
// simulate by mutating the finding's file in a way the cache key
|
|
62
63
|
// hashes over. Simpler: clear the per-scanRoot cache before each trial.
|
|
63
|
-
|
|
64
|
+
// `stateWritesEnabled()` gates the DELETE as well as any write: removing
|
|
65
|
+
// files from the scanned tree is a mutation like any other, and under a
|
|
66
|
+
// read-only scan there is no cache to clear anyway. (PRD M1)
|
|
67
|
+
if (!useCache && scanRoot && stateWritesEnabled()) {
|
|
64
68
|
try {
|
|
65
|
-
const cacheDir =
|
|
69
|
+
const cacheDir = statePath(scanRoot, 'llm-cache');
|
|
66
70
|
if (fs.existsSync(cacheDir)) {
|
|
67
71
|
for (const e of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, e));
|
|
68
72
|
}
|