@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +304 -0
- package/bin/agentic-security.js +477 -47
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +108 -17
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +165 -52
- package/dist/526.index.js +108 -17
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +1 -1
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +109 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +19 -11
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +575 -288
- package/src/fix/apply-fix-service.js +403 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/mcp/tools.js +157 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +91 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +101 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/accuracy-scorecard.js +96 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +217 -0
- package/src/posture/auditor-walkthrough.js +70 -8
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +314 -17
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +113 -19
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +56 -7
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +90 -0
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/privacy-framework.js +33 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/state-dir.js +36 -1
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +51 -2
- package/src/report/oscal.js +7 -2
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Privacy data-classification taxonomy (assurance-hardening PRD FR-402).
|
|
2
|
+
//
|
|
3
|
+
// `privacy-taint.js` used to carry a single hardcoded PII_PATTERNS const
|
|
4
|
+
// with exactly four buckets (PII/PHI/PCI/FIN) and no version — extending it
|
|
5
|
+
// (a new regulated-data class, a missed field-name pattern for an existing
|
|
6
|
+
// class) meant editing this package's source. FR-402's acceptance criterion
|
|
7
|
+
// is specifically that the taxonomy is versioned AND customizable without a
|
|
8
|
+
// source change, so this module splits the taxonomy out as data:
|
|
9
|
+
//
|
|
10
|
+
// - DEFAULT_TAXONOMY ships the seven classes the PRD names by name (PII,
|
|
11
|
+
// PHI, PCI, FIN, CREDENTIALS, GEOLOCATION, DEVICE_ID), each carrying a
|
|
12
|
+
// default severity and a list of field-name regex patterns.
|
|
13
|
+
// - loadPrivacyTaxonomy(scanRoot) reads an OPTIONAL operator config at
|
|
14
|
+
// .agentic-security/privacy-taxonomy.json and merges it over the
|
|
15
|
+
// defaults: a class name that already exists gets its patterns
|
|
16
|
+
// APPENDED (mode:'extend', the default) or REPLACED (mode:'replace');
|
|
17
|
+
// a class name that doesn't exist is added as a brand-new
|
|
18
|
+
// organization-defined class. No scanner source file is touched by
|
|
19
|
+
// either path.
|
|
20
|
+
//
|
|
21
|
+
// Degrades the same way every other operator-config reader in this
|
|
22
|
+
// codebase does (see posture/network-policy-import.js, custom-rules.js):
|
|
23
|
+
// a missing file returns the built-in taxonomy unchanged; a malformed one
|
|
24
|
+
// logs a warning and falls back to the built-in taxonomy rather than
|
|
25
|
+
// throwing and aborting the scan.
|
|
26
|
+
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import { statePath } from '../posture/state-dir.js';
|
|
29
|
+
|
|
30
|
+
export const BUILTIN_TAXONOMY_VERSION = '1.0.0';
|
|
31
|
+
|
|
32
|
+
// Severity ranking used to pick the worst-case severity when a single
|
|
33
|
+
// field/finding matches more than one class (e.g. a "ssn_and_password"
|
|
34
|
+
// field matching both PII and CREDENTIALS).
|
|
35
|
+
export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_TAXONOMY = Object.freeze({
|
|
38
|
+
PII: {
|
|
39
|
+
severity: 'medium',
|
|
40
|
+
patterns: [
|
|
41
|
+
'\\bfirst[_-]?name\\b', '\\blast[_-]?name\\b', '\\bfull[_-]?name\\b',
|
|
42
|
+
'\\bemail([_-]?address)?\\b', '\\bphone([_-]?number)?\\b', '\\bmobile\\b',
|
|
43
|
+
'\\baddress(?:_?(?:line|street|city|zip|postal))?\\b',
|
|
44
|
+
'\\bdob\\b', '\\bdate[_-]?of[_-]?birth\\b', '\\bbirthday\\b', '\\bbirthdate\\b',
|
|
45
|
+
'\\bage\\b', '\\bgender\\b', '\\bethnicity\\b', '\\brace\\b', '\\bnationality\\b',
|
|
46
|
+
'\\bssn\\b', '\\bsocial[_-]?security', '\\bnational[_-]?id', '\\bpassport\\b',
|
|
47
|
+
'\\bdriver[_-]?license\\b', '\\btax[_-]?id\\b', '\\bgovernment[_-]?id\\b',
|
|
48
|
+
'\\bip[_-]?address\\b',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
PHI: {
|
|
52
|
+
severity: 'high',
|
|
53
|
+
patterns: [
|
|
54
|
+
'\\b(?:medical|patient|health)[_-]?record\\b',
|
|
55
|
+
'\\bdiagnosis\\b', '\\bcondition\\b', '\\bsymptom\\b', '\\btreatment\\b',
|
|
56
|
+
'\\bmedication\\b', '\\bprescription\\b', '\\bdosage\\b',
|
|
57
|
+
'\\bicd[_-]?(?:9|10|11)\\b', '\\bcpt[_-]?code\\b', '\\bmrn\\b',
|
|
58
|
+
'\\bmedical[_-]?record[_-]?number\\b', '\\bdoctor[_-]?name\\b',
|
|
59
|
+
'\\bphysician\\b', '\\binsurance[_-]?id\\b', '\\bhealth[_-]?plan\\b',
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
PCI: {
|
|
63
|
+
severity: 'high',
|
|
64
|
+
patterns: [
|
|
65
|
+
'\\bcredit[_-]?card[_-]?(?:number|num|no)?\\b',
|
|
66
|
+
'\\bcard[_-]?(?:number|num|no)\\b',
|
|
67
|
+
'\\b(?:cvc|cvv)2?\\b', '\\bcvc[_-]?code\\b',
|
|
68
|
+
'\\bexp(?:iry|iration)?(?:_?date)?\\b',
|
|
69
|
+
'\\bcardholder[_-]?name\\b', '\\bpan\\b',
|
|
70
|
+
'\\biban\\b', '\\brouting[_-]?number\\b',
|
|
71
|
+
'\\baccount[_-]?number\\b',
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
FIN: {
|
|
75
|
+
severity: 'medium',
|
|
76
|
+
patterns: [
|
|
77
|
+
'\\bsalary\\b', '\\bincome\\b', '\\bbalance\\b', '\\btransaction[_-]?amount\\b',
|
|
78
|
+
'\\bbank[_-]?account\\b',
|
|
79
|
+
'\\bcredit[_-]?score\\b', '\\bnet[_-]?worth\\b',
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
CREDENTIALS: {
|
|
83
|
+
severity: 'critical',
|
|
84
|
+
patterns: [
|
|
85
|
+
'\\bpassword\\b', '\\bpasswd\\b', '\\bapi[_-]?key\\b', '\\bsecret[_-]?key\\b',
|
|
86
|
+
'\\baccess[_-]?token\\b', '\\brefresh[_-]?token\\b', '\\bauth[_-]?token\\b',
|
|
87
|
+
'\\bprivate[_-]?key\\b', '\\bclient[_-]?secret\\b', '\\bsession[_-]?token\\b',
|
|
88
|
+
'\\bsecurity[_-]?answer\\b', '\\bpin[_-]?code\\b',
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
GEOLOCATION: {
|
|
92
|
+
severity: 'medium',
|
|
93
|
+
patterns: [
|
|
94
|
+
'\\bgeo[_-]?location\\b', '\\blatitude\\b', '\\blongitude\\b',
|
|
95
|
+
'\\bgps[_-]?coord(?:inates?)?\\b', '\\bprecise[_-]?location\\b',
|
|
96
|
+
'\\bcurrent[_-]?location\\b',
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
DEVICE_ID: {
|
|
100
|
+
severity: 'low',
|
|
101
|
+
patterns: [
|
|
102
|
+
'\\bdevice[_-]?id\\b', '\\bimei\\b', '\\budid\\b', '\\bmac[_-]?address\\b',
|
|
103
|
+
'\\badvertising[_-]?id\\b', '\\bidfa\\b', '\\bandroid[_-]?id\\b',
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function _taxonomyStatePath(scanRoot) {
|
|
109
|
+
return statePath(scanRoot, 'privacy-taxonomy.json');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compile a taxonomy (class -> {severity, patterns: string[]}) into
|
|
114
|
+
* class -> {severity, regexes: RegExp[]} for repeated use across a scan.
|
|
115
|
+
*/
|
|
116
|
+
export function compileTaxonomy(taxonomy) {
|
|
117
|
+
const compiled = {};
|
|
118
|
+
for (const [cls, def] of Object.entries(taxonomy || {})) {
|
|
119
|
+
const patterns = Array.isArray(def?.patterns) ? def.patterns : [];
|
|
120
|
+
const regexes = [];
|
|
121
|
+
for (const p of patterns) {
|
|
122
|
+
try { regexes.push(new RegExp(p, 'i')); } catch { /* skip an invalid operator-supplied pattern */ }
|
|
123
|
+
}
|
|
124
|
+
compiled[cls] = { severity: def?.severity || 'medium', regexes };
|
|
125
|
+
}
|
|
126
|
+
return compiled;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const _BUILTIN_COMPILED = compileTaxonomy(DEFAULT_TAXONOMY);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Merge an operator-supplied taxonomy config over DEFAULT_TAXONOMY.
|
|
133
|
+
* Never mutates DEFAULT_TAXONOMY. `raw.classes[name].mode` controls how an
|
|
134
|
+
* existing class is merged: 'extend' (default) appends patterns to the
|
|
135
|
+
* built-in list; 'replace' discards the built-in patterns for that class.
|
|
136
|
+
* A class name not present in DEFAULT_TAXONOMY is added as-is (a new
|
|
137
|
+
* organization-defined class) and defaults to severity 'medium' if unset.
|
|
138
|
+
*/
|
|
139
|
+
function _mergeTaxonomy(raw) {
|
|
140
|
+
const merged = {};
|
|
141
|
+
for (const [cls, def] of Object.entries(DEFAULT_TAXONOMY)) {
|
|
142
|
+
merged[cls] = { severity: def.severity, patterns: [...def.patterns] };
|
|
143
|
+
}
|
|
144
|
+
const classes = raw && typeof raw.classes === 'object' && raw.classes ? raw.classes : {};
|
|
145
|
+
let customized = false;
|
|
146
|
+
for (const [cls, def] of Object.entries(classes)) {
|
|
147
|
+
const patterns = Array.isArray(def?.patterns) ? def.patterns.filter(p => typeof p === 'string') : [];
|
|
148
|
+
if (!patterns.length) continue;
|
|
149
|
+
customized = true;
|
|
150
|
+
const existing = merged[cls];
|
|
151
|
+
if (existing && def?.mode !== 'replace') {
|
|
152
|
+
merged[cls] = {
|
|
153
|
+
severity: typeof def?.severity === 'string' ? def.severity : existing.severity,
|
|
154
|
+
patterns: [...existing.patterns, ...patterns],
|
|
155
|
+
};
|
|
156
|
+
} else {
|
|
157
|
+
merged[cls] = {
|
|
158
|
+
severity: typeof def?.severity === 'string' ? def.severity : (existing?.severity || 'medium'),
|
|
159
|
+
patterns,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { taxonomy: merged, customized };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Load the effective privacy taxonomy for a scan: the built-in defaults,
|
|
168
|
+
* merged with .agentic-security/privacy-taxonomy.json when present. Never
|
|
169
|
+
* throws — a missing file returns the built-in taxonomy; a malformed one
|
|
170
|
+
* logs a warning and falls back to the built-in taxonomy untouched.
|
|
171
|
+
*
|
|
172
|
+
* Returns { version, taxonomy, compiled, customized }.
|
|
173
|
+
*/
|
|
174
|
+
export function loadPrivacyTaxonomy(scanRoot) {
|
|
175
|
+
if (!scanRoot) {
|
|
176
|
+
return { version: BUILTIN_TAXONOMY_VERSION, taxonomy: DEFAULT_TAXONOMY, compiled: _BUILTIN_COMPILED, customized: false };
|
|
177
|
+
}
|
|
178
|
+
const fp = _taxonomyStatePath(scanRoot);
|
|
179
|
+
// Read-first, not existsSync()-then-readFileSync() — the file can vanish
|
|
180
|
+
// between those two calls (TOCTOU). ENOENT is the expected "no config"
|
|
181
|
+
// case and degrades silently; anything else (bad JSON, permission
|
|
182
|
+
// denied) logs a warning and falls back the same way.
|
|
183
|
+
let raw = null;
|
|
184
|
+
try {
|
|
185
|
+
raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (e.code !== 'ENOENT') {
|
|
188
|
+
console.error(`agentic-security: bad JSON in privacy-taxonomy.json — falling back to the built-in taxonomy (${e.message})`);
|
|
189
|
+
}
|
|
190
|
+
raw = null;
|
|
191
|
+
}
|
|
192
|
+
if (!raw) {
|
|
193
|
+
return { version: BUILTIN_TAXONOMY_VERSION, taxonomy: DEFAULT_TAXONOMY, compiled: _BUILTIN_COMPILED, customized: false };
|
|
194
|
+
}
|
|
195
|
+
const { taxonomy, customized } = _mergeTaxonomy(raw);
|
|
196
|
+
const version = typeof raw.taxonomyVersion === 'string' && raw.taxonomyVersion
|
|
197
|
+
? raw.taxonomyVersion
|
|
198
|
+
: (customized ? `${BUILTIN_TAXONOMY_VERSION}+custom` : BUILTIN_TAXONOMY_VERSION);
|
|
199
|
+
return { version, taxonomy, compiled: compileTaxonomy(taxonomy), customized };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Classify a field/variable name against a compiled taxonomy (see
|
|
204
|
+
* compileTaxonomy). Returns an array of matched class names, in the
|
|
205
|
+
* taxonomy's own key order — defaults to the built-in taxonomy when none
|
|
206
|
+
* is supplied, so existing callers with a single argument are unaffected.
|
|
207
|
+
*/
|
|
208
|
+
export function classifyFieldAgainst(name, compiled) {
|
|
209
|
+
if (!name) return [];
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const [cls, def] of Object.entries(compiled || _BUILTIN_COMPILED)) {
|
|
212
|
+
for (const re of def.regexes) {
|
|
213
|
+
if (re.test(name)) { out.push(cls); break; }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Worst-case (highest) severity among a set of matched class names, per
|
|
221
|
+
* the given compiled taxonomy. Falls back to 'medium' for an empty or
|
|
222
|
+
* unrecognized class list — same default the pre-FR-402 code used.
|
|
223
|
+
*/
|
|
224
|
+
export function severityForClasses(classes, compiled) {
|
|
225
|
+
const table = compiled || _BUILTIN_COMPILED;
|
|
226
|
+
let best = null;
|
|
227
|
+
for (const cls of classes || []) {
|
|
228
|
+
const sev = table[cls]?.severity;
|
|
229
|
+
if (!sev) continue;
|
|
230
|
+
if (!best || (SEVERITY_RANK[sev] || 0) > (SEVERITY_RANK[best] || 0)) best = sev;
|
|
231
|
+
}
|
|
232
|
+
return best || 'medium';
|
|
233
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// not vote, and is excluded from the denominator rather than counted as
|
|
14
14
|
// agreement. If nobody votes the panel is `undecided` and the candidate
|
|
15
15
|
// SURVIVES — an outage must not quietly delete findings.
|
|
16
|
-
import {
|
|
16
|
+
import { resolveLlmInvokeWithDecision } from './llm-invoke.js';
|
|
17
17
|
|
|
18
18
|
const DEFAULT_ANGLES = ['reachability', 'preconditions', 'sanitization'];
|
|
19
19
|
export const REFUTE_ANGLES = Object.freeze([...DEFAULT_ANGLES]);
|
|
@@ -51,7 +51,11 @@ function parseVote(raw) {
|
|
|
51
51
|
|
|
52
52
|
export async function disproveCandidate(candidate, opts = {}) {
|
|
53
53
|
const angles = Array.isArray(opts.angles) && opts.angles.length ? opts.angles : DEFAULT_ANGLES;
|
|
54
|
-
|
|
54
|
+
// FR-601: an egress-policy denial resolves llmInvoke to null the same way a
|
|
55
|
+
// missing endpoint always has, so it falls straight into this module's own
|
|
56
|
+
// pre-existing rule — "silence never refutes" — with zero votes cast and no
|
|
57
|
+
// prompt ever built for a denied endpoint.
|
|
58
|
+
const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, purpose: 'discovery-disprove' });
|
|
55
59
|
|
|
56
60
|
const votes = [];
|
|
57
61
|
if (typeof llmInvoke === 'function') {
|
|
@@ -66,7 +70,7 @@ export async function disproveCandidate(candidate, opts = {}) {
|
|
|
66
70
|
const refuteCount = votes.filter(v => v.refuted).length;
|
|
67
71
|
const undecided = voterCount === 0;
|
|
68
72
|
const refuted = !undecided && refuteCount * 2 > voterCount;
|
|
69
|
-
return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided } };
|
|
73
|
+
return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided, egressDecision: egressDecision || undefined } };
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
export async function disprovePanel(candidates, opts = {}) {
|
package/src/discovery/hunter.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// discovery pass that cannot run must leave the rest of the scan intact.
|
|
12
12
|
import * as crypto from 'node:crypto';
|
|
13
13
|
import { buildHunterPrompt } from './lenses.js';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveLlmInvokeWithDecision } from './llm-invoke.js';
|
|
15
15
|
|
|
16
16
|
function appendEntry(transcript, entry) {
|
|
17
17
|
const prev = transcript.length ? transcript[transcript.length - 1].hash : null;
|
|
@@ -75,12 +75,16 @@ export async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
|
|
|
75
75
|
const transcript = [];
|
|
76
76
|
const lensKey = lens?.key || 'unknown';
|
|
77
77
|
const base = { focusAreaId: focusArea.id, lens: lensKey, transcript };
|
|
78
|
-
const llmInvoke =
|
|
78
|
+
const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, purpose: 'discovery-hunter' });
|
|
79
79
|
|
|
80
80
|
if (typeof llmInvoke !== 'function') {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// FR-601: distinguish "policy denied a configured endpoint" from "nothing
|
|
82
|
+
// was configured at all" — the reason must reflect which actually happened.
|
|
83
|
+
const reason = egressDecision
|
|
84
|
+
? `egress policy denied this call: ${egressDecision.reason}`
|
|
85
|
+
: 'no llmInvoke supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
|
|
86
|
+
appendEntry(transcript, { phase: 'init', reason, egressDecision: egressDecision || undefined });
|
|
87
|
+
return { ...base, candidates: [], degraded: true, reason, egressDecision };
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
let prompt;
|
package/src/discovery/index.js
CHANGED
|
@@ -169,7 +169,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
169
169
|
for (const area of areas) {
|
|
170
170
|
let areaDegradedCount = 0;
|
|
171
171
|
for (const lens of lenses) {
|
|
172
|
-
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke });
|
|
172
|
+
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke, scanRoot: opts.scanRoot });
|
|
173
173
|
runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
|
|
174
174
|
if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
|
|
175
175
|
if (run.degraded) areaDegradedCount += 1;
|
|
@@ -231,7 +231,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
231
231
|
'every candidate is reported unconfirmed and severity is not evidence-derived');
|
|
232
232
|
}
|
|
233
233
|
const confirmed = await confirmAll(candidates, { taintProbe });
|
|
234
|
-
const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke });
|
|
234
|
+
const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke, scanRoot: opts.scanRoot });
|
|
235
235
|
const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
|
|
236
236
|
|
|
237
237
|
// A spent budget is a coverage gap, stated once at the top level rather than
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// other does not, the bug stays buried in one direction.
|
|
6
6
|
//
|
|
7
7
|
|
|
8
|
+
import { evaluateEgress } from '../egress/policy.js';
|
|
9
|
+
|
|
8
10
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
9
11
|
|
|
10
12
|
export async function defaultLlmInvoke(prompt, opts = {}) {
|
|
@@ -88,12 +90,27 @@ export function consensusOf(responses) {
|
|
|
88
90
|
* An llmInvoke that queries several endpoints and returns the consensus answer.
|
|
89
91
|
* Returns null when no endpoint answered — the callers already treat a null or
|
|
90
92
|
* a throw as degradation, so an all-providers-down run degrades honestly.
|
|
93
|
+
*
|
|
94
|
+
* FR-605 (assurance-hardening PRD): each endpoint gets its OWN egress
|
|
95
|
+
* decision before being included — `mode: local-only` / `deniedProviders`
|
|
96
|
+
* must not be smuggled past just because the SINGLE-endpoint path already
|
|
97
|
+
* checks it. A policy-denied endpoint is EXCLUDED from the vote, the exact
|
|
98
|
+
* same treatment an unreachable endpoint already gets a few lines below
|
|
99
|
+
* (never counted as dissent) — the module's own established pattern
|
|
100
|
+
* extended to a second exclusion reason. Returns `{invoke, decisions}`:
|
|
101
|
+
* `invoke` is null only when EVERY endpoint was denied (the all-down
|
|
102
|
+
* equivalent); `decisions` is the full per-endpoint array for a caller
|
|
103
|
+
* that wants it, kept alongside the single aggregate `decision` the
|
|
104
|
+
* pre-existing callers already read.
|
|
91
105
|
*/
|
|
92
|
-
function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
|
|
106
|
+
function makeConsensusInvoke(endpoints, { timeoutMs, scanRoot, purpose } = {}) {
|
|
93
107
|
const list = parseEndpoints(endpoints);
|
|
94
|
-
if (list.length === 0) return null;
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
if (list.length === 0) return { invoke: null, decisions: [] };
|
|
109
|
+
const decisions = list.map(url => evaluateEgress({ scanRoot, purpose: purpose || 'discovery-consensus', endpoint: url }));
|
|
110
|
+
const allowedList = list.filter((_, i) => decisions[i].allowed);
|
|
111
|
+
if (allowedList.length === 0) return { invoke: null, decisions };
|
|
112
|
+
const invoke = async (prompt) => {
|
|
113
|
+
const answers = await Promise.all(allowedList.map(async (url) => {
|
|
97
114
|
try { return await defaultLlmInvoke(prompt, { timeoutMs, endpoint: url }); }
|
|
98
115
|
catch { return null; } // excluded from the vote, never counted as dissent
|
|
99
116
|
}));
|
|
@@ -101,20 +118,59 @@ function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
|
|
|
101
118
|
if (value === null) throw new Error('no LLM endpoint answered');
|
|
102
119
|
return value;
|
|
103
120
|
};
|
|
121
|
+
return { invoke, decisions };
|
|
104
122
|
}
|
|
105
123
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
// FR-601: egress policy is evaluated here, BEFORE returning a callable and
|
|
125
|
+
// therefore before either caller (hunter.js, disprove.js) builds a prompt —
|
|
126
|
+
// a denied decision makes this resolve to `invoke: null`, which both callers
|
|
127
|
+
// already treat as "nothing to call" via their existing degrade path, so a
|
|
128
|
+
// denial produces no network request through the exact same code path a
|
|
129
|
+
// missing endpoint always has. `decision` carries the machine-readable
|
|
130
|
+
// reason so callers can distinguish "not configured" from "configured but
|
|
131
|
+
// policy-denied" in their own degrade message, rather than reporting a
|
|
132
|
+
// generic "not set" that would be actively wrong once policy is what
|
|
133
|
+
// blocked the call.
|
|
134
|
+
//
|
|
135
|
+
// FR-605 (assurance-hardening PRD): consensus mode (multiple endpoints) is
|
|
136
|
+
// now egress-filtered per endpoint, same as the single-endpoint path below —
|
|
137
|
+
// this is what closes the actual "a remote URL cannot be smuggled into
|
|
138
|
+
// local-only configuration" gap the paragraph below used to describe as
|
|
139
|
+
// open. Per-endpoint CONSTRAINT dimensions beyond allow/deny/local-only
|
|
140
|
+
// (role/region/repository/path/data-class — one provider allowed, another
|
|
141
|
+
// denied for a REASON beyond the deny-list) remain FR-602's separate scope;
|
|
142
|
+
// what changed here is that the single allow/deny/local-only gate FR-601
|
|
143
|
+
// already built is no longer bypassable just by using multiple endpoints
|
|
144
|
+
// instead of one.
|
|
145
|
+
export function resolveLlmInvokeWithDecision(opts = {}) {
|
|
146
|
+
// An injected callback is a test/consumer-controlled escape hatch — it
|
|
147
|
+
// bypasses egress the same way it already bypasses endpoint resolution,
|
|
148
|
+
// because there is no real endpoint here for a policy to evaluate.
|
|
149
|
+
if (opts.llmInvoke) return { invoke: opts.llmInvoke, decision: null };
|
|
111
150
|
|
|
112
151
|
const multi = opts.endpoints || process.env[DEFAULT_CONSENSUS_ENV];
|
|
113
152
|
if (multi) {
|
|
114
|
-
const
|
|
115
|
-
|
|
153
|
+
const { invoke, decisions } = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs, scanRoot: opts.scanRoot, purpose: opts.purpose });
|
|
154
|
+
// A single aggregate `decision` for the pre-existing callers (hunter.js,
|
|
155
|
+
// disprove.js), which only ever read `.reason` when `invoke` is null —
|
|
156
|
+
// the all-denied case. The full per-endpoint detail is on `decisions`
|
|
157
|
+
// for a caller that wants it.
|
|
158
|
+
const decision = decisions.find(d => !d.allowed) || decisions[0] || null;
|
|
159
|
+
return { invoke, decision, decisions };
|
|
116
160
|
}
|
|
117
161
|
|
|
118
|
-
|
|
119
|
-
|
|
162
|
+
const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
|
|
163
|
+
if (!endpoint) return { invoke: null, decision: null };
|
|
164
|
+
|
|
165
|
+
const decision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: opts.purpose || 'discovery', endpoint });
|
|
166
|
+
if (!decision.allowed) return { invoke: null, decision };
|
|
167
|
+
|
|
168
|
+
return { invoke: (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs }), decision };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function resolveLlmInvoke(opts = {}) {
|
|
172
|
+
// Precedence, most explicit first: an injected callback beats configuration,
|
|
173
|
+
// and a multi-endpoint list beats a single endpoint. A caller who supplied
|
|
174
|
+
// their own function must always get exactly that function.
|
|
175
|
+
return resolveLlmInvokeWithDecision(opts).invoke;
|
|
120
176
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Per-call egress audit log (assurance-hardening PRD FR-604).
|
|
2
|
+
//
|
|
3
|
+
// FR-601 gives every outbound LLM call a machine-readable in-memory
|
|
4
|
+
// decision object. FR-604's acceptance criterion asks for more than that:
|
|
5
|
+
// a PERSISTED, per-call audit record — "purpose, provider, model, region if
|
|
6
|
+
// known, byte/token counts, policy, hashes, and outcome" — WITHOUT ever
|
|
7
|
+
// retaining the prohibited prompt content itself. This module is that
|
|
8
|
+
// record.
|
|
9
|
+
//
|
|
10
|
+
// Design: intentionally mirrors mcp/audit.js's proven OWASP-MCP08
|
|
11
|
+
// technique (append-only NDJSON, each entry's `prev` is the SHA-256 of the
|
|
12
|
+
// previous line, so tampering breaks the chain from that point forward) —
|
|
13
|
+
// same shape of guarantee this codebase already ships and tests for MCP
|
|
14
|
+
// tool calls, now for egress calls. Deliberately a SEPARATE, self-contained
|
|
15
|
+
// implementation rather than an import from mcp/audit.js: that module's
|
|
16
|
+
// public surface (`auditCall`) is shaped around {tool, args}, not
|
|
17
|
+
// {purpose, provider, model, region, policy, hashes}, and its hash-chain
|
|
18
|
+
// helpers are private (`_sha`/`_readLastEntryHash`/`_postRemote`, not
|
|
19
|
+
// exported) — reshaping that already-tested, security-relevant module to
|
|
20
|
+
// export shared primitives is a real, separate refactor, not something to
|
|
21
|
+
// fold into one FR cycle. The ~15 lines of hash-chaining logic duplicated
|
|
22
|
+
// here are a well-understood idiom (one sha256 call), not a place where
|
|
23
|
+
// drift risk between the two copies matters in practice.
|
|
24
|
+
//
|
|
25
|
+
// CONTENT NEVER RETAINED: this module accepts a byte count, a token
|
|
26
|
+
// estimate, and a content HASH — never the text itself. A caller that
|
|
27
|
+
// passes raw prompt text here has misused the API; there is no parameter
|
|
28
|
+
// that accepts it.
|
|
29
|
+
|
|
30
|
+
import * as fs from 'node:fs';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import * as crypto from 'node:crypto';
|
|
33
|
+
import { stateDir } from '../posture/state-dir.js';
|
|
34
|
+
|
|
35
|
+
const GENESIS = 'GENESIS';
|
|
36
|
+
const LOG_FILE_NAME = 'egress-audit.log';
|
|
37
|
+
const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
|
|
38
|
+
|
|
39
|
+
function _sha(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
|
|
40
|
+
|
|
41
|
+
function _hasProjectMarker(sessionRoot) {
|
|
42
|
+
const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
|
|
43
|
+
for (const m of MARKERS) {
|
|
44
|
+
try { if (fs.existsSync(path.join(sessionRoot, m))) return true; } catch { /* keep checking */ }
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Read-first, not existsSync()-then-readFileSync() — ENOENT is the expected
|
|
50
|
+
// "no prior log" case (see this session's D-0012: every optional-file
|
|
51
|
+
// reader in this codebase follows this shape from the start).
|
|
52
|
+
function _readLastEntryHash(logFile) {
|
|
53
|
+
let all;
|
|
54
|
+
try {
|
|
55
|
+
all = fs.readFileSync(logFile, 'utf8');
|
|
56
|
+
} catch {
|
|
57
|
+
return GENESIS;
|
|
58
|
+
}
|
|
59
|
+
const lines = all.split('\n').filter(Boolean);
|
|
60
|
+
if (!lines.length) return GENESIS;
|
|
61
|
+
return _sha(lines[lines.length - 1]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compute the {byteCount, tokenCount, contentHash} triple for an outbound
|
|
66
|
+
* payload WITHOUT the caller having to hash/measure it inline at every call
|
|
67
|
+
* site. Never returns or logs the text itself.
|
|
68
|
+
*/
|
|
69
|
+
export function payloadMetrics(text) {
|
|
70
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
71
|
+
return { byteCount: 0, tokenCount: 0, contentHash: null };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
byteCount: Buffer.byteLength(text, 'utf8'),
|
|
75
|
+
tokenCount: Math.ceil(text.length / 4), // same rough estimate llm-validator/index.js's own R12 ceiling uses
|
|
76
|
+
contentHash: _sha(text),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Append one audit entry for an evaluateEgress() decision.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} opts
|
|
84
|
+
* @param {string} opts.scanRoot - project root; entries are only written
|
|
85
|
+
* when it looks like a real project (same safety check mcp/audit.js
|
|
86
|
+
* uses) so a scratch/temp scanRoot never accumulates an audit file.
|
|
87
|
+
* @param {object} opts.decision - the object evaluateEgress() returned:
|
|
88
|
+
* { allowed, decision, reason?, provider, policySource, purpose }.
|
|
89
|
+
* @param {object} [opts.ctx] - the SAME ctx object passed to evaluateEgress
|
|
90
|
+
* — only `model` and `region` are read from it (if present); nothing
|
|
91
|
+
* else is retained.
|
|
92
|
+
* @param {object} [opts.metrics] - the result of payloadMetrics(text), or
|
|
93
|
+
* omitted entirely for a denied call where no payload was ever built.
|
|
94
|
+
*/
|
|
95
|
+
export function recordEgressCall({ scanRoot, decision, ctx = {}, metrics = null } = {}) {
|
|
96
|
+
if (!scanRoot || !decision) return;
|
|
97
|
+
try {
|
|
98
|
+
if (!_hasProjectMarker(scanRoot)) return;
|
|
99
|
+
const dir = stateDir(scanRoot);
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const logFile = path.join(dir, LOG_FILE_NAME);
|
|
102
|
+
const entry = {
|
|
103
|
+
ts: new Date().toISOString(),
|
|
104
|
+
sessionId: SESSION_ID,
|
|
105
|
+
purpose: decision.purpose || ctx.purpose || 'unknown',
|
|
106
|
+
provider: decision.provider || 'unknown',
|
|
107
|
+
model: typeof ctx.model === 'string' ? ctx.model : null,
|
|
108
|
+
region: typeof ctx.region === 'string' ? ctx.region : null,
|
|
109
|
+
policy: { policySource: decision.policySource || 'default' },
|
|
110
|
+
outcome: decision.decision || (decision.allowed ? 'allow' : 'deny'),
|
|
111
|
+
...(decision.reason ? { reason: decision.reason } : {}),
|
|
112
|
+
byteCount: metrics ? metrics.byteCount : null,
|
|
113
|
+
tokenCount: metrics ? metrics.tokenCount : null,
|
|
114
|
+
contentHash: metrics ? metrics.contentHash : null,
|
|
115
|
+
prev: _readLastEntryHash(logFile),
|
|
116
|
+
};
|
|
117
|
+
fs.appendFileSync(logFile, JSON.stringify(entry) + '\n');
|
|
118
|
+
} catch { /* audit failure must never break the call it is auditing */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Verify the chain from start to end — same contract as mcp/audit.js's
|
|
122
|
+
// verifyAuditLog: { ok: true, entries: N } if intact, or
|
|
123
|
+
// { ok: false, brokenAt, expected, got } at the first broken link.
|
|
124
|
+
export function verifyEgressAuditLog(logFile) {
|
|
125
|
+
// Read-first, not existsSync()-then-readFileSync() (D-0012) — the file can
|
|
126
|
+
// vanish between those two calls; ENOENT is the expected "no log yet" case.
|
|
127
|
+
let text;
|
|
128
|
+
try {
|
|
129
|
+
text = fs.readFileSync(logFile, 'utf8');
|
|
130
|
+
} catch {
|
|
131
|
+
return { ok: true, entries: 0 };
|
|
132
|
+
}
|
|
133
|
+
const lines = text.split('\n').filter(Boolean);
|
|
134
|
+
let expectedPrev = GENESIS;
|
|
135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
136
|
+
let entry;
|
|
137
|
+
try { entry = JSON.parse(lines[i]); }
|
|
138
|
+
catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
|
|
139
|
+
if (entry.prev !== expectedPrev) {
|
|
140
|
+
return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
|
|
141
|
+
}
|
|
142
|
+
expectedPrev = _sha(lines[i]);
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, entries: lines.length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const _internals = { LOG_FILE_NAME, GENESIS, _hasProjectMarker, _readLastEntryHash };
|