@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.
- package/CHANGELOG.md +555 -0
- package/bin/agentic-security.js +770 -49
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +117 -18
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +24 -6
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +187 -52
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +117 -18
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +27 -5
- 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 +173 -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 +23 -9
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- 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 +1048 -302
- package/src/fix/apply-fix-service.js +404 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +177 -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 +154 -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 +108 -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 +123 -0
- package/src/posture/accuracy-scorecard.js +156 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +241 -0
- package/src/posture/auditor-walkthrough.js +186 -21
- 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 +324 -17
- package/src/posture/cross-repo-memory.js +7 -2
- 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 +137 -20
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +65 -8
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/git-history.js +13 -5
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +111 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +47 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +64 -5
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +248 -5
- package/src/report/oscal.js +7 -2
- package/src/runScan.js +34 -5
- package/src/sast/cpp.js +3 -14
- package/src/sast/rate-limit.js +33 -3
- package/src/sca/llm-function-extract.js +6 -0
- 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
|
-
|
|
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
|
}
|
package/src/posture/fleet.js
CHANGED
|
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
|
+
}
|
package/src/posture/mttr.js
CHANGED
|
@@ -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
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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 =
|
|
92
|
-
const ageNote = median != null
|
|
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
|
|