@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.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 (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -38,7 +38,15 @@ export async function verifyPatch({
38
38
  const fileContents = { ...files };
39
39
  let scan;
40
40
  try {
41
- scan = await runFullScan({ fileContents, depFileContents, scanRoot }, () => {});
41
+ // `provenance:false` is REQUIRED here, not an optimisation. This scan is
42
+ // deliberately scoped to just the patched file(s), so its finding set is a
43
+ // tiny subset of the project's. updateLifecycle marks every open stableId
44
+ // NOT in the set it is handed as `remediated` — so a single fix
45
+ // verification (every /fix, apply_fix, and autopilot iteration runs one)
46
+ // would mass-mark the rest of the project as remediated, then
47
+ // `reintroduced` on the next real scan. The patched content is also not
48
+ // committed, so there is no history to resolve provenance against anyway.
49
+ scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
42
50
  } catch (e) {
43
51
  return { ok: false, reason: 'rescan-failed', error: e.message };
44
52
  }
@@ -187,12 +195,6 @@ export async function verifyFix({
187
195
  // and on the result so a caller cannot mistake it for a verified patch.
188
196
  const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
189
197
  const testsOk = tests.skipped ? true : tests.passed === true;
190
- let honesty = null;
191
- if (fixMeta && typeof fixMeta === 'object') {
192
- try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
193
- }
194
- _lap('honesty');
195
-
196
198
  // R5 — the PoC leg. Re-run the finding's proof-of-concept against the
197
199
  // CANDIDATE patch inside R1's sandbox. A patch that still lets the PoC
198
200
  // demonstrate the predicted effect has not fixed anything, however green the
@@ -204,6 +206,11 @@ export async function verifyFix({
204
206
  // to run, or a sandbox that could not start, is recorded as `inconclusive`
205
207
  // and left out of the verdict entirely. Treating "could not prove it" as
206
208
  // "fixed" is exactly the false confidence this leg exists to prevent.
209
+ //
210
+ // Computed BEFORE the honesty gate (FR-308): a still-exploitable PoC is
211
+ // MECHANICAL evidence, not a self-report, and gateFixOutput cross-checks a
212
+ // self-reported FULL tier against it below — the ordering matters, not
213
+ // just the value.
207
214
  let pocLeg = { status: 'not-requested', reason: null, tier: null };
208
215
  if (poc?.code) {
209
216
  try {
@@ -222,7 +229,54 @@ export async function verifyFix({
222
229
  _lap('poc');
223
230
  const pocOk = pocLeg.status !== 'still-exploitable';
224
231
 
232
+ // FR-308: "a mitigation or workaround cannot be represented as a full
233
+ // fix" — gateFixOutput's tier/residual check is a pure self-consistency
234
+ // check (fixMeta.signals is agent-self-reported; see fix-honesty-gate.js's
235
+ // header for why nothing there is server-computable). pocLeg IS
236
+ // server-computable — a real execution result, not a claim — so it is
237
+ // passed through as the one MECHANICAL cross-check available: a
238
+ // self-reported FULL tier is refuted, not just internally inconsistent,
239
+ // when the PoC still demonstrates the vulnerability against the patch.
240
+ // D-0024: `fixMeta` is a shared envelope — FR-307/FR-1002's `approval`
241
+ // key lives alongside FR-308's completeness self-report (`residual`/
242
+ // `verdict`/`evidence`/`signals`). Gating on mere object-truthiness meant
243
+ // a caller supplying ONLY `approval` (a real, common shape once the CLI's
244
+ // --approved-by flag and the MCP schema fix made that reachable) got
245
+ // silently gated on an UNRELATED FR-308 self-consistency check it never
246
+ // engaged with — computeFixTier(undefined) defaults to MITIGATION, which
247
+ // then demands a `residual` nobody was ever asked to supply, blocking an
248
+ // otherwise-genuine, approved fix for a reason that has nothing to do
249
+ // with completeness honesty. Scope the gate to fixMeta shapes that
250
+ // actually make a completeness-adjacent claim.
251
+ const hasHonestyClaim = fixMeta && typeof fixMeta === 'object' &&
252
+ (fixMeta.residual !== undefined || fixMeta.verdict !== undefined ||
253
+ fixMeta.evidence !== undefined || fixMeta.signals !== undefined);
254
+ let honesty = null;
255
+ if (hasHonestyClaim) {
256
+ try { honesty = gateFixOutput(fixMeta, { pocLeg }); } catch { honesty = null; }
257
+ }
258
+ _lap('honesty');
259
+
225
260
  const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && pocOk && (honesty ? honesty.ok : true);
261
+
262
+ // FR-305 (assurance-hardening PRD): `ok` alone conflates "every leg
263
+ // genuinely ran and passed" with "passed, but a required leg was skipped
264
+ // or unavailable" — `lint.ok`/`testsOk` are both true in the skipped case
265
+ // by design (this codebase does not fail-closed just because a repo has
266
+ // no linter or no detected test runner), so a caller checking only `ok`
267
+ // cannot tell the difference. `lint.skipped` is only ever true when a
268
+ // linter WAS configured but its binary could not be run (missing config
269
+ // entirely returns `runner: 'none'` with no `skipped` field at all — that
270
+ // is a genuine N/A, nothing was required, not a degradation). `verifiedFull`
271
+ // is the honest label: true only when nothing that WAS required was
272
+ // skipped. A caller must never present `ok: true, verifiedFull: false` as
273
+ // "fully verified" — `degradedLegs` names exactly what was skipped so a
274
+ // report can say so plainly instead of a bare pass.
275
+ const degradedLegs = [];
276
+ if (lint.skipped) degradedLegs.push(`lint: ${lint.runner} not installed`);
277
+ if (tests.skipped) degradedLegs.push(`tests: skipped (${tests.reason})`);
278
+ const verifiedFull = ok && degradedLegs.length === 0;
279
+
226
280
  const durations = { ...stages, totalMs: Date.now() - t0 };
227
281
  const summary = [
228
282
  `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
@@ -244,6 +298,8 @@ export async function verifyFix({
244
298
  : pocLeg.status === 'still-exploitable' ? `poc: FAIL — the proof-of-concept still demonstrates the vulnerability against the patch`
245
299
  : pocLeg.status === 'no-longer-proven' ? 'poc: PASS (ran against the patch and no longer demonstrates the vulnerability)'
246
300
  : `poc: inconclusive — not counted either way (${pocLeg.reason || 'no detail reported'})`,
301
+ // FR-305: never let a degraded pass read the same as a full one.
302
+ ok && !verifiedFull ? `NOTE: PASSED, but NOT fully verified — ${degradedLegs.join('; ')}` : null,
247
303
  ].filter(Boolean).join('\n');
248
304
  // Persist the attempt so the distribution can be reported from real runs.
249
305
  // `testsRan` is the load-bearing field: it is what keeps "verified with no
@@ -256,6 +312,7 @@ export async function verifyFix({
256
312
  at: new Date().toISOString(),
257
313
  stableId: originalFindingStableId || null,
258
314
  ok,
315
+ verifiedFull,
259
316
  testsRan: !tests.skipped,
260
317
  testsPassed: tests.skipped ? null : tests.passed === true,
261
318
  testedPrePatch: _testedPrePatch,
@@ -268,5 +325,5 @@ export async function verifyFix({
268
325
  });
269
326
  }
270
327
 
271
- return { ok, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary };
328
+ return { ok, verifiedFull, degradedLegs, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary };
272
329
  }
Binary file
@@ -13,6 +13,8 @@
13
13
  //
14
14
  // Fail-closed: any LLM error → template fallback, never a missing field.
15
15
 
16
+ import { evaluateEgress } from '../egress/policy.js';
17
+
16
18
  const TEMPLATES = {
17
19
  'sql-injection': (f) =>
18
20
  `An unauthenticated attacker sends a crafted request containing UNION-style SQL syntax in the ${f.source?.variable || 'tainted'} field. The server's database driver executes the injected query verbatim, returning rows from any table the connection has read access to. Typical impact: full table dump of users (emails, password hashes), bypass of authentication via boolean-blind exfiltration. If the DB role has write privileges, the attacker can also INSERT/UPDATE arbitrary rows. Recovery cost: incident response, customer notification, password reset, regulatory reporting if PII leaked.`,
@@ -60,9 +62,12 @@ function _renderTemplate(f) {
60
62
 
61
63
  // Optional LLM call. Disabled by default; opt-in via env. Falls back to the
62
64
  // template on any error.
63
- async function _renderLlm(f) {
65
+ async function _renderLlm(f, scanRoot) {
64
66
  const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
65
67
  if (!endpoint) return null;
68
+ // FR-601: evaluated before the prompt below is built.
69
+ const egressDecision = evaluateEgress({ scanRoot, purpose: 'flow-narration', endpoint });
70
+ if (!egressDecision.allowed) return null;
66
71
  const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
67
72
  const headers = { 'Content-Type': 'application/json' };
68
73
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
@@ -101,7 +106,7 @@ export async function annotateNarration(findings, opts = {}) {
101
106
  f.narration = null;
102
107
  continue;
103
108
  }
104
- let text = useLlm ? await _renderLlm(f) : null;
109
+ let text = useLlm ? await _renderLlm(f, opts.scanRoot) : null;
105
110
  if (!text) text = _renderTemplate(f);
106
111
  f.narration = text;
107
112
  }
@@ -19,14 +19,19 @@
19
19
  import * as cp from 'node:child_process';
20
20
  import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
22
23
 
23
24
  const MAX_BLAME_PER_SCAN = 500;
24
25
  const SUBPROC_TIMEOUT_MS = 1500;
25
26
  const PROMPT_MARKER_RE = /(?:^|\n)(?:Prompt|User asked|Original request|Co-Authored-By:\s*Claude)/i;
26
27
 
28
+ // `scanRoot` is the scanned project's repository, not this project's own
29
+ // trusted checkout — every call below is hardened per FR-PROV-024 / the
30
+ // second Finding Provenance PRD audit (same exposure class as
31
+ // provenance/git-evidence.js's `_run`).
27
32
  function _isGitRepo(scanRoot) {
28
33
  try {
29
- cp.execFileSync('git', ['rev-parse', '--git-dir'], { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS });
34
+ cp.execFileSync('git', hardenGitArgs(['rev-parse', '--git-dir']), { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS, env: hardenGitEnv() });
30
35
  return true;
31
36
  } catch { return false; }
32
37
  }
@@ -36,10 +41,13 @@ function _blame(scanRoot, file, line) {
36
41
  const rel = path.isAbsolute(file) ? path.relative(scanRoot, file) : file;
37
42
  if (rel.startsWith('..')) return null;
38
43
  try {
44
+ // `--no-textconv`: VERIFIED exploitable without it — `git blame`
45
+ // applies a hostile `.gitattributes` textconv driver by default in
46
+ // current git, same as provenance/git-evidence.js's blameLine.
39
47
  const stdout = cp.execFileSync(
40
48
  'git',
41
- ['blame', '-L', `${line},${line}`, '--porcelain', '--', rel],
42
- { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
49
+ hardenGitArgs(['blame', '-L', `${line},${line}`, '--porcelain', '--no-textconv', '--', rel]),
50
+ { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
43
51
  );
44
52
  return _parsePorcelain(stdout);
45
53
  } catch { return null; }
@@ -66,8 +74,8 @@ function _parsePorcelain(out) {
66
74
  function _fullMessage(scanRoot, sha) {
67
75
  try {
68
76
  return cp.execFileSync(
69
- 'git', ['show', '-s', '--format=%B', sha],
70
- { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
77
+ 'git', hardenGitArgs(['show', '-s', '--no-textconv', '--format=%B', sha]),
78
+ { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
71
79
  );
72
80
  } catch { return ''; }
73
81
  }
@@ -0,0 +1,140 @@
1
+ // FR-707 (assurance-hardening PRD): "Support legal hold and policy-
2
+ // authorized retention exceptions | Legal hold is identity-bound, reasoned,
3
+ // time-bounded where applicable, and auditable."
4
+ //
5
+ // A third instance of the recurring {owner, reason, expires_at} exception
6
+ // shape this codebase already uses twice — `posture/suppressions.js`'s
7
+ // pro-tier exception (scoped to a FINDING) and `posture/compliance-policy.js`'s
8
+ // structured `not-applicable` (scoped to a COMPLIANCE CONTROL). Per D-0025,
9
+ // these are deliberately distinct mechanisms serving different subjects, not
10
+ // one shared module — this file is the third subject: a STATE ARTIFACT.
11
+ //
12
+ // Field naming matches the existing two schemas' snake_case convention
13
+ // (`owner`, `reason`, `expires_at`) rather than inventing a fourth style.
14
+ //
15
+ // identity-bound -> `owner` (required, who placed the hold and is
16
+ // accountable for lifting it)
17
+ // reasoned -> `reason` (required — "we might need this later" is
18
+ // not a reason; same discipline suppressions.js
19
+ // already enforces for its own exceptions)
20
+ // time-bounded
21
+ // where applicable -> `expires_at` is OPTIONAL: a null/absent value is
22
+ // an INDEFINITE hold, which the acceptance
23
+ // criterion's own "where applicable" phrase
24
+ // explicitly allows (a genuine legal matter may have
25
+ // no known end date) — an ISO date value behaves
26
+ // exactly like FR-506/FR-1004's own expiring
27
+ // exceptions: once past, the hold is no longer
28
+ // active and the artifact is exposed to its normal
29
+ // retention TTL again.
30
+ // auditable -> persisted as a single JSON array under
31
+ // `.agentic-security/legal-holds.json` (itself
32
+ // registered as operator-config — an operator/legal
33
+ // team's own input, never scanner-written from scan
34
+ // results), readable via `listLegalHolds`.
35
+ //
36
+ // Consulted from TWO places, not one: `retention-policy.js#findExpiredArtifacts`
37
+ // (defense in depth for any caller reaching it directly) AND `cmdReset`
38
+ // itself for its PLAIN (non-`--expired`) path, which deletes every
39
+ // registered 'generated' artifact unconditionally and would otherwise blow
40
+ // through a hold that only gated TTL expiry.
41
+
42
+ import * as fs from 'node:fs';
43
+ import { statePath, safeWriteState } from './state-dir.js';
44
+ import { isRegisteredArtifact } from './artifact-registry.js';
45
+
46
+ export const LEGAL_HOLD_FILE = 'legal-holds.json';
47
+
48
+ function _loadRaw(scanRoot) {
49
+ let fp;
50
+ try { fp = statePath(scanRoot, LEGAL_HOLD_FILE); } catch { return []; }
51
+ let raw;
52
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { return []; }
53
+ try {
54
+ const doc = JSON.parse(raw);
55
+ return Array.isArray(doc) ? doc : [];
56
+ } catch { return []; }
57
+ }
58
+
59
+ /**
60
+ * All legal holds ever recorded for this project, malformed entries
61
+ * dropped rather than throwing. Never filters by expiry — see
62
+ * `isUnderHold`/`listLegalHolds` for that.
63
+ */
64
+ export function loadLegalHolds(scanRoot) {
65
+ return _loadRaw(scanRoot).filter(h => h && typeof h === 'object' && typeof h.artifact === 'string' && typeof h.owner === 'string' && typeof h.reason === 'string');
66
+ }
67
+
68
+ /**
69
+ * Is `artifactName` currently protected by an active (non-expired) hold?
70
+ * Returns the matching hold record, or null. Multiple holds on the same
71
+ * artifact are permitted (e.g. two independent legal matters); the first
72
+ * still-active one found is returned.
73
+ */
74
+ export function isUnderHold(artifactName, holds, now = Date.now()) {
75
+ for (const h of holds) {
76
+ if (h.artifact !== artifactName) continue;
77
+ if (!h.expires_at) return h; // indefinite hold — always active
78
+ const t = Date.parse(h.expires_at);
79
+ if (!Number.isFinite(t) || t >= now) return h;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Active (non-expired) holds only, unless `includeExpired`. For
86
+ * `legal-hold list` / auditing.
87
+ */
88
+ export function listLegalHolds(scanRoot, { includeExpired = false, now = Date.now() } = {}) {
89
+ const holds = loadLegalHolds(scanRoot);
90
+ if (includeExpired) return holds;
91
+ return holds.filter(h => {
92
+ if (!h.expires_at) return true;
93
+ const t = Date.parse(h.expires_at);
94
+ return !Number.isFinite(t) || t >= now;
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Add a legal hold. Validates identity-bound + reasoned up front (both
100
+ * required, non-empty) and that `expires_at`, if given, is a parseable
101
+ * date in the future — an already-expired hold would be a hold that
102
+ * protects nothing, which is never a legitimate request. `artifact` must
103
+ * name a real registered artifact (artifact-registry.js) — a hold on an
104
+ * unrecognised name can never protect anything and almost always means a
105
+ * typo. Returns `{ok:true, hold}` or `{ok:false, reason}`; never throws.
106
+ */
107
+ export function addLegalHold(scanRoot, { artifact, owner, reason, expires_at } = {}) {
108
+ if (!artifact || typeof artifact !== 'string') return { ok: false, reason: '--artifact is required' };
109
+ if (!isRegisteredArtifact(artifact)) return { ok: false, reason: `"${artifact}" is not a registered state artifact` };
110
+ if (!owner || typeof owner !== 'string') return { ok: false, reason: '--owner is required (identity-bound)' };
111
+ if (!reason || typeof reason !== 'string') return { ok: false, reason: '--reason is required (reasoned)' };
112
+ if (expires_at) {
113
+ const t = Date.parse(expires_at);
114
+ if (!Number.isFinite(t)) return { ok: false, reason: 'expires_at must be a parseable date' };
115
+ if (t < Date.now()) return { ok: false, reason: 'expires_at is in the past — a hold that already expired protects nothing' };
116
+ }
117
+ const hold = { artifact, owner, reason, expires_at: expires_at || null, created_at: new Date().toISOString() };
118
+ const holds = _loadRaw(scanRoot);
119
+ holds.push(hold);
120
+ const fp = statePath(scanRoot, LEGAL_HOLD_FILE);
121
+ if (!safeWriteState(fp, JSON.stringify(holds, null, 2) + '\n')) {
122
+ return { ok: false, reason: 'state writes are disabled (--no-state) or this is not a safe state directory' };
123
+ }
124
+ return { ok: true, hold };
125
+ }
126
+
127
+ /**
128
+ * Remove every hold on `artifact` (lifting a hold, not letting it expire).
129
+ * Returns the number removed. A no-op (0) if none existed — never an error.
130
+ */
131
+ export function removeLegalHold(scanRoot, artifact) {
132
+ const holds = _loadRaw(scanRoot);
133
+ const remaining = holds.filter(h => !(h && h.artifact === artifact));
134
+ const removedCount = holds.length - remaining.length;
135
+ if (removedCount > 0) {
136
+ const fp = statePath(scanRoot, LEGAL_HOLD_FILE);
137
+ safeWriteState(fp, JSON.stringify(remaining, null, 2) + '\n');
138
+ }
139
+ return removedCount;
140
+ }
@@ -13,6 +13,7 @@
13
13
  // strategies (encoding/role-play/authority/etc.), severity-graded report.
14
14
 
15
15
  import { RED_TEAM_PROMPTS, ATTACK_STRATEGIES, PLUGIN_SEVERITY, categorizePrompts, pluginCoverage } from './llm-redteam-prompts.js';
16
+ import { evaluateEgress } from '../egress/policy.js';
16
17
 
17
18
  // ─── STATIC mode ─────────────────────────────────────────────────────────
18
19
  // Scan the repo for prompt files / system-prompt strings and check whether
@@ -173,8 +174,16 @@ function judgeResponse(response, expectedRejection) {
173
174
  * opts: { endpoint, apiKey?, categories?, strategies?, concurrency? }
174
175
  * Returns { results: [...], summary: {...} } */
175
176
  export async function runActiveRedteam(opts = {}) {
176
- const { endpoint, apiKey, categories, strategies, concurrency = 4 } = opts;
177
+ const { endpoint, apiKey, categories, strategies, concurrency = 4, scanRoot } = opts;
177
178
  if (!endpoint) throw new Error('endpoint URL required');
179
+ // FR-601: one decision for the whole batch (same endpoint for every
180
+ // prompt) — evaluated before any prompt in the corpus is sent, not
181
+ // per-prompt, since a policy verdict on an endpoint does not vary by
182
+ // which static red-team prompt is about to be sent to it.
183
+ const egressDecision = evaluateEgress({ scanRoot, purpose: 'llm-redteam', endpoint });
184
+ if (!egressDecision.allowed) {
185
+ throw new Error(`egress policy denied this call: ${egressDecision.reason}`);
186
+ }
178
187
 
179
188
  let prompts = RED_TEAM_PROMPTS;
180
189
  if (Array.isArray(categories) && categories.length) {
@@ -11,6 +11,8 @@
11
11
  // or the command runner) collects the unified diff and feeds hunks into classifyHunk.
12
12
 
13
13
  import * as cp from 'node:child_process';
14
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
15
+ import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
14
16
 
15
17
  // Patterns that fire on the deletion side (auth/check removed).
16
18
  const DEL_PATTERNS = [
@@ -26,6 +28,21 @@ const DEL_PATTERNS = [
26
28
  kind: 'security-middleware-removed', sev: 'medium', evidence: 'Security middleware removed' },
27
29
  ];
28
30
 
31
+ // FR-307: a new/modified PII/PHI-shaped field is one of the named
32
+ // high-impact categories ("PII" — see HIGH_IMPACT_CATEGORY_OF_KIND below).
33
+ // Reuses dataflow/privacy-taxonomy.js's FR-402 field-name vocabulary
34
+ // (built-in defaults only — this is a coarse architectural-risk signal,
35
+ // not the authoritative privacy classification egress/redact.js and
36
+ // dataflow/privacy-taint.js already own) rather than inventing a second,
37
+ // parallel PII field-name list that could drift from the first.
38
+ const _PII_FIELD_NAMES = [
39
+ ...(loadPrivacyTaxonomy(null).taxonomy.PII?.patterns || []),
40
+ ...(loadPrivacyTaxonomy(null).taxonomy.PHI?.patterns || []),
41
+ ];
42
+ const PII_FIELD_RE = _PII_FIELD_NAMES.length
43
+ ? new RegExp('(?:' + _PII_FIELD_NAMES.join('|') + ')\\s*[:=]', 'i')
44
+ : /(?!)/; // never matches — degrades safely if the taxonomy import ever returns nothing
45
+
29
46
  // Patterns that fire on the addition side (new attack surface introduced).
30
47
  const ADD_PATTERNS = [
31
48
  { re: /\b(?:app|router)\s*\.\s*(?:get|post|put|patch|delete|all)\s*\(\s*['"][^'"]+['"]/,
@@ -56,8 +73,38 @@ const ADD_PATTERNS = [
56
73
  kind: 'pipeline-floating-tag', sev: 'medium', evidence: 'GitHub Actions step pinned to floating tag' },
57
74
  { re: /\bprivileged\s*:\s*true\b/i,
58
75
  kind: 'new-iac-privilege', sev: 'high', evidence: 'Container/pod marked privileged' },
76
+ // FR-307: crypto, PII, and schema — the three named high-impact
77
+ // categories the pre-existing kind taxonomy above did not yet cover.
78
+ { re: /\b(?:md5|sha1)\s*\(/i,
79
+ kind: 'weak-crypto-added', sev: 'high', evidence: 'Weak/deprecated hash primitive (MD5/SHA-1) referenced' },
80
+ { re: /createCipheriv\s*\(\s*['"`](?:des|des-ede3|rc4|bf|rc2)/i,
81
+ kind: 'weak-crypto-added', sev: 'critical', evidence: 'Weak/deprecated cipher algorithm referenced' },
82
+ { re: /\b(?:createCipheriv|createDecipheriv|generateKeyPair(?:Sync)?|crypto\.subtle|jwt\.sign|jsonwebtoken)\s*[.(]/,
83
+ kind: 'crypto-primitive-changed', sev: 'high', evidence: 'Cryptographic primitive or JWT signing usage added or changed' },
84
+ { re: PII_FIELD_RE,
85
+ kind: 'new-pii-field', sev: 'high', evidence: 'PII/PHI-shaped field introduced or modified' },
86
+ { re: /\b(?:ALTER\s+TABLE|CREATE\s+TABLE|DROP\s+(?:TABLE|COLUMN)|ADD\s+COLUMN)\b/i,
87
+ kind: 'schema-change', sev: 'medium', evidence: 'Database schema change (DDL)' },
59
88
  ];
60
89
 
90
+ // FR-307: which `kind`s constitute one of the PRD's named high-impact
91
+ // change classes ("Auth, authZ, crypto, PII, schema, infrastructure
92
+ // privilege, and public API changes cannot auto-apply without approval
93
+ // evidence"). A finding whose kind is NOT in this map is still scored for
94
+ // severity as before, but does not by itself trigger the approval gate —
95
+ // only these specific, named categories do.
96
+ export const HIGH_IMPACT_CATEGORY_OF_KIND = {
97
+ 'auth-removed': 'auth',
98
+ 'priv-from-body': 'authZ',
99
+ 'new-pii-field': 'pii',
100
+ 'weak-crypto-added': 'crypto',
101
+ 'crypto-primitive-changed': 'crypto',
102
+ 'schema-change': 'schema',
103
+ 'new-iac-privilege': 'infra-privilege',
104
+ 'pipeline-perms-widened': 'infra-privilege',
105
+ 'new-endpoint': 'public-api',
106
+ };
107
+
61
108
  // Routine / low-risk patterns (NEVER classify higher than 'low').
62
109
  const ROUTINE_PATTERNS = [
63
110
  /^\+\s*\/\//, // adding a comment
@@ -150,14 +197,76 @@ function summarize(findings) {
150
197
  }
151
198
 
152
199
  // Convenience: invoke `git diff <ref>...HEAD` for the project and classify it.
200
+ //
201
+ // `rootDir` is the scanned project's repository, not this project's own
202
+ // trusted checkout. `--no-textconv` is load-bearing here, not
203
+ // defense-in-depth: this renders real diff content, the same shape VERIFIED
204
+ // exploitable via a hostile `.gitattributes` textconv driver in
205
+ // provenance/git-evidence.js's `commitDiff` (FR-PROV-024 / the second audit).
206
+ //
207
+ // `--no-ext-diff` is ALSO load-bearing and is a SEPARATE surface from
208
+ // `--no-textconv`: `git diff` (unlike `git show`/`git log -p`/`git blame`)
209
+ // honours an external diff driver (`.gitattributes` `diff=<name>` +
210
+ // `.git/config [diff "<name>"] command=<script>`, or the global
211
+ // `diff.external`) even with `--no-textconv` set — VERIFIED empirically: the
212
+ // exact argv this function shipped with before this fix
213
+ // (`-c core.fsmonitor= -c core.hooksPath=/dev/null diff --unified=0
214
+ // --no-textconv <ref>...HEAD`) still ran an attacker's `diff.evil.command`
215
+ // script. This was the live, remaining RCE a second review caught: this
216
+ // function is the real entry point for `/scan --diff` and
217
+ // `security-material-change`, both invoked against the scanned project.
153
218
  export function classifyGitDiff(rootDir, ref) {
154
219
  let out;
155
220
  try {
156
- out = cp.execFileSync('git', ['diff', '--unified=0', `${ref}...HEAD`], {
157
- cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
221
+ out = cp.execFileSync('git', hardenGitArgs(['diff', '--unified=0', '--no-textconv', '--no-ext-diff', `${ref}...HEAD`]), {
222
+ cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
158
223
  });
159
224
  } catch (e) {
160
225
  return { materialRisk: 'unknown', error: 'git diff failed: ' + (e.message || e), findings: [], perKindCounts: {}, byFile: {} };
161
226
  }
162
227
  return classifyDiff(out);
163
228
  }
229
+
230
+ /**
231
+ * FR-307: classify a candidate FIX (not a committed diff) for high-impact
232
+ * change classes. `files` is `{relPath: {before, after}}` — exactly the
233
+ * before/after content shape fix/apply-fix-service.js already has on hand
234
+ * for every candidate write, so no git invocation or unified-diff text is
235
+ * needed here. A crude but sufficient line-set diff (lines only in `after`
236
+ * are additions, lines only in `before` are deletions) is enough for
237
+ * PATTERN matching — classifyHunk operates per-line regardless of hunk
238
+ * boundaries, so an imprecise line-diff still detects the same risk
239
+ * signals a real diff would.
240
+ *
241
+ * Returns the same shape classifyDiff() does, plus `highImpactCategories`
242
+ * — the deduplicated, sorted list of named categories
243
+ * (auth/authZ/crypto/pii/schema/infra-privilege/public-api) any finding in
244
+ * this candidate belongs to. An empty list means nothing in the candidate
245
+ * matched one of the PRD's named high-impact classes — the approval gate
246
+ * in apply-fix-service.js is a no-op in that case, same
247
+ * restricts-nothing-until-triggered default this codebase's other policy
248
+ * gates (egress/policy.js, dataflow/privacy-sink-policy.js) already follow.
249
+ */
250
+ export function classifyFixMaterialRisk(files) {
251
+ const findings = [];
252
+ for (const [file, pair] of Object.entries(files || {})) {
253
+ const before = String(pair?.before ?? '');
254
+ const after = String(pair?.after ?? '');
255
+ if (before === after) continue;
256
+ const beforeLines = before.split('\n');
257
+ const afterLines = after.split('\n');
258
+ const beforeSet = new Set(beforeLines);
259
+ const afterSet = new Set(afterLines);
260
+ const add = afterLines.filter(l => !beforeSet.has(l));
261
+ const del = beforeLines.filter(l => !afterSet.has(l));
262
+ if (!add.length && !del.length) continue;
263
+ findings.push(...classifyHunk({ file, add, del }));
264
+ }
265
+ const result = summarize(findings);
266
+ const categories = new Set();
267
+ for (const f of result.findings) {
268
+ const cat = HIGH_IMPACT_CATEGORY_OF_KIND[f.kind];
269
+ if (cat) categories.add(cat);
270
+ }
271
+ return { ...result, highImpactCategories: [...categories].sort() };
272
+ }
@@ -8,6 +8,29 @@
8
8
  // when to persist firstSeenAt back into the baseline.
9
9
 
10
10
  import * as crypto from 'node:crypto';
11
+ import { AGE_BASIS } from './provenance/schema.js';
12
+
13
+ // FR-PROV-019: "reports never show an age without its basis and confidence."
14
+ // FINDING_ORIGIN is the only basis backed by a resolved git commit for the
15
+ // finding's actual introduction; EARLIEST_OBSERVABLE is also git-derived but
16
+ // partial (weaker claim, no exact introduction commit). UNCOMMITTED and
17
+ // FIRST_OBSERVED are both wall-clock fallbacks — the age is a first-seen
18
+ // timestamp, never resolved against git history — and must say so honestly
19
+ // rather than read like a proven date.
20
+ function _ageBasisLabel(ageBasis, confidence) {
21
+ const level = confidence?.level && confidence.level !== 'unknown' ? confidence.level.toUpperCase() : null;
22
+ switch (ageBasis) {
23
+ case AGE_BASIS.FINDING_ORIGIN:
24
+ return `proven origin${level ? `, ${level} confidence` : ''}`;
25
+ case AGE_BASIS.EARLIEST_OBSERVABLE:
26
+ return `earliest observable commit, partial history${level ? `, ${level} confidence` : ''}`;
27
+ case AGE_BASIS.UNCOMMITTED:
28
+ return 'uncommitted — first-seen fallback, origin not proven';
29
+ case AGE_BASIS.FIRST_OBSERVED:
30
+ default:
31
+ return 'first-seen fallback — origin not proven';
32
+ }
33
+ }
11
34
 
12
35
  // Stable fingerprint for cross-scan finding identity. Mirrors the dedupe key.
13
36
  // Exported so a caller can compute the "removed since baseline" (i.e. fixed)
@@ -37,6 +60,25 @@ export function stampFindingTimestamps(findings, baselineMap = new Map(), now =
37
60
  f.lastSeenAt = nowIso;
38
61
  const firstMs = Date.parse(f.firstSeenAt);
39
62
  f.ageDays = Math.max(0, Math.floor((now - firstMs) / 86400000));
63
+ // FR-PROV-019: age/SLA basis. ageDays above stays pure wall-clock —
64
+ // every existing SLA/computeMTTR consumer keeps its current meaning.
65
+ // ageBasis + provenAgeDays are ADDITIVE: a report can show both and
66
+ // explain the discrepancy, never silently swap which number "age" means.
67
+ const status = f.findingProvenance?.status;
68
+ const origin = f.findingProvenance?.findingOrigin;
69
+ if (status === 'complete' && origin?.authorDate) {
70
+ f.ageBasis = AGE_BASIS.FINDING_ORIGIN;
71
+ f.provenAgeDays = Math.max(0, Math.floor((now - Date.parse(origin.authorDate)) / 86400000));
72
+ } else if (status === 'partial' && origin?.authorDate) {
73
+ f.ageBasis = AGE_BASIS.EARLIEST_OBSERVABLE;
74
+ f.provenAgeDays = Math.max(0, Math.floor((now - Date.parse(origin.authorDate)) / 86400000));
75
+ } else if (status === 'uncommitted') {
76
+ f.ageBasis = AGE_BASIS.UNCOMMITTED;
77
+ f.provenAgeDays = f.ageDays;
78
+ } else {
79
+ f.ageBasis = AGE_BASIS.FIRST_OBSERVED;
80
+ f.provenAgeDays = f.ageDays;
81
+ }
40
82
  }
41
83
  return findings;
42
84
  }
@@ -68,28 +110,49 @@ export function findingsExceedingSLA(findings, slaDays = null) {
68
110
  });
69
111
  }
70
112
 
71
- // Median age (days) of the currently-open findings a single-scan proxy for
72
- // "how long has this debt been sitting". True MTTR (computeMTTR) needs the set
73
- // of findings that were FIXED; this reports the open backlog's median age so a
74
- // scan can show whether debt is getting older. Returns null on empty input.
75
- // Local surfaced only through renderSlaSummary (its sole consumer).
76
- function medianOpenAgeDays(findings) {
77
- const ages = (findings || []).map(f => f.ageDays || 0).sort((a, b) => a - b);
78
- if (!ages.length) return null;
79
- return ages[Math.floor(ages.length / 2)];
113
+ // Median age (days) of the currently-open findings, PLUS the ageBasis/
114
+ // confidence of whichever finding landed on that median a single-scan proxy
115
+ // for "how long has this debt been sitting". True MTTR (computeMTTR) needs the
116
+ // set of findings that were FIXED; this reports the open backlog's median age
117
+ // so a scan can show whether debt is getting older. Returns null on empty
118
+ // input. Local — surfaced only through renderSlaSummary (its sole consumer).
119
+ //
120
+ // FR-PROV-019: the days figure prefers `provenAgeDays` (git-derived when
121
+ // available) over the pure-wall-clock `ageDays`, and the ageBasis/confidence
122
+ // travel WITH the day count they describe — never a bare number with the
123
+ // basis looked up separately, which is how the original miss happened
124
+ // (ageBasis was stamped onto the finding but never reached the string that
125
+ // printed its age). Findings stamped by an older/test caller that never ran
126
+ // through stampFindingTimestamps (no ageBasis at all) degrade to the same
127
+ // honest "not proven" label FIRST_OBSERVED gets, never a false claim.
128
+ function medianOpenAge(findings) {
129
+ const entries = (findings || [])
130
+ .map(f => ({
131
+ days: f.provenAgeDays != null ? f.provenAgeDays : (f.ageDays || 0),
132
+ ageBasis: f.ageBasis || AGE_BASIS.FIRST_OBSERVED,
133
+ confidence: f.findingProvenance?.confidence || null,
134
+ }))
135
+ .sort((a, b) => a.days - b.days);
136
+ if (!entries.length) return null;
137
+ return entries[Math.floor(entries.length / 2)];
80
138
  }
81
139
 
82
140
  // One-line SLA-breach summary for surfacing after a scan (#10). Returns null
83
- // when nothing is past its per-severity SLA. Pairs with medianOpenAgeDays for a
141
+ // when nothing is past its per-severity SLA. Pairs with medianOpenAge for a
84
142
  // "is my security debt aging" readout that the vibecoder can act on.
143
+ //
144
+ // FR-PROV-019: never prints the median age number without its basis and
145
+ // confidence alongside it — see medianOpenAge/_ageBasisLabel above.
85
146
  export function renderSlaSummary(findings, slaDays = null) {
86
147
  const breached = findingsExceedingSLA(findings || [], slaDays);
87
148
  if (!breached.length) return null;
88
149
  const bySev = {};
89
150
  for (const f of breached) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
90
151
  const parts = ['critical', 'high', 'medium', 'low', 'info'].filter(s => bySev[s]).map(s => `${bySev[s]} ${s}`);
91
- const median = medianOpenAgeDays(findings);
92
- const ageNote = median != null ? ` (median open age ${median}d)` : '';
152
+ const median = medianOpenAge(findings);
153
+ const ageNote = median != null
154
+ ? ` (median open age ${median.days}d, ${_ageBasisLabel(median.ageBasis, median.confidence)})`
155
+ : '';
93
156
  return `${breached.length} finding(s) past remediation SLA: ${parts.join(', ')}${ageNote}`;
94
157
  }
95
158