@clear-capabilities/agentic-security-scanner 0.134.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 +196 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +269 -44
- 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/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- 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 +14 -14
- 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 +9 -5
- 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 +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +45 -8
- 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 +1 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/tools.js +4 -3
- package/src/posture/CLAUDE.md +26 -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/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/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/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/model-rescan.js +4 -3
- package/src/posture/network-policy-import.js +3 -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/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/sast/code-injection-multilang.js +29 -0
- package/src/sca/dep-confusion.js +2 -1
|
@@ -13,7 +13,11 @@ export async function defaultLlmInvoke(prompt, opts = {}) {
|
|
|
13
13
|
// variable they set. Reaching it is this module's entire purpose; no
|
|
14
14
|
// request-controlled input exists anywhere on this path, and an operator who
|
|
15
15
|
// can set this variable can already run code.
|
|
16
|
-
|
|
16
|
+
// `opts.endpoint` lets the consensus caller target one specific provider.
|
|
17
|
+
// Absent, it falls back to the single configured endpoint — so the ordinary
|
|
18
|
+
// single-model path is byte-identical to what it was before consensus existed.
|
|
19
|
+
const endpoint = opts.endpoint || process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
|
|
20
|
+
const res = await fetch(endpoint, { // agentic-security-ignore: CWE-918
|
|
17
21
|
method: 'POST',
|
|
18
22
|
headers: { 'content-type': 'application/json' },
|
|
19
23
|
body: JSON.stringify({ prompt }),
|
|
@@ -24,8 +28,93 @@ export async function defaultLlmInvoke(prompt, opts = {}) {
|
|
|
24
28
|
return typeof body === 'string' ? body : (body?.text ?? JSON.stringify(body));
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
// --- PRD Phase 3 / C2: multi-model consensus --------------------------------
|
|
32
|
+
//
|
|
33
|
+
// One model's opinion is one model's opinion. Asking several INDEPENDENT
|
|
34
|
+
// endpoints the same question and keeping only what a majority agree on
|
|
35
|
+
// collapses the idiosyncratic failures of any single one — a model that
|
|
36
|
+
// hallucinates a sink, or that is simply having a bad day on a prompt shape.
|
|
37
|
+
//
|
|
38
|
+
// WHY IT LIVES HERE AND NOWHERE ELSE. Every LLM call in the discovery layer
|
|
39
|
+
// already funnels through `resolveLlmInvoke`. Consensus is therefore a property
|
|
40
|
+
// of the seam, not of the hunter or the panel, and adding a provider cannot
|
|
41
|
+
// require touching either.
|
|
42
|
+
//
|
|
43
|
+
// WHAT CONSENSUS DOES AND DOES NOT MEAN. It reduces variance. It does NOT make
|
|
44
|
+
// the answer true — three models can agree and all be wrong, which is precisely
|
|
45
|
+
// why the deterministic confirmation gate still runs afterwards and still sets
|
|
46
|
+
// severity. Consensus is a noise filter in front of the real check, never a
|
|
47
|
+
// replacement for it.
|
|
48
|
+
//
|
|
49
|
+
// A provider that errors is EXCLUDED from the vote, not counted as dissent —
|
|
50
|
+
// the same rule `disprove.js` applies to its voters, for the same reason: an
|
|
51
|
+
// outage must never look like disagreement.
|
|
52
|
+
// Internal: read by resolveLlmInvoke below. Exporting it with no external
|
|
53
|
+
// caller is shipped dead code by the dead-module guard's definition.
|
|
54
|
+
const DEFAULT_CONSENSUS_ENV = 'AGENTIC_SECURITY_LLM_ENDPOINTS';
|
|
55
|
+
|
|
56
|
+
/** Split a comma-separated endpoint list into distinct URLs. */
|
|
57
|
+
export function parseEndpoints(raw) {
|
|
58
|
+
return String(raw || '')
|
|
59
|
+
.split(',')
|
|
60
|
+
.map(s => s.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.filter((v, i, a) => a.indexOf(v) === i); // duplicates would fake agreement
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Combine N responses into one, keeping the most common answer.
|
|
67
|
+
*
|
|
68
|
+
* Ties are resolved towards the FIRST endpoint listed, deterministically, rather
|
|
69
|
+
* than arbitrarily — a caller ordering their endpoints by trust should get the
|
|
70
|
+
* behaviour that ordering implies, and a random tie-break would make the whole
|
|
71
|
+
* pipeline non-reproducible.
|
|
72
|
+
*/
|
|
73
|
+
export function consensusOf(responses) {
|
|
74
|
+
const usable = (responses || []).filter(r => typeof r === 'string' && r.trim());
|
|
75
|
+
if (usable.length === 0) return { value: null, agreement: 0, voters: 0 };
|
|
76
|
+
const counts = new Map();
|
|
77
|
+
for (const r of usable) counts.set(r, (counts.get(r) || 0) + 1);
|
|
78
|
+
let best = usable[0];
|
|
79
|
+
let bestCount = counts.get(best);
|
|
80
|
+
for (const r of usable) {
|
|
81
|
+
const c = counts.get(r);
|
|
82
|
+
if (c > bestCount) { best = r; bestCount = c; }
|
|
83
|
+
}
|
|
84
|
+
return { value: best, agreement: bestCount / usable.length, voters: usable.length };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An llmInvoke that queries several endpoints and returns the consensus answer.
|
|
89
|
+
* Returns null when no endpoint answered — the callers already treat a null or
|
|
90
|
+
* a throw as degradation, so an all-providers-down run degrades honestly.
|
|
91
|
+
*/
|
|
92
|
+
function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
|
|
93
|
+
const list = parseEndpoints(endpoints);
|
|
94
|
+
if (list.length === 0) return null;
|
|
95
|
+
return async (prompt) => {
|
|
96
|
+
const answers = await Promise.all(list.map(async (url) => {
|
|
97
|
+
try { return await defaultLlmInvoke(prompt, { timeoutMs, endpoint: url }); }
|
|
98
|
+
catch { return null; } // excluded from the vote, never counted as dissent
|
|
99
|
+
}));
|
|
100
|
+
const { value } = consensusOf(answers);
|
|
101
|
+
if (value === null) throw new Error('no LLM endpoint answered');
|
|
102
|
+
return value;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
27
106
|
export function resolveLlmInvoke(opts = {}) {
|
|
107
|
+
// Precedence, most explicit first: an injected callback beats configuration,
|
|
108
|
+
// and a multi-endpoint list beats a single endpoint. A caller who supplied
|
|
109
|
+
// their own function must always get exactly that function.
|
|
28
110
|
if (opts.llmInvoke) return opts.llmInvoke;
|
|
111
|
+
|
|
112
|
+
const multi = opts.endpoints || process.env[DEFAULT_CONSENSUS_ENV];
|
|
113
|
+
if (multi) {
|
|
114
|
+
const consensus = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs });
|
|
115
|
+
if (consensus) return consensus;
|
|
116
|
+
}
|
|
117
|
+
|
|
29
118
|
if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
|
|
30
119
|
return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
|
|
31
120
|
}
|
|
@@ -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
|
+
}
|
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';
|
|
@@ -585,7 +587,16 @@ const SANITIZER_PATTERNS=[{regex:/(?:escape|escapeHtml|htmlspecialchars|encodeUR
|
|
|
585
587
|
{regex:/(?:re\.escape|preg_quote|Regexp\.escape)\s*\(/g,type:"Regex Escaping"}];
|
|
586
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}];
|
|
587
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];
|
|
588
|
-
|
|
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"]);
|
|
589
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"]);
|
|
590
601
|
// Feat-2: IaC manifest filenames that aren't extension-based.
|
|
591
602
|
const IAC_FILENAMES = new Set(['Dockerfile', 'Containerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Chart.yaml']);
|
|
@@ -2401,7 +2412,7 @@ async function _loadCustomRules(scanRoot){
|
|
|
2401
2412
|
_customIgnorePaths = [];
|
|
2402
2413
|
let raw = null, parsedObj = null;
|
|
2403
2414
|
for (const ext of ['rules.yml', 'rules.yaml', 'rules.json']) {
|
|
2404
|
-
const p =
|
|
2415
|
+
const p = statePath(scanRoot, ext);
|
|
2405
2416
|
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
2406
2417
|
try {
|
|
2407
2418
|
if (ext.endsWith('.json')) parsedObj = JSON.parse(raw);
|
|
@@ -8211,7 +8222,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8211
8222
|
if (r && r.piiFields) {
|
|
8212
8223
|
try {
|
|
8213
8224
|
const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
|
|
8214
|
-
|
|
8225
|
+
_safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
|
|
8215
8226
|
} catch (_) {}
|
|
8216
8227
|
}
|
|
8217
8228
|
});
|
|
@@ -8574,7 +8585,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8574
8585
|
// second party could corroborate it.
|
|
8575
8586
|
try {
|
|
8576
8587
|
if (scanRoot) {
|
|
8577
|
-
const raw = fs.readFileSync(
|
|
8588
|
+
const raw = fs.readFileSync(statePath(scanRoot, 'logic-claims.json'), 'utf8');
|
|
8578
8589
|
const parsed = JSON.parse(raw);
|
|
8579
8590
|
const incoming = Array.isArray(parsed) ? parsed : (parsed && parsed.claims) || [];
|
|
8580
8591
|
if (incoming.length) {
|
|
@@ -8770,6 +8781,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8770
8781
|
// Each is opt-in via env var. They produce machine-readable artifacts
|
|
8771
8782
|
// (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
|
|
8772
8783
|
// sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
|
|
8784
|
+
let _privacyFramework = null;
|
|
8773
8785
|
let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
|
|
8774
8786
|
_complianceReport = null, _exploitBundles = null, _pqcPlan = null,
|
|
8775
8787
|
_licenseGraph = null, _attributions = null, _taxonomySummary = null;
|
|
@@ -8837,6 +8849,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8837
8849
|
if (_pqcPlan) persistPqcPlan(scanRoot, _pqcPlan);
|
|
8838
8850
|
} catch (_) {}
|
|
8839
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
|
+
}
|
|
8840
8875
|
// Exploit bundles — per-family PoC + Jest + pytest + remediation for
|
|
8841
8876
|
// top-N critical/high findings.
|
|
8842
8877
|
if (process.env.AGENTIC_SECURITY_NO_EXPLOIT_BUNDLES !== '1') {
|
|
@@ -8845,9 +8880,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8845
8880
|
if (bundles.size) {
|
|
8846
8881
|
_exploitBundles = {};
|
|
8847
8882
|
for (const [id, b] of bundles) _exploitBundles[id] = b;
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
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));
|
|
8851
8888
|
}
|
|
8852
8889
|
} catch (_) {}
|
|
8853
8890
|
}
|
|
@@ -8881,7 +8918,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8881
8918
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
8882
8919
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
8883
8920
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
8884
|
-
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,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};}
|
|
8885
8922
|
|
|
8886
8923
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
8887
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
|
}
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
import * as fs from 'node:fs';
|
|
65
65
|
import * as path from 'node:path';
|
|
66
66
|
import * as crypto from 'node:crypto';
|
|
67
|
-
import {
|
|
67
|
+
import { ensureStateDir, safeWriteState, statePath } from '../posture/state-dir.js';
|
|
68
68
|
import { redactSecrets } from './redact.js';
|
|
69
69
|
import { signLastScan } from '../posture/integrity.js';
|
|
70
70
|
|
|
@@ -84,7 +84,6 @@ const MAX_OUTPUT_TOKENS = 512;
|
|
|
84
84
|
let _localPresetRefusal = null;
|
|
85
85
|
|
|
86
86
|
export const PROMPT_VERSION = 'v2.0-hardened';
|
|
87
|
-
const CACHE_DIR = '.agentic-security/llm-cache';
|
|
88
87
|
|
|
89
88
|
// System preamble — embeds a per-request challenge token the model MUST
|
|
90
89
|
// echo, and a strict instruction-priority frame. {{challenge}} and {{nonce}}
|
package/src/mcp/audit.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as path from 'node:path';
|
|
|
27
27
|
import * as crypto from 'node:crypto';
|
|
28
28
|
import { redactArgsBlob } from './redact.js';
|
|
29
29
|
|
|
30
|
+
import { stateDir } from '../posture/state-dir.js';
|
|
30
31
|
const MAX_ARG_BYTES = 1024;
|
|
31
32
|
const GENESIS = 'GENESIS';
|
|
32
33
|
const REMOTE_TIMEOUT_MS = 1500;
|
|
@@ -87,7 +88,7 @@ export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
|
|
|
87
88
|
let hasMarker = false;
|
|
88
89
|
for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
|
|
89
90
|
if (!hasMarker) return;
|
|
90
|
-
const dir =
|
|
91
|
+
const dir = stateDir(sessionRoot);
|
|
91
92
|
fs.mkdirSync(dir, { recursive: true });
|
|
92
93
|
const logFile = path.join(dir, 'mcp-audit.log');
|
|
93
94
|
const entry = {
|
package/src/mcp/tools.js
CHANGED
|
@@ -157,7 +157,7 @@ function _scratchpadAbs(sessionRoot, relPath) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
function _scratchpadTotalBytes(sessionRoot) {
|
|
160
|
-
const base =
|
|
160
|
+
const base = statePath(sessionRoot, 'agent-scratchpad');
|
|
161
161
|
if (!fs.existsSync(base)) return 0;
|
|
162
162
|
let total = 0;
|
|
163
163
|
const walk = (dir) => {
|
|
@@ -222,8 +222,8 @@ function _confine(sessionRoot, candidate, label) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
|
|
225
|
-
const
|
|
226
|
-
const scanFile = path.join(
|
|
225
|
+
const stateDirPath = stateDir(sessionRoot);
|
|
226
|
+
const scanFile = path.join(stateDirPath, 'last-scan.json');
|
|
227
227
|
const sigFile = scanFile + '.sig';
|
|
228
228
|
if (!fs.existsSync(scanFile)) return { scan: null, status: 'missing' };
|
|
229
229
|
const body = fs.readFileSync(scanFile, 'utf8');
|
|
@@ -1013,6 +1013,7 @@ export const read_scratchpad = {
|
|
|
1013
1013
|
import { appendAgentsMemory as _appendAgentsMemory, readAgentsMemory as _readAgentsMemory } from '../posture/agents-memory.js';
|
|
1014
1014
|
import { lookupCve as _lookupCve } from '../posture/cve-lookup.js';
|
|
1015
1015
|
|
|
1016
|
+
import { stateDir, statePath } from '../posture/state-dir.js';
|
|
1016
1017
|
export const append_agents_memory = {
|
|
1017
1018
|
name: 'append_agents_memory',
|
|
1018
1019
|
description: 'Append a short narrative entry to AGENTS.md — agent-authored continual-learning notes. Use at session end to record "what worked / what didn\'t / what I\'d try differently next time" so the next agent can pick up the lesson. Bounded: 2 KB per entry, 20 KB total before rotation to AGENTS.md.archive. Use sparingly — narrative, not structured data.',
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -44,7 +44,7 @@ rather than creating a stray state dir outside a project.
|
|
|
44
44
|
|
|
45
45
|
**Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
|
|
46
46
|
|
|
47
|
-
**Methodology additions (
|
|
47
|
+
**Methodology additions (Agentic Methodology PRD, removed post-implementation)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
|
|
48
48
|
- `falsification.js` — default falsification pass. For each taint-style finding, tries to DISPROVE it (locate a context-matched control on the path, reusing `dataflow/sanitizer-proof.js`'s shape rules read-only); a blocked finding is demoted + `quarantined`, never removed and never severity-touched (recall-preserving, like `proof-gate`). Wired after `annotateProofGate`. Opt out: `AGENTIC_SECURITY_NO_FALSIFICATION=1`. Optional LLM tier over survivors when an endpoint is configured.
|
|
49
49
|
- `entrypoint-inventory.js` — attack-surface completeness ledger. Enumerates every entry point (HTTP/queue/cron/CLI/env/upload/webhook) with a disposition each; on `scan.entrypointInventory`.
|
|
50
50
|
- `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`. Searches the corpus **once per distinct sink pattern**, not once per finding — findings deriving the same pattern share one walk and one set of (read-only) match records. The counts are always exact; the materialised `instances` list is a bounded sample (`INSTANCE_SAMPLE_LIMIT`, 100) and says so via `instancesTruncated`. Both properties are load-bearing on large corpora: the per-finding walk was O(findings × corpus-bytes) and the instance records were O(findings × matches), which together exhausted a 6 GB heap on a 40k-file suite. If you touch this module, keep the own-site exclusion **per pattern group** — resolving it globally makes a group subtract an exclusion it never matched and drives counts negative.
|
|
@@ -80,6 +80,31 @@ Wired in `bin/agentic-security.js` after every filter and after `makeDeterminist
|
|
|
80
80
|
|
|
81
81
|
**Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
|
|
82
82
|
|
|
83
|
+
**NIST Privacy Framework 1.1 (`privacy-framework.js`)** — assessment + remediation
|
|
84
|
+
over the bundled `compliance-frameworks/nist-privacy-1-1.json` (all 104 controls).
|
|
85
|
+
Sits on top of `auditor-walkthrough.js`'s evaluator and adds the half a narrative
|
|
86
|
+
cannot give you: a gap becomes a FINDING (`family: privacy-compliance`,
|
|
87
|
+
`CWE-359`) carrying an actionable remediation, so it flows through triage and
|
|
88
|
+
`/fix`.
|
|
89
|
+
|
|
90
|
+
Four buckets, and the bucket is always stated: `gap` (mapped signal failing —
|
|
91
|
+
the ONLY bucket that emits a finding), `engine-gap` (NIST rates it code-testable
|
|
92
|
+
but this engine has no signal — disclosed by name, never a pass), `manual` (NIST
|
|
93
|
+
rates it not code-testable), `satisfied`. NIST's own `codeTestable` rating is
|
|
94
|
+
carried per control and is what separates "nobody checked" from "we checked and
|
|
95
|
+
it is fine" — 48 of 104 are governance controls no scanner can assess, and
|
|
96
|
+
reporting those as passed is the failure mode the module exists to prevent.
|
|
97
|
+
|
|
98
|
+
Two guards are load-bearing. **Findings are opt-in**
|
|
99
|
+
(`AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1`); the assessment always lands on
|
|
100
|
+
`scan.privacyFramework` and at `.agentic-security/privacy-framework.{json,md}`,
|
|
101
|
+
but appending to `scan.findings` by default would change every severity count
|
|
102
|
+
and gate verdict downstream. And the **vacuous-satisfaction guard**: a
|
|
103
|
+
`family:`-mapped control clears when no findings of that family are open, which
|
|
104
|
+
is also true of a scan that read zero files — so when nothing was examined every
|
|
105
|
+
mapped control degrades to `engine-gap` instead of reporting as satisfied. That
|
|
106
|
+
one was caught by the module's own test, not in review.
|
|
107
|
+
|
|
83
108
|
**Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `scorecard.js`, `security-trend.js`.
|
|
84
109
|
|
|
85
110
|
**Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.
|