@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/bin/agentic-security.js +477 -47
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +51 -2
  93. package/src/report/oscal.js +7 -2
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -0,0 +1,301 @@
1
+ export const id = 736;
2
+ export const ids = [736];
3
+ export const modules = {
4
+
5
+ /***/ 3736:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ buildPolicyBundle: () => (/* binding */ buildPolicyBundle),
10
+ /* harmony export */ ensurePolicyKeyPair: () => (/* binding */ ensurePolicyKeyPair),
11
+ /* harmony export */ loadPolicyBundles: () => (/* binding */ loadPolicyBundles),
12
+ /* harmony export */ loadPolicyPublicKey: () => (/* binding */ loadPolicyPublicKey),
13
+ /* harmony export */ resolveEffectivePolicy: () => (/* binding */ resolveEffectivePolicy),
14
+ /* harmony export */ signPolicyBundle: () => (/* binding */ signPolicyBundle)
15
+ /* harmony export */ });
16
+ /* unused harmony exports POLICY_BUNDLE_SCHEMA, SCOPES, canonicalPolicyBytes, verifyPolicyBundle, computePolicyDrift */
17
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
18
+ /* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8161);
19
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
20
+ /* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(7598);
21
+ /* harmony import */ var _evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(8317);
22
+ /* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(1174);
23
+ // Signed, portable policy bundles (assurance-hardening PRD FR-1001).
24
+ //
25
+ // "Support signed portable policy bundles with organization, repository, and
26
+ // environment inheritance" | "Tampered or expired policy is rejected;
27
+ // effective policy is explainable."
28
+ //
29
+ // WHY REUSE evidence-bundle.js's Ed25519 SCHEME, NOT integrity.js's HMAC
30
+ // -------------------------------------------------------------------------
31
+ // A policy bundle is authored by one party (typically a central security
32
+ // team, "the organization") and distributed to many repositories that never
33
+ // had the signing key. integrity.js's per-install symmetric HMAC is
34
+ // tamper-evidence for the SAME install that wrote it — a different install
35
+ // verifying a bundle it didn't sign is exactly the case that scheme cannot
36
+ // serve. evidence-bundle.js already solved this for findings: Ed25519,
37
+ // verify with only the public key. This module reuses that primitive
38
+ // directly (canonicalisation shape, allowlist-signed-fields discipline,
39
+ // exclusive-create key generation) rather than inventing a third signing
40
+ // mechanism, per this session's own established "survey before building a
41
+ // parallel primitive" practice (see D-0023/D-0025 for the same discipline
42
+ // applied to identity and exception mechanisms respectively).
43
+ //
44
+ // EA-03 LESSON, APPLIED HERE FROM THE START
45
+ // -------------------------------------------------------------------------
46
+ // evidence-bundle.js's own history (see its EA-03 comment) is the reason
47
+ // `canonicalPolicyBytes` signs an explicit ALLOWLIST and `verifyPolicyBundle`
48
+ // REJECTS any top-level key outside it: a bundle with a field stapled on
49
+ // after signing must fail verification, not silently pass with the addition
50
+ // unverified. Getting this right on the first pass — rather than discovering
51
+ // the gap the way evidence-bundle.js did — is the entire point of writing it
52
+ // down here.
53
+ //
54
+ // SCOPE — WHAT THIS MODULE DOES NOT DO
55
+ // -------------------------------------------------------------------------
56
+ // It does not invent a policy SCHEMA — `policy` is an opaque object; whatever
57
+ // keys a bundle carries (egress rules, severity floors, approved providers,
58
+ // anything else this repo already treats as policy) are merged the same way.
59
+ // It does not implement deep/recursive merging — inheritance is shallow,
60
+ // top-level-key override (organization -> repository -> environment, most
61
+ // specific wins per key), matching how every other flat policy config file
62
+ // in this repo (egress-policy.yml, compliance-severity-policy.json, …) is
63
+ // already shaped. A future consumer needing nested-key inheritance is new,
64
+ // separate scope, not assumed here.
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+ const POLICY_BUNDLE_SCHEMA = 'agentic-security/policy-bundle@1';
74
+
75
+ const SCOPES = ['organization', 'repository', 'environment'];
76
+ // Inheritance order — later entries override earlier ones, per top-level key.
77
+ const INHERITANCE_ORDER = ['organization', 'repository', 'environment'];
78
+
79
+ function keyDir() {
80
+ const xdg = process.env.XDG_CONFIG_HOME || node_path__WEBPACK_IMPORTED_MODULE_2__.join(node_os__WEBPACK_IMPORTED_MODULE_1__.homedir(), '.config');
81
+ return node_path__WEBPACK_IMPORTED_MODULE_2__.join(xdg, 'agentic-security', 'policy-bundles');
82
+ }
83
+
84
+ /** A SEPARATE Ed25519 keypair from evidence-bundle.js's finding-attestation
85
+ * key — signing "this policy is authentic" and "this finding really came
86
+ * from a scan run by me" are different trust domains and must not share a
87
+ * key. Reuses evidence-bundle.js's hardened, race-safe generation logic
88
+ * directly (its exclusive-create collision handling is the kind of code
89
+ * worth NOT re-implementing) rather than duplicating it — the separation
90
+ * that matters is the DIRECTORY (`policy-bundles/`, isolated from
91
+ * evidence-bundle.js's own key dir), not the leaf filenames, which are
92
+ * `attest-key.pem`/`attest-key.pub.pem` regardless of caller (a
93
+ * evidence-bundle.js internal, not overridable here) — a readability wart,
94
+ * not a security one, since the directory is what prevents key reuse. */
95
+ function ensurePolicyKeyPair(dir = keyDir()) {
96
+ return (0,_evidence_bundle_js__WEBPACK_IMPORTED_MODULE_4__.ensureKeyPair)(dir);
97
+ }
98
+
99
+ /**
100
+ * Deterministic JSON, keys sorted at every level — identical algorithm to
101
+ * evidence-bundle.js's canonicalJson (duplicated rather than imported: it is
102
+ * a pure, three-line function, and importing it would couple this module's
103
+ * signature format to evidence-bundle.js's internals for no real benefit).
104
+ */
105
+ function canonicalJson(value) {
106
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
107
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
108
+ const keys = Object.keys(value).sort();
109
+ return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
110
+ }
111
+
112
+ // The complete set of fields a legitimately-built bundle carries, BEFORE
113
+ // signing. This is the signed allowlist AND (in verifyPolicyBundle) the
114
+ // complete set of keys a signed bundle is permitted to have alongside
115
+ // `signature` — see the EA-03 note above for why both matter.
116
+ const BUNDLE_FIELDS = ['schema', 'scope', 'policy', 'issuedAt', 'expiresAt'];
117
+ const BUNDLE_TOP_LEVEL_KEYS = new Set([...BUNDLE_FIELDS, 'signature']);
118
+
119
+ function canonicalPolicyBytes(bundle) {
120
+ const signed = {};
121
+ for (const k of BUNDLE_FIELDS) signed[k] = bundle[k] ?? null;
122
+ return Buffer.from(canonicalJson(signed), 'utf8');
123
+ }
124
+
125
+ /**
126
+ * Build an unsigned bundle. `policy` is caller-supplied and opaque — this
127
+ * module does not validate its shape, only its provenance and freshness.
128
+ */
129
+ function buildPolicyBundle(scope, policy, { issuedAt, expiresAt } = {}) {
130
+ if (!SCOPES.includes(scope)) return null;
131
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return null;
132
+ return {
133
+ schema: POLICY_BUNDLE_SCHEMA,
134
+ scope,
135
+ policy,
136
+ issuedAt: issuedAt ?? new Date().toISOString(),
137
+ expiresAt: expiresAt ?? null,
138
+ };
139
+ }
140
+
141
+ /** Sign a bundle. Returns a new object; the input is not mutated. */
142
+ function signPolicyBundle(bundle, privateKeyPem) {
143
+ const sig = node_crypto__WEBPACK_IMPORTED_MODULE_3__.sign(null, canonicalPolicyBytes(bundle), privateKeyPem);
144
+ return {
145
+ ...bundle,
146
+ signature: { algorithm: 'ed25519', canonicalisation: POLICY_BUNDLE_SCHEMA, value: sig.toString('base64') },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Verify a bundle: signature authenticity AND freshness. Never throws — a
152
+ * malformed or tampered bundle from an untrusted source (a repo checking out
153
+ * whatever an org distributed) is an expected input, not exceptional.
154
+ *
155
+ * @returns {{ok: boolean, reason: string|null}}
156
+ */
157
+ function verifyPolicyBundle(bundle, publicKeyPem, { now = Date.now() } = {}) {
158
+ if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
159
+ if (bundle.schema !== POLICY_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
160
+ if (!SCOPES.includes(bundle.scope)) return { ok: false, reason: `unrecognised scope: ${bundle.scope}` };
161
+ const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
162
+ if (unknownKeys.length) {
163
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
164
+ }
165
+ const sig = bundle.signature;
166
+ if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
167
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
168
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
169
+ let sigOk = false;
170
+ try {
171
+ sigOk = node_crypto__WEBPACK_IMPORTED_MODULE_3__.verify(null, canonicalPolicyBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
172
+ } catch (e) {
173
+ return { ok: false, reason: `verification error: ${e.message}` };
174
+ }
175
+ if (!sigOk) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
176
+ if (bundle.expiresAt) {
177
+ const exp = Date.parse(bundle.expiresAt);
178
+ if (!Number.isFinite(exp)) return { ok: false, reason: `expiresAt is not a valid date: ${bundle.expiresAt}` };
179
+ if (exp < now) return { ok: false, reason: `policy expired on ${bundle.expiresAt}` };
180
+ }
181
+ return { ok: true, reason: null };
182
+ }
183
+
184
+ /**
185
+ * Merge verified bundles into one effective policy, in inheritance order
186
+ * (organization -> repository -> environment; most specific wins per key).
187
+ * A tampered or expired bundle is EXCLUDED from the merge (rejected, not
188
+ * silently dropped — its scope and reason are reported so a rejection is
189
+ * visible, not just absent). `provenance[key]` names which scope's bundle
190
+ * last set that key — the "effective policy is explainable" half of the
191
+ * acceptance criterion.
192
+ *
193
+ * @param {Array<{scope: string, bundle: object}>} entries
194
+ * @param {string} publicKeyPem
195
+ * @returns {{effective: object, provenance: Record<string,string>,
196
+ * accepted: string[], rejected: Array<{scope:string, reason:string}>}}
197
+ */
198
+ function resolveEffectivePolicy(entries, publicKeyPem, { now = Date.now() } = {}) {
199
+ const byScope = new Map();
200
+ for (const e of entries || []) {
201
+ if (e && SCOPES.includes(e.scope)) byScope.set(e.scope, e.bundle);
202
+ }
203
+ const effective = {};
204
+ const provenance = {};
205
+ const accepted = [];
206
+ const rejected = [];
207
+ for (const scope of INHERITANCE_ORDER) {
208
+ const bundle = byScope.get(scope);
209
+ if (!bundle) continue;
210
+ const v = verifyPolicyBundle(bundle, publicKeyPem, { now });
211
+ if (!v.ok) { rejected.push({ scope, reason: v.reason }); continue; }
212
+ accepted.push(scope);
213
+ for (const [k, val] of Object.entries(bundle.policy)) {
214
+ effective[k] = val;
215
+ provenance[k] = scope;
216
+ }
217
+ }
218
+ return { effective, provenance, accepted, rejected };
219
+ }
220
+
221
+ /**
222
+ * Load whichever policy bundle files exist under
223
+ * `.agentic-security/policy-bundles/{organization,repository,environment}.json`.
224
+ * Degrades gracefully — a missing directory or missing/malformed individual
225
+ * file is simply absent from the result, never thrown. Read-first-in-try/
226
+ * catch throughout (no existsSync-then-readFileSync — D-0012/D-0022).
227
+ */
228
+ function loadPolicyBundles(scanRoot) {
229
+ if (!scanRoot) return [];
230
+ const entries = [];
231
+ for (const scope of SCOPES) {
232
+ let fp;
233
+ try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__/* .statePath */ .BQ)(scanRoot, 'policy-bundles', `${scope}.json`); } catch { continue; }
234
+ let raw;
235
+ try { raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { continue; }
236
+ try {
237
+ const bundle = JSON.parse(raw);
238
+ if (bundle && typeof bundle === 'object') entries.push({ scope, bundle });
239
+ } catch { /* malformed — skip, do not throw */ }
240
+ }
241
+ return entries;
242
+ }
243
+
244
+ /**
245
+ * Read the operator-supplied public key an org distributes alongside its
246
+ * bundles, from `.agentic-security/policy-bundle-public-key.pem`. Returns
247
+ * null (not a throw) if absent or unreadable — resolution then rejects
248
+ * every bundle for lack of a key, same as any other "no public key
249
+ * supplied" case in verifyPolicyBundle.
250
+ */
251
+ function loadPolicyPublicKey(scanRoot) {
252
+ if (!scanRoot) return null;
253
+ let fp;
254
+ try { fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_5__/* .statePath */ .BQ)(scanRoot, 'policy-bundle-public-key.pem'); } catch { return null; }
255
+ try { return node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(fp, 'utf8'); } catch { return null; }
256
+ }
257
+
258
+ /**
259
+ * FR-1006 ("policy drift"): does this repository's LOCALLY resolved effective
260
+ * policy diverge from what the organization-scope bundle alone specifies —
261
+ * and is anything the repo is relying on actually being rejected (tampered
262
+ * or expired) rather than enforced? Both are GOVERNANCE gaps, distinct from
263
+ * risk findings, which is the exact distinction FR-1006's acceptance
264
+ * criterion asks fleet output to make.
265
+ *
266
+ * No bundles configured at all is a no-op (`null`), matching this session's
267
+ * established convention: drift can only be reported relative to a
268
+ * baseline, and a repo with no organization bundle has no baseline to drift
269
+ * from.
270
+ *
271
+ * @returns {{accepted: string[], rejected: Array<{scope,reason}>,
272
+ * overrides: Array<{key, organizationValue, effectiveValue, overriddenBy}>}
273
+ * | null}
274
+ */
275
+ function computePolicyDrift(scanRoot) {
276
+ const entries = loadPolicyBundles(scanRoot);
277
+ if (!entries.length) return null;
278
+ const publicKeyPem = loadPolicyPublicKey(scanRoot);
279
+ const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
280
+
281
+ const overrides = [];
282
+ const orgEntry = entries.find(e => e.scope === 'organization');
283
+ if (orgEntry && accepted.includes('organization')) {
284
+ for (const [key, organizationValue] of Object.entries(orgEntry.bundle.policy || {})) {
285
+ const overriddenBy = provenance[key];
286
+ if (overriddenBy && overriddenBy !== 'organization' && !_deepEqual(effective[key], organizationValue)) {
287
+ overrides.push({ key, organizationValue, effectiveValue: effective[key], overriddenBy });
288
+ }
289
+ }
290
+ }
291
+ return { accepted, rejected, overrides };
292
+ }
293
+
294
+ function _deepEqual(a, b) {
295
+ return JSON.stringify(a) === JSON.stringify(b);
296
+ }
297
+
298
+
299
+ /***/ })
300
+
301
+ };
package/dist/824.index.js CHANGED
@@ -12,6 +12,7 @@ export const modules = {
12
12
  /* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7598);
13
13
  /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
14
14
  /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
15
+ /* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5712);
15
16
  // LLM-assisted vulnerable function extraction for SCA findings.
16
17
  //
17
18
  // For CVEs without OSV ecosystem_specific data or GHSA fix commits,
@@ -24,6 +25,7 @@ export const modules = {
24
25
 
25
26
 
26
27
 
28
+
27
29
  const CACHE_DIR = process.env.XDG_CONFIG_HOME
28
30
  ? node_path__WEBPACK_IMPORTED_MODULE_2__.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
29
31
  : node_path__WEBPACK_IMPORTED_MODULE_2__.join(process.env.HOME || '/tmp', '.config', 'agentic-security', 'llm-sca-cache');
@@ -77,6 +79,11 @@ async function extractVulnFunctionsViaLLM(supplyChain, opts = {}) {
77
79
  if (!isLlmScaEnabled()) return [];
78
80
  const config = _endpointConfig();
79
81
  if (!config) return [];
82
+ // FR-601: one decision for the whole batch (same endpoint for every CVE in
83
+ // this run) — evaluated before any of the per-candidate prompts below are
84
+ // built.
85
+ const egressDecision = (0,_egress_policy_js__WEBPACK_IMPORTED_MODULE_3__/* .evaluateEgress */ .nn)({ scanRoot: opts.scanRoot, purpose: 'sca-llm-function-extract', endpoint: config.endpoint });
86
+ if (!egressDecision.allowed) return [];
80
87
 
81
88
  const enriched = [];
82
89
  const candidates = (supplyChain || []).filter(sc =>
package/dist/905.index.js CHANGED
@@ -111,6 +111,8 @@ function partitionCallGraph(callGraph, opts = {}) {
111
111
 
112
112
  // EXTERNAL MODULE: ./src/discovery/lenses.js
113
113
  var discovery_lenses = __webpack_require__(3499);
114
+ // EXTERNAL MODULE: ./src/egress/policy.js
115
+ var policy = __webpack_require__(5712);
114
116
  ;// CONCATENATED MODULE: ./src/discovery/llm-invoke.js
115
117
  //
116
118
  // Shared LLM endpoint caller. Both the hunter and the refutation panel need
@@ -119,6 +121,8 @@ var discovery_lenses = __webpack_require__(3499);
119
121
  // other does not, the bug stays buried in one direction.
120
122
  //
121
123
 
124
+
125
+
122
126
  const DEFAULT_TIMEOUT_MS = 60000;
123
127
 
124
128
  async function defaultLlmInvoke(prompt, opts = {}) {
@@ -202,12 +206,27 @@ function consensusOf(responses) {
202
206
  * An llmInvoke that queries several endpoints and returns the consensus answer.
203
207
  * Returns null when no endpoint answered — the callers already treat a null or
204
208
  * a throw as degradation, so an all-providers-down run degrades honestly.
209
+ *
210
+ * FR-605 (assurance-hardening PRD): each endpoint gets its OWN egress
211
+ * decision before being included — `mode: local-only` / `deniedProviders`
212
+ * must not be smuggled past just because the SINGLE-endpoint path already
213
+ * checks it. A policy-denied endpoint is EXCLUDED from the vote, the exact
214
+ * same treatment an unreachable endpoint already gets a few lines below
215
+ * (never counted as dissent) — the module's own established pattern
216
+ * extended to a second exclusion reason. Returns `{invoke, decisions}`:
217
+ * `invoke` is null only when EVERY endpoint was denied (the all-down
218
+ * equivalent); `decisions` is the full per-endpoint array for a caller
219
+ * that wants it, kept alongside the single aggregate `decision` the
220
+ * pre-existing callers already read.
205
221
  */
206
- function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
222
+ function makeConsensusInvoke(endpoints, { timeoutMs, scanRoot, purpose } = {}) {
207
223
  const list = parseEndpoints(endpoints);
208
- if (list.length === 0) return null;
209
- return async (prompt) => {
210
- const answers = await Promise.all(list.map(async (url) => {
224
+ if (list.length === 0) return { invoke: null, decisions: [] };
225
+ const decisions = list.map(url => (0,policy/* evaluateEgress */.nn)({ scanRoot, purpose: purpose || 'discovery-consensus', endpoint: url }));
226
+ const allowedList = list.filter((_, i) => decisions[i].allowed);
227
+ if (allowedList.length === 0) return { invoke: null, decisions };
228
+ const invoke = async (prompt) => {
229
+ const answers = await Promise.all(allowedList.map(async (url) => {
211
230
  try { return await defaultLlmInvoke(prompt, { timeoutMs, endpoint: url }); }
212
231
  catch { return null; } // excluded from the vote, never counted as dissent
213
232
  }));
@@ -215,22 +234,61 @@ function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
215
234
  if (value === null) throw new Error('no LLM endpoint answered');
216
235
  return value;
217
236
  };
237
+ return { invoke, decisions };
218
238
  }
219
239
 
220
- function resolveLlmInvoke(opts = {}) {
221
- // Precedence, most explicit first: an injected callback beats configuration,
222
- // and a multi-endpoint list beats a single endpoint. A caller who supplied
223
- // their own function must always get exactly that function.
224
- if (opts.llmInvoke) return opts.llmInvoke;
240
+ // FR-601: egress policy is evaluated here, BEFORE returning a callable and
241
+ // therefore before either caller (hunter.js, disprove.js) builds a prompt —
242
+ // a denied decision makes this resolve to `invoke: null`, which both callers
243
+ // already treat as "nothing to call" via their existing degrade path, so a
244
+ // denial produces no network request through the exact same code path a
245
+ // missing endpoint always has. `decision` carries the machine-readable
246
+ // reason so callers can distinguish "not configured" from "configured but
247
+ // policy-denied" in their own degrade message, rather than reporting a
248
+ // generic "not set" that would be actively wrong once policy is what
249
+ // blocked the call.
250
+ //
251
+ // FR-605 (assurance-hardening PRD): consensus mode (multiple endpoints) is
252
+ // now egress-filtered per endpoint, same as the single-endpoint path below —
253
+ // this is what closes the actual "a remote URL cannot be smuggled into
254
+ // local-only configuration" gap the paragraph below used to describe as
255
+ // open. Per-endpoint CONSTRAINT dimensions beyond allow/deny/local-only
256
+ // (role/region/repository/path/data-class — one provider allowed, another
257
+ // denied for a REASON beyond the deny-list) remain FR-602's separate scope;
258
+ // what changed here is that the single allow/deny/local-only gate FR-601
259
+ // already built is no longer bypassable just by using multiple endpoints
260
+ // instead of one.
261
+ function resolveLlmInvokeWithDecision(opts = {}) {
262
+ // An injected callback is a test/consumer-controlled escape hatch — it
263
+ // bypasses egress the same way it already bypasses endpoint resolution,
264
+ // because there is no real endpoint here for a policy to evaluate.
265
+ if (opts.llmInvoke) return { invoke: opts.llmInvoke, decision: null };
225
266
 
226
267
  const multi = opts.endpoints || process.env[DEFAULT_CONSENSUS_ENV];
227
268
  if (multi) {
228
- const consensus = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs });
229
- if (consensus) return consensus;
269
+ const { invoke, decisions } = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs, scanRoot: opts.scanRoot, purpose: opts.purpose });
270
+ // A single aggregate `decision` for the pre-existing callers (hunter.js,
271
+ // disprove.js), which only ever read `.reason` when `invoke` is null —
272
+ // the all-denied case. The full per-endpoint detail is on `decisions`
273
+ // for a caller that wants it.
274
+ const decision = decisions.find(d => !d.allowed) || decisions[0] || null;
275
+ return { invoke, decision, decisions };
230
276
  }
231
277
 
232
- if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
233
- return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
278
+ const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
279
+ if (!endpoint) return { invoke: null, decision: null };
280
+
281
+ const decision = (0,policy/* evaluateEgress */.nn)({ scanRoot: opts.scanRoot, purpose: opts.purpose || 'discovery', endpoint });
282
+ if (!decision.allowed) return { invoke: null, decision };
283
+
284
+ return { invoke: (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs }), decision };
285
+ }
286
+
287
+ function resolveLlmInvoke(opts = {}) {
288
+ // Precedence, most explicit first: an injected callback beats configuration,
289
+ // and a multi-endpoint list beats a single endpoint. A caller who supplied
290
+ // their own function must always get exactly that function.
291
+ return resolveLlmInvokeWithDecision(opts).invoke;
234
292
  }
235
293
 
236
294
  ;// CONCATENATED MODULE: ./src/discovery/hunter.js
@@ -311,12 +369,16 @@ async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
311
369
  const transcript = [];
312
370
  const lensKey = lens?.key || 'unknown';
313
371
  const base = { focusAreaId: focusArea.id, lens: lensKey, transcript };
314
- const llmInvoke = resolveLlmInvoke(opts);
372
+ const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, purpose: 'discovery-hunter' });
315
373
 
316
374
  if (typeof llmInvoke !== 'function') {
317
- const reason = 'no llmInvoke supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
318
- appendEntry(transcript, { phase: 'init', reason });
319
- return { ...base, candidates: [], degraded: true, reason };
375
+ // FR-601: distinguish "policy denied a configured endpoint" from "nothing
376
+ // was configured at all" — the reason must reflect which actually happened.
377
+ const reason = egressDecision
378
+ ? `egress policy denied this call: ${egressDecision.reason}`
379
+ : 'no llmInvoke supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
380
+ appendEntry(transcript, { phase: 'init', reason, egressDecision: egressDecision || undefined });
381
+ return { ...base, candidates: [], degraded: true, reason, egressDecision };
320
382
  }
321
383
 
322
384
  let prompt;
@@ -454,7 +516,11 @@ function parseVote(raw) {
454
516
 
455
517
  async function disproveCandidate(candidate, opts = {}) {
456
518
  const angles = Array.isArray(opts.angles) && opts.angles.length ? opts.angles : DEFAULT_ANGLES;
457
- const llmInvoke = resolveLlmInvoke(opts);
519
+ // FR-601: an egress-policy denial resolves llmInvoke to null the same way a
520
+ // missing endpoint always has, so it falls straight into this module's own
521
+ // pre-existing rule — "silence never refutes" — with zero votes cast and no
522
+ // prompt ever built for a denied endpoint.
523
+ const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, purpose: 'discovery-disprove' });
458
524
 
459
525
  const votes = [];
460
526
  if (typeof llmInvoke === 'function') {
@@ -469,7 +535,7 @@ async function disproveCandidate(candidate, opts = {}) {
469
535
  const refuteCount = votes.filter(v => v.refuted).length;
470
536
  const undecided = voterCount === 0;
471
537
  const refuted = !undecided && refuteCount * 2 > voterCount;
472
- return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided } };
538
+ return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided, egressDecision: egressDecision || undefined } };
473
539
  }
474
540
 
475
541
  async function disprovePanel(candidates, opts = {}) {
@@ -800,7 +866,7 @@ function makeTaintProbe(perFileIR, callGraph) {
800
866
 
801
867
  async function runDeepAnalysisSafe(perFileIR, callGraph) {
802
868
  try {
803
- const { runDeepAnalysis } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 6732));
869
+ const { runDeepAnalysis } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 7165));
804
870
  return runDeepAnalysis(perFileIR, callGraph, {});
805
871
  } catch {
806
872
  return null;
@@ -925,7 +991,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
925
991
  for (const area of areas) {
926
992
  let areaDegradedCount = 0;
927
993
  for (const lens of lenses) {
928
- const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke });
994
+ const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke, scanRoot: opts.scanRoot });
929
995
  runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
930
996
  if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
931
997
  if (run.degraded) areaDegradedCount += 1;
@@ -987,7 +1053,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
987
1053
  'every candidate is reported unconfirmed and severity is not evidence-derived');
988
1054
  }
989
1055
  const confirmed = await confirmAll(candidates, { taintProbe });
990
- const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke });
1056
+ const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke, scanRoot: opts.scanRoot });
991
1057
  const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
992
1058
 
993
1059
  // A spent budget is a coverage gap, stated once at the top level rather than