@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1192 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +20 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -80,6 +80,21 @@ export async function proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_
|
|
|
80
80
|
if (!poc?.code) {
|
|
81
81
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|
|
82
82
|
}
|
|
83
|
+
// X-009: language witnesses (generated Nix shell scripts; Haskell programs when a toolchain exists). The proof is the
|
|
84
|
+
// same marker file; the SCOPE is recorded so a configuration/generated-script proof is never read as runtime
|
|
85
|
+
// exploitation of a deployed system.
|
|
86
|
+
if (poc.lang === 'nix-shell' || poc.lang === 'haskell') {
|
|
87
|
+
const W = await import('../language/witness.js');
|
|
88
|
+
const r = poc.lang === 'haskell' ? W.runHaskellWitness(poc.code, { timeoutMs, force }) : W.runNixScriptWitness(poc.witness, { timeoutMs, force });
|
|
89
|
+
const scope = poc.lang === 'nix-shell'
|
|
90
|
+
? { proofKind: 'generated-script-execution', doesNotProve: 'that a deployed host evaluates this configuration or that the interpolated option is attacker-controlled there' }
|
|
91
|
+
: { proofKind: 'source-witness-execution', doesNotProve: 'that the deployed application receives attacker input on this path' };
|
|
92
|
+
return attachProofTier(finding, _evidence({
|
|
93
|
+
tier: r.status === 'reproduced' ? 'execution-proven' : r.status === 'not-reproduced' ? 'proof-failed' : proofTierOf(finding),
|
|
94
|
+
backend: r.backend || detectBackend(), ran: !!r.ran, observed: r.observed || null, reason: r.reason || null, exitCode: r.exitCode ?? null, timedOut: !!r.timedOut,
|
|
95
|
+
witnessStatus: r.status, ...scope,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
83
98
|
if (poc.lang !== 'js') {
|
|
84
99
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: `unsupported poc language: ${poc.lang}` }));
|
|
85
100
|
}
|
|
@@ -90,6 +90,13 @@ export function classifyFinding(finding, fileContents) {
|
|
|
90
90
|
if (finding.sanitizerMismatch) {
|
|
91
91
|
return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
|
|
92
92
|
}
|
|
93
|
+
// Haskell and Nix findings are judged on the IR / effective configuration, never on adjacent text: the taint
|
|
94
|
+
// engine already refuses a flow that a DOMINATING, context-matched guard covers (language/haskell-guards.js), and
|
|
95
|
+
// a NixOS value is decided by option precedence. A sanitizer-looking word near the sink (a comment, a helper that
|
|
96
|
+
// is not on the path, a control for a different family) is not control evidence, so text proximity never blocks.
|
|
97
|
+
if (/\.(?:l?hs|nix)$/i.test(String(finding.file || ''))) {
|
|
98
|
+
return { verdict: 'survived', reasons: ['judged on the IR/effective configuration: adjacent text is not control evidence'] };
|
|
99
|
+
}
|
|
93
100
|
const window = _pathWindow(finding, fileContents);
|
|
94
101
|
if (!window || !window.trim()) {
|
|
95
102
|
return { verdict: 'unproven', reasons: ['no source context available to attempt falsification'] };
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
//
|
|
11
11
|
// One definition, no cycle, both callers import from here.
|
|
12
12
|
|
|
13
|
+
import { LANGUAGE_FAMILY_ALIASES } from '../language/compliance-map.js';
|
|
14
|
+
|
|
13
15
|
export const COMPLIANCE_FAMILY_ALIAS = {
|
|
14
16
|
// ASVS spells it `sqli`; every detector emits `sql-injection` (or a
|
|
15
17
|
// language-prefixed variant, which the suffix rule below does NOT cover
|
|
@@ -45,3 +47,8 @@ export function resolveFamilyKeys(fam, availableKeys) {
|
|
|
45
47
|
return out;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// Haskell/Nix producers emit finer families than the frameworks name; those are evidence for the framework family
|
|
51
|
+
// (language/compliance-map.js). Merged without disturbing any existing alias.
|
|
52
|
+
for (const [fam, extra] of Object.entries(LANGUAGE_FAMILY_ALIASES)) {
|
|
53
|
+
COMPLIANCE_FAMILY_ALIAS[fam] = [...new Set([...(COMPLIANCE_FAMILY_ALIAS[fam] || []), ...extra])];
|
|
54
|
+
}
|
|
@@ -356,6 +356,46 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
|
|
|
356
356
|
});
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Records a fix that a language lifecycle (Haskell, Nix) has ALREADY written, with its own backup, so one ledger
|
|
361
|
+
* serves every ecosystem: `agentic-security undo` reverts it, the attempt budget counts it, and its tier (FULL,
|
|
362
|
+
* MITIGATION, WORKAROUND) and the gates it passed are kept on the entry beside the timestamp. No second write happens
|
|
363
|
+
* here; `backup.original` is the file the lifecycle saved before it wrote.
|
|
364
|
+
*/
|
|
365
|
+
export async function recordExternalFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fixLabel = null, verification = null, backup = null, source = 'language-lifecycle', findingProvenance = null }) {
|
|
366
|
+
return _withLogLock(scanRoot, async () => {
|
|
367
|
+
if (!ensure(scanRoot)) return null;
|
|
368
|
+
const log = readLog(scanRoot);
|
|
369
|
+
const priorAttempts = _countPriorAttempts(log, stableId || null, findingId);
|
|
370
|
+
const bakAbs = backup && backup.dir ? path.join(backup.dir, 'original') : null;
|
|
371
|
+
const entry = {
|
|
372
|
+
id: `fix-${Date.now().toString(36)}-${sha(file + findingId).slice(0, 6)}`,
|
|
373
|
+
findingId, stableId: stableId || null, ruleId: ruleId || null, vuln: vuln || null, file, fileExisted: true,
|
|
374
|
+
backupPath: bakAbs ? path.relative(scanRoot, bakAbs) : null,
|
|
375
|
+
originalSha: sha(originalContent), newSha: sha(newContent),
|
|
376
|
+
appliedAt: new Date().toISOString(), status: 'applied', reverted: false, attemptOrdinal: priorAttempts + 1,
|
|
377
|
+
provenanceAtFix: _snapshotProvenanceAtFix(findingProvenance, new Date().toISOString()),
|
|
378
|
+
fixLabel, verification, source, languageBackupId: backup ? backup.id : null,
|
|
379
|
+
};
|
|
380
|
+
log.push(entry);
|
|
381
|
+
await _writeLogAndSync(scanRoot, log);
|
|
382
|
+
return entry;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Marks the history entry written for a language-lifecycle backup as reverted (sync: the lifecycle's undo is sync). */
|
|
387
|
+
export function markRevertedByBackup(scanRoot, backupId) {
|
|
388
|
+
try {
|
|
389
|
+
const fp = logPath(scanRoot);
|
|
390
|
+
if (!fs.existsSync(fp)) return false;
|
|
391
|
+
const log = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
392
|
+
let hit = false;
|
|
393
|
+
for (const e of log) if (e && e.languageBackupId === backupId && !e.reverted) { e.reverted = true; e.revertedAt = new Date().toISOString(); hit = true; }
|
|
394
|
+
if (hit) { const tmp = `${fp}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(log, null, 2)); fs.renameSync(tmp, fp); }
|
|
395
|
+
return hit;
|
|
396
|
+
} catch { return false; }
|
|
397
|
+
}
|
|
398
|
+
|
|
359
399
|
async function _writeAndSync(fp, content) {
|
|
360
400
|
await fsp.mkdir(path.dirname(fp), { recursive: true });
|
|
361
401
|
const handle = await fsp.open(fp, 'w');
|
|
@@ -35,8 +35,17 @@ export async function verifyPatch({
|
|
|
35
35
|
depFileContents = {},
|
|
36
36
|
} = {}) {
|
|
37
37
|
if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
|
|
38
|
-
|
|
38
|
+
let fileContents = { ...files };
|
|
39
39
|
let scan;
|
|
40
|
+
// Haskell/Nix findings come from the deep (IR) layer and depend on imported modules: verifying a patch without
|
|
41
|
+
// either would never see the original finding at all, so a patch that fixes nothing would read "verified". The
|
|
42
|
+
// patched files are scanned deep, together with their import closure and manifests read from disk.
|
|
43
|
+
let deep;
|
|
44
|
+
if (Object.keys(fileContents).some((f) => /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(f))) {
|
|
45
|
+
const { withLanguageContext } = await import('../language/context.js');
|
|
46
|
+
const lc = withLanguageContext(scanRoot, fileContents, depFileContents);
|
|
47
|
+
fileContents = lc.fileContents; depFileContents = lc.depFileContents; deep = true;
|
|
48
|
+
}
|
|
40
49
|
try {
|
|
41
50
|
// `provenance:false` is REQUIRED here, not an optimisation. This scan is
|
|
42
51
|
// deliberately scoped to just the patched file(s), so its finding set is a
|
|
@@ -46,7 +55,7 @@ export async function verifyPatch({
|
|
|
46
55
|
// would mass-mark the rest of the project as remediated, then
|
|
47
56
|
// `reintroduced` on the next real scan. The patched content is also not
|
|
48
57
|
// committed, so there is no history to resolve provenance against anyway.
|
|
49
|
-
scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
|
|
58
|
+
scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false, ...(deep ? { deep: true } : {}) }, () => {});
|
|
50
59
|
} catch (e) {
|
|
51
60
|
return { ok: false, reason: 'rescan-failed', error: e.message };
|
|
52
61
|
}
|
|
@@ -56,8 +65,11 @@ export async function verifyPatch({
|
|
|
56
65
|
if (stillHasOriginal) {
|
|
57
66
|
return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
|
|
58
67
|
}
|
|
68
|
+
// With language context in play, files that were only read for context carry findings that predate the patch;
|
|
69
|
+
// only findings in the PATCHED files can be introduced by it.
|
|
70
|
+
const patchedSet = new Set(Object.keys(files));
|
|
59
71
|
const introducedHighOrAbove = findings.filter(f =>
|
|
60
|
-
(SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
|
|
72
|
+
(SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium && (!deep || patchedSet.has(f.file)));
|
|
61
73
|
// Don't count findings on lines outside the patched files — but our
|
|
62
74
|
// fileContents map IS the patched files, so every finding is in-scope.
|
|
63
75
|
return {
|
|
@@ -233,6 +233,30 @@ export function getBlobAtCommit(scanRoot, sha, file) {
|
|
|
233
233
|
return r.ok ? r.stdout : null;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/** Files in the tree at `sha` (relative to scanRoot) whose path matches `re`, capped. Empty on any failure. */
|
|
237
|
+
export function listFilesAtCommit(scanRoot, sha, re, { max = 400 } = {}) {
|
|
238
|
+
if (!_isSha(sha) || !(re instanceof RegExp)) return [];
|
|
239
|
+
const r = _run(scanRoot, ['ls-tree', '-r', '--name-only', sha, '--', '.']);
|
|
240
|
+
if (!r.ok) return [];
|
|
241
|
+
return r.stdout.split('\n').map((x) => x.trim()).filter((x) => x && re.test(x)).slice(0, max);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Commits, oldest first, that touched ANY of `files`. Used when a finding is decided by several files (an import
|
|
246
|
+
* chain, a module graph) so the commit that changed any of them is a candidate origin. No rename following.
|
|
247
|
+
*/
|
|
248
|
+
export function candidateCommitsForFiles(scanRoot, files, { since } = {}) {
|
|
249
|
+
const rels = (files || []).map((f) => _relPath(scanRoot, f)).filter(Boolean);
|
|
250
|
+
if (!rels.length) return [];
|
|
251
|
+
if (since && !_isSafeRevision(since)) return [];
|
|
252
|
+
const args = ['log', '--format=%H', '--reverse'];
|
|
253
|
+
if (since) args.push(`${since}..HEAD`);
|
|
254
|
+
args.push('--', ...rels);
|
|
255
|
+
const r = _run(scanRoot, args);
|
|
256
|
+
if (!r.ok) return [];
|
|
257
|
+
return [...new Set(r.stdout.split('\n').map((x) => x.trim()).filter(Boolean))];
|
|
258
|
+
}
|
|
259
|
+
|
|
236
260
|
export function candidateCommitsForLine(scanRoot, file, line, { since } = {}) {
|
|
237
261
|
const rel = _relPath(scanRoot, file);
|
|
238
262
|
if (!rel) return [];
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// that disambiguates them; see the branch below for exactly how each is
|
|
24
24
|
// handled.
|
|
25
25
|
|
|
26
|
-
import { candidateCommitsForLine, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
|
|
26
|
+
import { candidateCommitsForLine, candidateCommitsForFiles, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
|
|
27
27
|
import { replayAt } from './predicate-replay.js';
|
|
28
28
|
import { PROVENANCE_METHOD } from './schema.js';
|
|
29
29
|
import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-walk.js';
|
|
@@ -38,6 +38,11 @@ function relevantFiles(finding) {
|
|
|
38
38
|
if (Array.isArray(finding.pathSteps)) {
|
|
39
39
|
for (const step of finding.pathSteps) if (step.file) files.add(step.file);
|
|
40
40
|
}
|
|
41
|
+
// Haskell/Nix findings are decided by more than the line they point at: a taint chain crosses modules, and a
|
|
42
|
+
// NixOS option is decided by imported modules. Replaying only the anchor file would attribute the finding to the
|
|
43
|
+
// commit that last touched that one file, so the files that decided it are replayed together.
|
|
44
|
+
if (Array.isArray(finding.chain)) for (const step of finding.chain) if (step && typeof step.file === 'string' && step.file) files.add(step.file);
|
|
45
|
+
if (Array.isArray(finding.controlFiles)) for (const f of finding.controlFiles) if (typeof f === 'string' && f) files.add(f);
|
|
41
46
|
return [...files];
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -173,7 +178,14 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
|
|
|
173
178
|
return { status: 'not_available', reason: 'missing-file-line-or-stableId', commitsConsidered: 0 };
|
|
174
179
|
}
|
|
175
180
|
|
|
176
|
-
|
|
181
|
+
let candidates = candidateCommitsForLine(scanRoot, file, line, { since });
|
|
182
|
+
// Haskell/Nix: more than the anchor line decides a finding (an import chain, a module graph), so the commits that
|
|
183
|
+
// touched ANY of the deciding files are candidates too, in chronological order.
|
|
184
|
+
const decidingFiles = relevantFiles(finding);
|
|
185
|
+
if (/\.nix$/i.test(file) || (decidingFiles.length > 1 && /\.l?hs$/i.test(file))) {
|
|
186
|
+
const wide = candidateCommitsForFiles(scanRoot, decidingFiles, { since });
|
|
187
|
+
if (wide.length) candidates = [...new Set([...wide, ...candidates])];
|
|
188
|
+
}
|
|
177
189
|
if (candidates.length === 0) {
|
|
178
190
|
return { status: 'not_available', reason: 'no-candidate-commits', commitsConsidered: 0 };
|
|
179
191
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { runFullScan, _snapshotSuppressionLog, _restoreSuppressionLog } from '../../engine.js';
|
|
13
13
|
import { computeStableId } from '../stable-id.js';
|
|
14
|
-
import { getBlobAtCommit } from './git-evidence.js';
|
|
14
|
+
import { getBlobAtCommit, listFilesAtCommit } from './git-evidence.js';
|
|
15
15
|
|
|
16
16
|
// Task 11 concurrency fix: `coordinator.js` resolves several findings' origins
|
|
17
17
|
// CONCURRENTLY (its own comment: "the scheduler runs these four at a time"),
|
|
@@ -59,6 +59,12 @@ export function _runExclusive(fn) {
|
|
|
59
59
|
|
|
60
60
|
export async function replayAt(scanRoot, sha, files, targetStableId) {
|
|
61
61
|
const fileContents = {};
|
|
62
|
+
// A NixOS option is decided by every module the configuration imports, including ones deleted later, so a Nix
|
|
63
|
+
// finding is replayed against ALL Nix sources and the lock file as they existed at that commit.
|
|
64
|
+
if (files.some((f) => /\.nix$/i.test(f))) {
|
|
65
|
+
const all = listFilesAtCommit(scanRoot, sha, /(?:\.nix|(?:^|\/)flake\.lock)$/i);
|
|
66
|
+
files = [...new Set([...files, ...all])];
|
|
67
|
+
}
|
|
62
68
|
for (const f of files) {
|
|
63
69
|
const content = getBlobAtCommit(scanRoot, sha, f);
|
|
64
70
|
if (content != null) fileContents[f] = content;
|
|
@@ -100,7 +106,10 @@ export async function replayAt(scanRoot, sha, files, targetStableId) {
|
|
|
100
106
|
// identical computeStableId output with and without annotators, over
|
|
101
107
|
// a real scan) before this was wired in — see the FR-PROV-029 commit
|
|
102
108
|
// message for methodology.
|
|
103
|
-
|
|
109
|
+
// Haskell/Nix findings come from the IR-taint layer, which only runs in deep mode: a replay that skips it
|
|
110
|
+
// can never see the finding at ANY commit, so origin resolution would end "never confirmed" for every one.
|
|
111
|
+
const _langDeep = Object.keys(fileContents).some((f) => /\.(?:l?hs|nix)$/i.test(f)) ? { deep: true } : {};
|
|
112
|
+
return await runFullScan({ fileContents, scanRoot, provenance: false, skipAnnotators: true, ..._langDeep }, () => {});
|
|
104
113
|
} finally {
|
|
105
114
|
_restoreSuppressionLog(_suppSnapshot);
|
|
106
115
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
2
|
import { candidateCommitsForFile, getFirstParent, getBlobAtCommit, commitMeta } from './git-evidence.js';
|
|
3
3
|
import { PROVENANCE_METHOD } from './schema.js';
|
|
4
|
+
import { compareVersions } from '../../language/haskell-manifests.js';
|
|
4
5
|
|
|
5
6
|
function parseSemver(v) {
|
|
6
7
|
const m = String(v || '').replace(/^[^\d]*/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
@@ -13,7 +14,17 @@ function cmpSemver(a, b) {
|
|
|
13
14
|
return 0;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// Hackage versions follow the PVP: any number of components, compared numerically component by component. The
|
|
18
|
+
// three-component semver comparison below would call 2.1.2.1 and 2.1.2.9 equal.
|
|
19
|
+
function pvpInRange(version, range) {
|
|
20
|
+
if (!version) return false;
|
|
21
|
+
if (range.introduced && compareVersions(version, range.introduced) < 0) return false;
|
|
22
|
+
if (range.fixed && compareVersions(version, range.fixed) >= 0) return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function versionInRange(version, range, ecosystem = null) {
|
|
27
|
+
if (ecosystem && String(ecosystem).toLowerCase() === 'hackage') return pvpInRange(version, range);
|
|
17
28
|
const v = parseSemver(version);
|
|
18
29
|
if (!v) return false;
|
|
19
30
|
if (range.introduced) {
|
|
@@ -42,6 +53,12 @@ function extractDeclaredVersion(blobText, depName, filePath) {
|
|
|
42
53
|
return null;
|
|
43
54
|
}
|
|
44
55
|
}
|
|
56
|
+
// Hackage: the version is exact only where a freeze, a Stack lock or an extra-deps pin states it.
|
|
57
|
+
const esc = String(depName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
58
|
+
if (base === 'cabal.project.freeze') { const m = new RegExp(`any\\.${esc}\\s*==\\s*([0-9][0-9.]*)`).exec(blobText); return m ? m[1] : null; }
|
|
59
|
+
if (base === 'stack.yaml.lock') { const m = new RegExp(`hackage:\\s*${esc}-([0-9][0-9.]*)(?:@|\\s|$)`).exec(blobText); return m ? m[1] : null; }
|
|
60
|
+
if (base === 'stack.yaml') { const m = new RegExp(`^\\s*-\\s*${esc}-([0-9][0-9.]*)\\s*$`, 'm').exec(blobText); return m ? m[1] : null; }
|
|
61
|
+
if (/\.cabal$/i.test(base)) { const m = new RegExp(`(?:^|[\\s,:])${esc}\\s*==\\s*([0-9][0-9.]*)(?![\\w.*])`, 'm').exec(blobText); return m ? m[1] : null; }
|
|
45
62
|
if (/^requirements(?:[._-][\w.-]+)?\.txt$/i.test(base)) {
|
|
46
63
|
for (const line of blobText.split('\n')) {
|
|
47
64
|
const m = line.trim().match(/^([A-Za-z0-9_.-]+)\s*[=~<>!]+\s*([^\s;#,]*)/);
|
|
@@ -98,7 +115,7 @@ export async function resolveDirectSCAOrigin(scanRoot, scaEntry, { since, deadli
|
|
|
98
115
|
const blob = getBlobAtCommit(scanRoot, sha, file);
|
|
99
116
|
if (blob == null) continue;
|
|
100
117
|
const declaredVersion = extractDeclaredVersion(blob, scaEntry.name, file);
|
|
101
|
-
if (!declaredVersion || !versionInRange(declaredVersion, range)) continue;
|
|
118
|
+
if (!declaredVersion || !versionInRange(declaredVersion, range, scaEntry.ecosystem)) continue;
|
|
102
119
|
|
|
103
120
|
const parent = getFirstParent(scanRoot, sha);
|
|
104
121
|
if (!parent) {
|
|
@@ -111,7 +128,7 @@ export async function resolveDirectSCAOrigin(scanRoot, scaEntry, { since, deadli
|
|
|
111
128
|
|
|
112
129
|
const parentBlob = getBlobAtCommit(scanRoot, parent, file);
|
|
113
130
|
const parentVersion = parentBlob ? extractDeclaredVersion(parentBlob, scaEntry.name, file) : null;
|
|
114
|
-
const parentOutOfRange = !parentVersion || !versionInRange(parentVersion, range);
|
|
131
|
+
const parentOutOfRange = !parentVersion || !versionInRange(parentVersion, range, scaEntry.ecosystem);
|
|
115
132
|
if (!parentOutOfRange) {
|
|
116
133
|
// Parent was already in the vulnerable range too. If the declared value
|
|
117
134
|
// actually changed here, this is the ambiguous "still-vulnerable bump"
|
package/src/posture/sbom-diff.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// module produces the diff on the next scan.
|
|
22
22
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
|
-
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
24
|
+
import { statePath, stateWritesEnabled, safeWriteState } from './state-dir.js';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
26
|
import * as crypto from 'node:crypto';
|
|
27
27
|
import { execFileSync } from 'node:child_process';
|
|
@@ -52,6 +52,9 @@ function _gitHead(scanRoot) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function _snapshotKey(component) {
|
|
55
|
+
// Language components carry an identity that survives a version change but keeps two resolved builds apart
|
|
56
|
+
// (a flag variant, a second store hash); everything else keeps the ecosystem:name key it always had.
|
|
57
|
+
if (component.identityKey) return component.identityKey;
|
|
55
58
|
return `${component.ecosystem || 'unknown'}:${component.name || ''}`;
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -65,7 +68,6 @@ export function persistSbom(scanRoot, components) {
|
|
|
65
68
|
// empty `sbom-history/` in the scanned tree — invisible to `git status`,
|
|
66
69
|
// because git does not track empty directories, and therefore exactly the
|
|
67
70
|
// kind of mutation that passes a clean-status check while still being one.
|
|
68
|
-
if (stateWritesEnabled()) { try { fs.mkdirSync(dir, { recursive: true }); } catch {} }
|
|
69
71
|
const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
|
|
70
72
|
const snap = {
|
|
71
73
|
sha, ts: new Date().toISOString(),
|
|
@@ -73,14 +75,13 @@ export function persistSbom(scanRoot, components) {
|
|
|
73
75
|
components: components.map(c => ({
|
|
74
76
|
ecosystem: c.ecosystem, name: c.name, version: c.version,
|
|
75
77
|
purl: c.purl, scope: c.scope, isUnpinned: !!c.isUnpinned,
|
|
76
|
-
|
|
78
|
+
...(c.identityKey ? { identityKey: c.identityKey, bomRef: c.bomRef || null } : {}),
|
|
79
|
+
sha256: c.sha256 || c.integrity || ((c.hashes || []).find((h) => h.alg === 'SHA-256') || {}).content || null,
|
|
77
80
|
})),
|
|
78
81
|
};
|
|
79
82
|
// NON_MUTATING_SCAN_PRD S1 — history is a feature, but not at the cost of
|
|
80
83
|
// mutating a tree the caller only asked us to read.
|
|
81
|
-
if (stateWritesEnabled()) {
|
|
82
|
-
try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
|
|
83
|
-
}
|
|
84
|
+
if (stateWritesEnabled()) safeWriteState(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); // refuses a symlinked state directory or file
|
|
84
85
|
return snap;
|
|
85
86
|
}
|
|
86
87
|
|
package/src/posture/sbom.js
CHANGED
|
@@ -60,27 +60,49 @@ function _documentUuid(seed) {
|
|
|
60
60
|
return isDeterministic() ? _stableUuidFrom(seed) : crypto.randomUUID();
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// The bom-ref a vulnerability points at: the component's own ref when it has one (language components do), else the
|
|
64
|
+
// derived one. A different resolved build of the same package is a different component and keeps its own ref.
|
|
65
|
+
function _vulnTargetRef(components, s) {
|
|
66
|
+
const hit = components.find(c => c.bomRef && c.name === s.name && c.version === s.version && (c.ecosystem || '') === (s.ecosystem || ''));
|
|
67
|
+
return hit ? hit.bomRef : _bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version });
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
export function toCycloneDX(scan, meta = {}) {
|
|
64
|
-
const components = scan.components || [];
|
|
71
|
+
const components = [...(scan.components || []), ...((scan.languageBom && scan.languageBom.components) || [])];
|
|
72
|
+
scan = scan.languageBom ? { ...scan, bomRoot: scan.languageBom.root || scan.bomRoot, bomDependencies: scan.languageBom.dependencies, bomCompositions: scan.languageBom.compositions, bomProperties: scan.languageBom.properties } : scan;
|
|
65
73
|
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
66
74
|
const serialNumber = `urn:uuid:${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
|
|
67
75
|
|
|
76
|
+
// Language components (Hackage, Nix) carry their own identity, scope, hashes and build provenance; every other
|
|
77
|
+
// component keeps exactly the shape it always had. A version is written only when one is known (a null or empty
|
|
78
|
+
// version is not a valid CycloneDX string), and nothing here invents a license.
|
|
79
|
+
const refOf = (c) => c.bomRef || _bomRef(c);
|
|
68
80
|
const cdxComponents = components.map(c => ({
|
|
69
|
-
type: 'library',
|
|
70
|
-
'bom-ref':
|
|
81
|
+
type: c.cdxType || 'library',
|
|
82
|
+
'bom-ref': refOf(c),
|
|
71
83
|
name: c.name,
|
|
72
|
-
version: c.version,
|
|
73
|
-
purl: _purl(c),
|
|
84
|
+
...(c.version ? { version: c.version } : (c.bomRef ? {} : { version: c.version })),
|
|
85
|
+
...(c.purl || !c.bomRef ? { purl: _purl(c) } : {}),
|
|
74
86
|
...(c.license ? { licenses: [{ license: { id: c.license } }] } : {}),
|
|
75
|
-
...(c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {}),
|
|
87
|
+
...(c.cdxScope ? { scope: c.cdxScope } : (c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {})),
|
|
88
|
+
...(Array.isArray(c.hashes) && c.hashes.length ? { hashes: c.hashes } : {}),
|
|
89
|
+
...(Array.isArray(c.externalReferences) && c.externalReferences.length ? { externalReferences: c.externalReferences } : {}),
|
|
90
|
+
...(Array.isArray(c.properties) && c.properties.length ? { properties: c.properties } : {}),
|
|
76
91
|
}));
|
|
92
|
+
const knownRefs = new Set([...cdxComponents.map(x => x['bom-ref']), ...(scan.bomRoot ? [scan.bomRoot['bom-ref']] : [])]);
|
|
93
|
+
const cdxDependencies = (scan.bomDependencies || [])
|
|
94
|
+
.filter(d => knownRefs.has(d.ref))
|
|
95
|
+
.map(d => ({ ref: d.ref, dependsOn: (d.dependsOn || []).filter(r => knownRefs.has(r)) }));
|
|
96
|
+
const cdxCompositions = (scan.bomCompositions || [])
|
|
97
|
+
.map(c => ({ aggregate: c.aggregate, dependencies: (c.dependencies || []).filter(r => knownRefs.has(r)) }))
|
|
98
|
+
.filter(c => c.dependencies.length);
|
|
77
99
|
|
|
78
100
|
const vulnerabilities = supplyChain.map((s, i) => ({
|
|
79
101
|
// The last-resort id was crypto.randomUUID(), which reintroduced
|
|
80
102
|
// per-run drift for any advisory carrying neither an osvId nor an
|
|
81
103
|
// advisory string. Index within the (already deterministically sorted)
|
|
82
104
|
// supplyChain array identifies it just as well and is reproducible.
|
|
83
|
-
'bom-ref': `${
|
|
105
|
+
'bom-ref': `${_vulnTargetRef(components, s)}#${s.osvId || s.advisory || `unidentified-${i}`}`,
|
|
84
106
|
id: s.osvId || (s.cveAliases || [])[0] || s.advisory,
|
|
85
107
|
source: { name: 'OSV.dev', url: `https://osv.dev/vulnerability/${s.osvId || ''}` },
|
|
86
108
|
references: (s.cveAliases || []).map(cve => ({ id: cve, source: { name: 'NVD' } })),
|
|
@@ -89,7 +111,7 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
89
111
|
...(s.cvssVector ? [{ vector: s.cvssVector, method: 'CVSSv3' }] : []),
|
|
90
112
|
],
|
|
91
113
|
description: s.description || s.advisory || '',
|
|
92
|
-
affects: [{ ref:
|
|
114
|
+
affects: [{ ref: _vulnTargetRef(components, s) }],
|
|
93
115
|
properties: [
|
|
94
116
|
...(s.epssScore != null ? [{ name: 'epss:score', value: String(s.epssScore) }] : []),
|
|
95
117
|
...(s.epssPercentile != null ? [{ name: 'epss:percentile', value: String(s.epssPercentile) }] : []),
|
|
@@ -105,34 +127,53 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
105
127
|
metadata: {
|
|
106
128
|
timestamp: meta.startedAt || new Date().toISOString(),
|
|
107
129
|
tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: meta.engineVersion || 'dev' }],
|
|
108
|
-
component: { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
130
|
+
component: scan.bomRoot ? { ...scan.bomRoot } : { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
131
|
+
...(Array.isArray(scan.bomProperties) && scan.bomProperties.length ? { properties: scan.bomProperties } : {}),
|
|
109
132
|
},
|
|
110
133
|
components: cdxComponents,
|
|
134
|
+
...(cdxDependencies.length ? { dependencies: cdxDependencies } : {}),
|
|
135
|
+
...(cdxCompositions.length ? { compositions: cdxCompositions } : {}),
|
|
111
136
|
...(vulnerabilities.length ? { vulnerabilities } : {}),
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
139
|
|
|
115
140
|
export function toSPDX(scan, meta = {}) {
|
|
116
|
-
const components = scan.components || [];
|
|
141
|
+
const components = [...(scan.components || []), ...((scan.languageBom && scan.languageBom.components) || [])];
|
|
142
|
+
scan = scan.languageBom ? { ...scan, bomRoot: scan.languageBom.root || scan.bomRoot, bomDependencies: scan.languageBom.dependencies } : scan;
|
|
117
143
|
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
118
144
|
const docNamespace = `https://agentic-security.local/spdx/${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
|
|
119
|
-
|
|
145
|
+
// SPDX 2.3 `created` is YYYY-MM-DDThh:mm:ssZ: fractional seconds are not part of the format.
|
|
146
|
+
const ts = (meta.startedAt || new Date().toISOString()).replace(/\.\d+(Z|[+-]\d{2}:\d{2})$/, (_m, z) => (z === 'Z' ? 'Z' : z));
|
|
120
147
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
versionInfo:
|
|
125
|
-
downloadLocation: 'NOASSERTION',
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
const spdxIdOfRef = new Map();
|
|
149
|
+
// The described application itself is a package, so dependency edges that start at it have an element to attach to.
|
|
150
|
+
const rootPackage = scan.bomRoot && scan.bomRoot['bom-ref'] ? [{
|
|
151
|
+
SPDXID: 'SPDXRef-Package-root', name: scan.bomRoot.name, ...(scan.bomRoot.version ? { versionInfo: scan.bomRoot.version } : {}),
|
|
152
|
+
downloadLocation: 'NOASSERTION', filesAnalyzed: false, licenseConcluded: 'NOASSERTION', licenseDeclared: 'NOASSERTION', copyrightText: 'NOASSERTION',
|
|
153
|
+
primaryPackagePurpose: 'APPLICATION', externalRefs: [],
|
|
154
|
+
}] : [];
|
|
155
|
+
if (rootPackage.length) spdxIdOfRef.set(scan.bomRoot['bom-ref'], 'SPDXRef-Package-root');
|
|
156
|
+
const packages = components.map((c, i) => {
|
|
157
|
+
const id = `SPDXRef-Package-${i}`;
|
|
158
|
+
if (c.bomRef) spdxIdOfRef.set(c.bomRef, id);
|
|
159
|
+
return {
|
|
160
|
+
SPDXID: id,
|
|
161
|
+
name: c.name,
|
|
162
|
+
...((c.version || !c.bomRef) ? { versionInfo: c.version } : {}),
|
|
163
|
+
downloadLocation: 'NOASSERTION',
|
|
164
|
+
filesAnalyzed: false,
|
|
165
|
+
...(Array.isArray(c.hashes) && c.hashes.length ? { checksums: c.hashes.map(h => ({ algorithm: String(h.alg).replace('-', ''), checksumValue: h.content })) } : {}),
|
|
166
|
+
licenseConcluded: c.license || 'NOASSERTION',
|
|
167
|
+
licenseDeclared: c.license || 'NOASSERTION',
|
|
168
|
+
copyrightText: 'NOASSERTION',
|
|
169
|
+
...(c.bomRef ? { primaryPackagePurpose: 'LIBRARY' } : {}),
|
|
170
|
+
externalRefs: [...((c.purl || !c.bomRef) ? [{
|
|
171
|
+
referenceCategory: 'PACKAGE-MANAGER',
|
|
172
|
+
referenceType: 'purl',
|
|
173
|
+
referenceLocator: _purl(c),
|
|
174
|
+
}] : [])],
|
|
175
|
+
};
|
|
176
|
+
});
|
|
136
177
|
|
|
137
178
|
// SPDX expresses CVEs as external refs on the package, not separate elements
|
|
138
179
|
const cveByName = {};
|
|
@@ -162,11 +203,23 @@ export function toSPDX(scan, meta = {}) {
|
|
|
162
203
|
created: ts,
|
|
163
204
|
creators: [`Tool: agentic-security-${meta.engineVersion || 'dev'}`],
|
|
164
205
|
},
|
|
165
|
-
packages,
|
|
166
|
-
relationships:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
206
|
+
packages: [...rootPackage, ...packages],
|
|
207
|
+
relationships: [
|
|
208
|
+
...[...rootPackage, ...packages].map(p => ({
|
|
209
|
+
spdxElementId: 'SPDXRef-DOCUMENT',
|
|
210
|
+
relatedSpdxElement: p.SPDXID,
|
|
211
|
+
relationshipType: 'DESCRIBES',
|
|
212
|
+
})),
|
|
213
|
+
// Dependency edges between language components. Runtime edges are DEPENDS_ON; a build- or test-only target is
|
|
214
|
+
// expressed with the SPDX 2.3 *_DEPENDENCY_OF relationship so the scope survives the conversion.
|
|
215
|
+
...(scan.bomDependencies || []).flatMap(d => (d.dependsOn || []).map(to => {
|
|
216
|
+
const a = spdxIdOfRef.get(d.ref); const b = spdxIdOfRef.get(to);
|
|
217
|
+
if (!a || !b) return null;
|
|
218
|
+
const kind = (d.kinds || {})[to] || 'runtime';
|
|
219
|
+
if (kind === 'test') return { spdxElementId: b, relatedSpdxElement: a, relationshipType: 'TEST_DEPENDENCY_OF' };
|
|
220
|
+
if (kind === 'build') return { spdxElementId: b, relatedSpdxElement: a, relationshipType: 'BUILD_DEPENDENCY_OF' };
|
|
221
|
+
return { spdxElementId: a, relatedSpdxElement: b, relationshipType: 'DEPENDS_ON' };
|
|
222
|
+
})).filter(Boolean),
|
|
223
|
+
],
|
|
171
224
|
};
|
|
172
225
|
}
|
|
@@ -41,6 +41,7 @@ import * as path from 'node:path';
|
|
|
41
41
|
import * as yaml from '../util/yaml.js';
|
|
42
42
|
|
|
43
43
|
import { safeWriteState, stateDir, statePath } from './state-dir.js';
|
|
44
|
+
import { BUILD_TRUST_TYPE } from '../language/contracts.js';
|
|
44
45
|
const DEFAULT_POLICY = {
|
|
45
46
|
acceptRisk: [],
|
|
46
47
|
sla: {},
|
|
@@ -75,6 +76,7 @@ function _normalizeAccept(entry) {
|
|
|
75
76
|
return {
|
|
76
77
|
cve: entry.cve ? String(entry.cve).toUpperCase() : null,
|
|
77
78
|
package: entry.package ? String(entry.package).toLowerCase() : null,
|
|
79
|
+
rule: entry.rule ? String(entry.rule) : null,
|
|
78
80
|
version: entry.version ? String(entry.version) : null,
|
|
79
81
|
ecosystem: entry.ecosystem ? String(entry.ecosystem).toLowerCase() : null,
|
|
80
82
|
reason: entry.reason || '',
|
|
@@ -125,6 +127,13 @@ export function matchAcceptRisk(finding, policy, today = new Date()) {
|
|
|
125
127
|
const pkgName = String(finding.name || '').toLowerCase();
|
|
126
128
|
for (const entry of policy.acceptRisk) {
|
|
127
129
|
if (!_accepted(entry, today)) continue;
|
|
130
|
+
// A `rule:` entry targets a build-trust policy finding (no CVE, no version) by its rule id.
|
|
131
|
+
if (entry.rule) {
|
|
132
|
+
if (finding.type === BUILD_TRUST_TYPE && finding.rule === entry.rule
|
|
133
|
+
&& (!entry.package || pkgName === entry.package)
|
|
134
|
+
&& (!entry.ecosystem || entry.ecosystem === finding.ecosystem)) return entry;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
128
137
|
if (entry.cve && cves.includes(entry.cve)) return entry;
|
|
129
138
|
if (entry.package && pkgName === entry.package) {
|
|
130
139
|
if (entry.version && finding.version && entry.version !== finding.version) continue;
|
|
@@ -144,7 +153,7 @@ export function applyScaPolicy(findings, policy, scanTime = new Date()) {
|
|
|
144
153
|
const stats = { suppressed: 0, slaTagged: 0, frozen: 0 };
|
|
145
154
|
if (!policy || !Array.isArray(findings)) return stats;
|
|
146
155
|
for (const f of findings) {
|
|
147
|
-
if (!f || f.type !== 'vulnerable_dep') continue;
|
|
156
|
+
if (!f || (f.type !== 'vulnerable_dep' && f.type !== BUILD_TRUST_TYPE)) continue;
|
|
148
157
|
// Accept-risk suppression
|
|
149
158
|
const acceptance = matchAcceptRisk(f, policy, scanTime);
|
|
150
159
|
if (acceptance) {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// Verdict enum (must match the agent + downstream /fix --sca):
|
|
14
14
|
// AUTO_MERGE_PATCH · WAIT_FOR_PATCH · MANUAL_REVIEW · ACCEPT_RISK · WONT_FIX
|
|
15
15
|
|
|
16
|
+
import { BUILD_TRUST_TYPE } from '../language/contracts.js';
|
|
17
|
+
|
|
16
18
|
export const SCA_VERDICTS = ['AUTO_MERGE_PATCH', 'WAIT_FOR_PATCH', 'MANUAL_REVIEW', 'ACCEPT_RISK', 'WONT_FIX'];
|
|
17
19
|
|
|
18
20
|
const REACHABLE_TIERS = new Set(['route-reachable-via-function', 'function-reachable', 'import-reachable']);
|
|
@@ -64,6 +66,11 @@ export function computeScaVerdict(sc, opts = {}) {
|
|
|
64
66
|
if (sc.suppressed || sc.suppressionReason) {
|
|
65
67
|
return { verdict: 'ACCEPT_RISK', reason: sc.suppressionReason || 'matched existing accept-risk in sca-policy.yml' };
|
|
66
68
|
}
|
|
69
|
+
// 1b. Nix build-trust policy findings have no advisory, fixed version or upgrade
|
|
70
|
+
// path: the remedy is a configuration or pin change, which is always a human call.
|
|
71
|
+
if (sc.type === BUILD_TRUST_TYPE) {
|
|
72
|
+
return { verdict: 'MANUAL_REVIEW', reason: `${sc.rule}: ${sc.remediation || 'review the Nix build-trust setting'}` };
|
|
73
|
+
}
|
|
67
74
|
// 2. No fixed version exists → nothing to upgrade yet.
|
|
68
75
|
if (!fixed.length) {
|
|
69
76
|
return { verdict: 'WAIT_FOR_PATCH', reason: `no fixed version published${sc.osvId ? ` for ${sc.osvId}` : ''}` };
|
|
@@ -116,7 +123,7 @@ export function annotateScaVerdicts(supplyChain, opts = {}) {
|
|
|
116
123
|
if (!Array.isArray(supplyChain)) return supplyChain;
|
|
117
124
|
const counts = { AUTO_MERGE_PATCH: 0, WAIT_FOR_PATCH: 0, MANUAL_REVIEW: 0, ACCEPT_RISK: 0, WONT_FIX: 0 };
|
|
118
125
|
for (const sc of supplyChain) {
|
|
119
|
-
if (!sc || sc.type !== 'vulnerable_dep') continue;
|
|
126
|
+
if (!sc || (sc.type !== 'vulnerable_dep' && sc.type !== BUILD_TRUST_TYPE)) continue;
|
|
120
127
|
const { verdict, reason, expiryDays } = computeScaVerdict(sc, opts);
|
|
121
128
|
sc.scaVerdict = verdict;
|
|
122
129
|
sc.scaVerdictReason = reason;
|