@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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Signed provenance evidence bundles (Finding Provenance PRD, M4 §4.1).
|
|
2
|
+
//
|
|
3
|
+
// Sibling to posture/evidence-bundle.js, not a generalization of it — that
|
|
4
|
+
// module's bundle shape (proofTier, taintPath, exploitability...) answers
|
|
5
|
+
// "is this finding real"; this one answers "who/when introduced it, how
|
|
6
|
+
// sure are we." Forcing one shape to cover both would leave half of every
|
|
7
|
+
// bundle null. Same four-function pattern (build/sign/verify + a schema
|
|
8
|
+
// string + a top-level-key allowlist), same Ed25519 key material — reused,
|
|
9
|
+
// not reimplemented.
|
|
10
|
+
//
|
|
11
|
+
// The allowlisted fields mirror provenance/coordinator.js's own
|
|
12
|
+
// computeDigest() material EXACTLY (stableId, findingOrigin.commit,
|
|
13
|
+
// branchIntroduction.commit, evidenceAttribution role:path:line:commit
|
|
14
|
+
// strings, method, confidence.reasons, limitations) plus repo/HEAD identity
|
|
15
|
+
// (not in computeDigest's material, since that digest never leaves the repo
|
|
16
|
+
// it was computed in, but a bundle does). Everything here is copied from
|
|
17
|
+
// what findingProvenance already computed. Nothing is inferred.
|
|
18
|
+
//
|
|
19
|
+
// canonicalJson vs canonicalBytes — resolved before writing this file
|
|
20
|
+
// -------------------------------------------------------------------
|
|
21
|
+
// evidence-bundle.js exports TWO canonicalisation helpers, not one, and they
|
|
22
|
+
// are not interchangeable. `canonicalJson(value)` is a pure, shape-agnostic
|
|
23
|
+
// deterministic serialiser (sorted keys at every level, order-preserving
|
|
24
|
+
// arrays) — safe to reuse for any bundle shape. `canonicalBytes(bundle)`
|
|
25
|
+
// is NOT generic: it hardcodes evidence-bundle.js's own six top-level keys
|
|
26
|
+
// (`schema, finding, evidence, engine, proves, doesNotProve`) when building
|
|
27
|
+
// the object it signs. Reusing `canonicalBytes` here would silently sign a
|
|
28
|
+
// filtered object missing this module's `repo` and `provenance` fields
|
|
29
|
+
// (and would read `bundle.evidence`, which a provenance bundle never has) —
|
|
30
|
+
// exactly the EA-03 failure mode the allowlist check below exists to catch,
|
|
31
|
+
// just introduced from the signing side instead of the verifying side. So
|
|
32
|
+
// this module imports the generic `canonicalJson` (as the brief's Step 1
|
|
33
|
+
// sketch already did) and signs `canonicalJson(bundle)` directly, since
|
|
34
|
+
// `buildProvenanceEvidenceBundle` never puts anything on the unsigned
|
|
35
|
+
// object beyond this module's own allowlisted fields.
|
|
36
|
+
|
|
37
|
+
import * as crypto from 'node:crypto';
|
|
38
|
+
import { ensureKeyPair, keyPaths, canonicalJson } from './evidence-bundle.js';
|
|
39
|
+
|
|
40
|
+
export const PROVENANCE_BUNDLE_SCHEMA = 'agentic-security/provenance-evidence@1';
|
|
41
|
+
|
|
42
|
+
const PROVES = 'This bundle\'s contents are exactly what was signed at attestation time.';
|
|
43
|
+
const DOES_NOT_PROVE = 'This bundle does NOT prove the origin commit is correctly identified — read confidence.level and limitations for that. It proves the RECORD is unmodified, not that the record is right.';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build an unsigned provenance bundle from one finding's findingProvenance.
|
|
47
|
+
* Returns null for a finding with no findingProvenance at all (nothing to
|
|
48
|
+
* attest) — this is a caller error (attest a scan before its provenance
|
|
49
|
+
* pass ran), not a case to paper over with an empty bundle.
|
|
50
|
+
*/
|
|
51
|
+
export function buildProvenanceEvidenceBundle(finding, { engineVersion, repoIdentity, head } = {}) {
|
|
52
|
+
if (!finding || typeof finding !== 'object') return null;
|
|
53
|
+
const fp = finding.findingProvenance;
|
|
54
|
+
if (!fp || typeof fp !== 'object') return null;
|
|
55
|
+
return {
|
|
56
|
+
schema: PROVENANCE_BUNDLE_SCHEMA,
|
|
57
|
+
finding: {
|
|
58
|
+
id: finding.id ?? null,
|
|
59
|
+
stableId: finding.stableId ?? null,
|
|
60
|
+
},
|
|
61
|
+
repo: {
|
|
62
|
+
identity: repoIdentity ?? null,
|
|
63
|
+
head: head ?? fp.analysisBasis?.head ?? null,
|
|
64
|
+
},
|
|
65
|
+
provenance: {
|
|
66
|
+
status: fp.status ?? null,
|
|
67
|
+
findingOrigin: fp.findingOrigin
|
|
68
|
+
? {
|
|
69
|
+
commit: fp.findingOrigin.commit ?? null,
|
|
70
|
+
authorName: fp.findingOrigin.authorName ?? null,
|
|
71
|
+
authorDate: fp.findingOrigin.authorDate ?? null,
|
|
72
|
+
summary: fp.findingOrigin.summary ?? null,
|
|
73
|
+
}
|
|
74
|
+
: null,
|
|
75
|
+
branchIntroduction: fp.branchIntroduction
|
|
76
|
+
? { commit: fp.branchIntroduction.commit ?? null, branch: fp.branchIntroduction.branch ?? null }
|
|
77
|
+
: null,
|
|
78
|
+
evidenceAttribution: (fp.evidenceAttribution || []).map((n) => ({
|
|
79
|
+
role: n.role ?? null, path: n.path ?? null, line: n.line ?? null, commit: n.commit ?? null,
|
|
80
|
+
})),
|
|
81
|
+
method: fp.method ?? null,
|
|
82
|
+
confidence: fp.confidence
|
|
83
|
+
? { level: fp.confidence.level ?? null, score: fp.confidence.score ?? null, reasons: fp.confidence.reasons || [] }
|
|
84
|
+
: null,
|
|
85
|
+
limitations: fp.limitations || [],
|
|
86
|
+
// M4 §4.2 final-review fix: this module was written (Task 1) before
|
|
87
|
+
// the cross-repo lineage feature existed (Task 5), so a
|
|
88
|
+
// cross-repo-resolved origin's boundary-crossing marker was dropped
|
|
89
|
+
// from the signed bundle — a foreign repository's commit SHA and a
|
|
90
|
+
// real author name, with no MACHINE-READABLE signal that the origin
|
|
91
|
+
// crossed a repository boundary (only the prose in `limitations`
|
|
92
|
+
// said so, and a programmatic verifier does not read prose). Nested
|
|
93
|
+
// under `provenance` alongside the fields above, so it is part of
|
|
94
|
+
// what gets signed — never a new top-level key.
|
|
95
|
+
historyCoverage: { crossRepoLineage: fp.historyCoverage?.crossRepoLineage ?? false },
|
|
96
|
+
},
|
|
97
|
+
engine: { engineVersion: engineVersion ?? null },
|
|
98
|
+
proves: PROVES,
|
|
99
|
+
doesNotProve: DOES_NOT_PROVE,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function signProvenanceEvidenceBundle(bundle, privateKeyPem) {
|
|
104
|
+
const sig = crypto.sign(null, Buffer.from(canonicalJson(bundle), 'utf8'), privateKeyPem);
|
|
105
|
+
return {
|
|
106
|
+
...bundle,
|
|
107
|
+
signature: { algorithm: 'ed25519', canonicalisation: PROVENANCE_BUNDLE_SCHEMA, value: sig.toString('base64') },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const PROVENANCE_BUNDLE_TOP_LEVEL_KEYS = new Set([
|
|
112
|
+
'schema', 'finding', 'repo', 'provenance', 'engine', 'proves', 'doesNotProve', 'signature',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Verify with a PUBLIC key only. Rejects any top-level key outside the
|
|
117
|
+
* allowlist BEFORE checking the signature — same EA-03 fix evidence-bundle.js
|
|
118
|
+
* carries: a signature only covers the bytes it was computed over, so an
|
|
119
|
+
* unknown key stapled on after signing would otherwise verify as authentic.
|
|
120
|
+
*/
|
|
121
|
+
export function verifyProvenanceEvidenceBundle(bundle, publicKeyPem) {
|
|
122
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
123
|
+
if (bundle.schema !== PROVENANCE_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
124
|
+
const unknownKeys = Object.keys(bundle).filter((k) => !PROVENANCE_BUNDLE_TOP_LEVEL_KEYS.has(k));
|
|
125
|
+
if (unknownKeys.length) {
|
|
126
|
+
return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
127
|
+
}
|
|
128
|
+
const sig = bundle.signature;
|
|
129
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
130
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
131
|
+
if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
|
|
132
|
+
const { signature, ...unsigned } = bundle;
|
|
133
|
+
let ok = false;
|
|
134
|
+
try {
|
|
135
|
+
ok = crypto.verify(null, Buffer.from(canonicalJson(unsigned), 'utf8'), publicKeyPem, Buffer.from(sig.value, 'base64'));
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return { ok: false, reason: `verification error: ${e.message}` };
|
|
138
|
+
}
|
|
139
|
+
return ok
|
|
140
|
+
? { ok: true, reason: null }
|
|
141
|
+
: { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { ensureKeyPair, keyPaths };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Retention policy: default and maximum TTL by artifact class (assurance-
|
|
2
|
+
// hardening PRD FR-702).
|
|
3
|
+
//
|
|
4
|
+
// "Enforce default and maximum TTL by artifact class | Expired caches,
|
|
5
|
+
// scans, evidence, tickets, and backups are purged or archived according
|
|
6
|
+
// to policy."
|
|
7
|
+
//
|
|
8
|
+
// TWO NUMBERS PER CLASS, ON PURPOSE
|
|
9
|
+
// --------------------------------------------------------------------------
|
|
10
|
+
// `defaultDays` is what applies when nobody configures anything — a
|
|
11
|
+
// reasonable, disclosed starting point, not a regulatory citation (an
|
|
12
|
+
// operator with a real compliance regime should configure their own via
|
|
13
|
+
// the policy file below). `maxDays` is a CEILING an operator's own
|
|
14
|
+
// configuration cannot exceed: the literal "default AND maximum" wording
|
|
15
|
+
// asks for both a floor-free default and a hard cap, not just a knob. An
|
|
16
|
+
// operator who wants indefinite retention for `evidence` for their own
|
|
17
|
+
// audit reasons should say so in their own retention program — this
|
|
18
|
+
// module will not silently allow a TTL past its built-in ceiling for a
|
|
19
|
+
// class, because "TTL" than can be configured to "never" is not a TTL.
|
|
20
|
+
//
|
|
21
|
+
// PURGE, NOT ARCHIVE
|
|
22
|
+
// --------------------------------------------------------------------------
|
|
23
|
+
// The acceptance criterion allows either. This implements PURGE (delete)
|
|
24
|
+
// only — the simpler, safer, and more directly verifiable of the two.
|
|
25
|
+
// "Archive" implies a defined export format and destination, which is
|
|
26
|
+
// FR-706's own separate scope (manifest-based export); an operator who
|
|
27
|
+
// wants to archive before purging already has the tool for it once FR-706
|
|
28
|
+
// exists, or can back up `.agentic-security/` by their own means before
|
|
29
|
+
// running the enforcement command.
|
|
30
|
+
//
|
|
31
|
+
// WHICH ARTIFACTS THIS APPLIES TO
|
|
32
|
+
// --------------------------------------------------------------------------
|
|
33
|
+
// Only 'generated' artifacts carrying a `retentionClass` in
|
|
34
|
+
// artifact-registry.js — deliberately a SUBSET of all generated artifacts
|
|
35
|
+
// (see that module's own header for which ones were left classless and
|
|
36
|
+
// why). An 'operator-config' artifact is NEVER touched by this module,
|
|
37
|
+
// regardless of age, matching FR-703's own "reset preserves operator-
|
|
38
|
+
// authored configuration" precedent.
|
|
39
|
+
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as yaml from '../util/yaml.js';
|
|
42
|
+
import { statePath, stateDir } from './state-dir.js';
|
|
43
|
+
import { listArtifactsWithRetentionClass } from './artifact-registry.js';
|
|
44
|
+
import { loadLegalHolds, isUnderHold } from './legal-hold.js';
|
|
45
|
+
|
|
46
|
+
const POLICY_FILE = 'retention-policy.yml';
|
|
47
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
// Engineering defaults, not a regulatory claim — see the header above.
|
|
50
|
+
export const RETENTION_DEFAULTS = {
|
|
51
|
+
cache: { defaultDays: 7, maxDays: 30 },
|
|
52
|
+
scan: { defaultDays: 90, maxDays: 365 },
|
|
53
|
+
evidence: { defaultDays: 365, maxDays: 1095 },
|
|
54
|
+
ticket: { defaultDays: 180, maxDays: 730 },
|
|
55
|
+
backup: { defaultDays: 30, maxDays: 180 },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load an operator's `.agentic-security/retention-policy.yml`. Never
|
|
60
|
+
* throws — a missing or malformed file degrades to "no overrides," the
|
|
61
|
+
* same no-op-until-configured convention this repo uses for every other
|
|
62
|
+
* policy surface. Shape:
|
|
63
|
+
* cache: { defaultDays: 3 }
|
|
64
|
+
* evidence: { defaultDays: 730 }
|
|
65
|
+
* A class not mentioned, or a file not present at all, uses
|
|
66
|
+
* RETENTION_DEFAULTS unmodified.
|
|
67
|
+
*/
|
|
68
|
+
export function loadRetentionPolicy(scanRoot) {
|
|
69
|
+
if (!scanRoot) return null;
|
|
70
|
+
let fp;
|
|
71
|
+
try { fp = statePath(scanRoot, POLICY_FILE); } catch { return null; }
|
|
72
|
+
let raw;
|
|
73
|
+
try { raw = fs.readFileSync(fp, 'utf8'); } catch { return null; }
|
|
74
|
+
try {
|
|
75
|
+
const doc = yaml.load(raw);
|
|
76
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
77
|
+
return doc;
|
|
78
|
+
} catch { return null; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The TTL (in days) actually in effect for a class, after applying any
|
|
83
|
+
* operator override and clamping it to the class's own maxDays — an
|
|
84
|
+
* override CANNOT raise retention past the ceiling, only lower it (or
|
|
85
|
+
* leave it at the default). An unrecognised class (should not happen —
|
|
86
|
+
* only artifact-registry.js's own 5 named classes are ever passed in)
|
|
87
|
+
* degrades to null, meaning "not subject to a TTL."
|
|
88
|
+
*/
|
|
89
|
+
export function effectiveTtlDays(retentionClass, policy) {
|
|
90
|
+
const bounds = RETENTION_DEFAULTS[retentionClass];
|
|
91
|
+
if (!bounds) return null;
|
|
92
|
+
const override = policy?.[retentionClass]?.defaultDays;
|
|
93
|
+
if (typeof override === 'number' && Number.isFinite(override) && override >= 0) {
|
|
94
|
+
return Math.min(override, bounds.maxDays);
|
|
95
|
+
}
|
|
96
|
+
return bounds.defaultDays;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which registered, retention-classed artifacts under `scanRoot`'s state
|
|
101
|
+
* dir are currently past their effective TTL. Never throws — a missing
|
|
102
|
+
* state dir or an artifact that does not exist on disk is simply absent
|
|
103
|
+
* from the result, not an error.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Array<{name: string, isDir: boolean, retentionClass: string,
|
|
106
|
+
* ageDays: number, ttlDays: number}>}
|
|
107
|
+
*/
|
|
108
|
+
export function findExpiredArtifacts(scanRoot, { now = Date.now() } = {}) {
|
|
109
|
+
const dir = stateDir(scanRoot);
|
|
110
|
+
let dirExists = true;
|
|
111
|
+
try { fs.accessSync(dir); } catch { dirExists = false; }
|
|
112
|
+
if (!dirExists) return [];
|
|
113
|
+
const policy = loadRetentionPolicy(scanRoot);
|
|
114
|
+
// FR-707: an artifact under an active legal hold is NEVER reported as
|
|
115
|
+
// expired, regardless of TTL — checked here (not just in cmdReset) so
|
|
116
|
+
// any future caller of this function inherits the same guarantee.
|
|
117
|
+
const holds = loadLegalHolds(scanRoot);
|
|
118
|
+
const expired = [];
|
|
119
|
+
for (const artifact of listArtifactsWithRetentionClass()) {
|
|
120
|
+
const ttlDays = effectiveTtlDays(artifact.retentionClass, policy);
|
|
121
|
+
if (ttlDays === null) continue;
|
|
122
|
+
if (isUnderHold(artifact.name, holds, now)) continue;
|
|
123
|
+
const p = `${dir}/${artifact.name}`;
|
|
124
|
+
let st;
|
|
125
|
+
try { st = fs.statSync(p); } catch { continue; } // not present — nothing to expire
|
|
126
|
+
const ageDays = (now - st.mtimeMs) / MS_PER_DAY;
|
|
127
|
+
if (ageDays > ttlDays) {
|
|
128
|
+
expired.push({ name: artifact.name, isDir: st.isDirectory(), retentionClass: artifact.retentionClass, ageDays, ttlDays });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return expired;
|
|
132
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Discount reachability tier (route-reachable > function-reachable
|
|
10
10
|
// > unknown > unreachable)
|
|
11
11
|
//
|
|
12
|
-
// EV per finding = P × Impact × Discount ×
|
|
12
|
+
// EV per finding = P × Impact × Discount × ConfidenceWeight
|
|
13
13
|
//
|
|
14
14
|
// Industry breach-cost figures used here are sourced from publicly
|
|
15
15
|
// reported aggregates (Ponemon Cost of a Data Breach Report — IBM/Verizon
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
//
|
|
20
20
|
// Disclaimer: this is an order-of-magnitude estimate for prioritization.
|
|
21
21
|
// It is NOT an actuarial or insurance assessment.
|
|
22
|
+
//
|
|
23
|
+
// FR-803/FR-805 (assurance-hardening PRD): the single `ev` number above is
|
|
24
|
+
// only ever the BASE scenario. Every finding also gets `range` (low/base/
|
|
25
|
+
// high spanning conservative/base/severe), `scenarios` (all three, named),
|
|
26
|
+
// `assumptions` (which table entry and which tier produced each factor),
|
|
27
|
+
// and `modelVersion` — so a reader can trace any dollar figure back to
|
|
28
|
+
// exactly the inputs and methodology version that produced it, and compare
|
|
29
|
+
// scenarios without any of this ever touching `finding.severity`.
|
|
22
30
|
|
|
23
31
|
import * as fs from 'node:fs';
|
|
24
32
|
import * as path from 'node:path';
|
|
@@ -78,36 +86,126 @@ const REACH_DISCOUNT = {
|
|
|
78
86
|
'function-reachable-but-not-route':0.4,
|
|
79
87
|
};
|
|
80
88
|
|
|
89
|
+
// Tiny YAML — parses the two flat "section:\n key: number" blocks
|
|
90
|
+
// (impactUSD, familyBaseProb) plus four top-level flat scalar keys (FR-802's
|
|
91
|
+
// organization-specific inputs, added below). Deliberately line-based rather
|
|
92
|
+
// than one multi-line regex: a single regex here
|
|
93
|
+
// (`(?:\s+\w+\s*:\s*\d+\s*\n?)+`) looked correct but had a real, silent bug
|
|
94
|
+
// — `\s*` before the optional trailing `\n?` greedily consumed the NEXT
|
|
95
|
+
// line's leading indentation too, so the repeated group's `+` quantifier
|
|
96
|
+
// only ever matched ONE entry before its required leading `\s+` failed to
|
|
97
|
+
// find any whitespace left to consume. A risk-config.yml with more than one
|
|
98
|
+
// impactUSD entry silently kept only the first — found while adding
|
|
99
|
+
// familyBaseProb support (which inherited the identical bug) and testing
|
|
100
|
+
// it with more than one entry, per this session's standing rule of testing
|
|
101
|
+
// multi-entry cases, not just the single-entry case a hand-rolled parser's
|
|
102
|
+
// own author tends to reach for first.
|
|
103
|
+
//
|
|
104
|
+
// FR-802: `organizationScale`, `industry`, `recordCount`, `controlStrength`
|
|
105
|
+
// are free-form top-level scalars (not sections) — an operator states them
|
|
106
|
+
// directly, e.g. `industry: healthcare`. `recordCount` is parsed as an
|
|
107
|
+
// integer; a non-numeric value is treated as not configured (never silently
|
|
108
|
+
// coerced to NaN, which `!= null` would otherwise count as "present").
|
|
109
|
+
const FLAT_SCALAR_KEYS = ['organizationScale', 'industry', 'recordCount', 'controlStrength'];
|
|
110
|
+
|
|
81
111
|
function _loadConfig(scanRoot) {
|
|
82
112
|
const fp = statePath(scanRoot, 'risk-config.yml');
|
|
83
113
|
if (!fs.existsSync(fp)) return null;
|
|
84
114
|
try {
|
|
85
115
|
const body = fs.readFileSync(fp, 'utf8');
|
|
86
|
-
// Tiny YAML — look for impactUSD / familyBaseProb overrides
|
|
87
116
|
const cfg = {};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
let section = null;
|
|
118
|
+
for (const line of body.split(/\r?\n/)) {
|
|
119
|
+
const sectionMatch = line.match(/^(impactUSD|familyBaseProb)\s*:\s*$/);
|
|
120
|
+
if (sectionMatch) { section = sectionMatch[1]; continue; }
|
|
121
|
+
const flatMatch = !section && line.match(/^([A-Za-z][\w-]*)\s*:\s*(\S.*?)\s*$/);
|
|
122
|
+
if (flatMatch && FLAT_SCALAR_KEYS.includes(flatMatch[1])) {
|
|
123
|
+
if (flatMatch[1] === 'recordCount') {
|
|
124
|
+
const n = parseInt(flatMatch[2], 10);
|
|
125
|
+
if (Number.isFinite(n)) cfg.recordCount = n;
|
|
126
|
+
} else {
|
|
127
|
+
cfg[flatMatch[1]] = flatMatch[2];
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!section) continue;
|
|
132
|
+
const entryMatch = line.match(/^[ \t]+([\w-]+)\s*:\s*([\d.]+)\s*$/);
|
|
133
|
+
if (entryMatch) {
|
|
134
|
+
if (!cfg[section]) cfg[section] = {};
|
|
135
|
+
cfg[section][entryMatch[1]] = section === 'impactUSD' ? parseInt(entryMatch[2], 10) : parseFloat(entryMatch[2]);
|
|
136
|
+
} else if (line.trim() !== '') {
|
|
137
|
+
section = null; // a non-indented, non-blank line ends the current section
|
|
138
|
+
}
|
|
92
139
|
}
|
|
93
140
|
return cfg;
|
|
94
141
|
} catch { return null; }
|
|
95
142
|
}
|
|
96
143
|
|
|
97
|
-
|
|
144
|
+
// FR-801/FR-802: every dollar estimate is a SCENARIO by default — generic,
|
|
145
|
+
// industry-wide probability/impact tables, not this organization's actual
|
|
146
|
+
// exposure. `ORG_SPECIFIC_DIMENSIONS` is the full set of tunable knobs this
|
|
147
|
+
// module recognizes (includes `familyBaseProb`, a bonus calibration input
|
|
148
|
+
// that isn't one of FR-802's five named inputs). The stronger claim FR-802
|
|
149
|
+
// gates — "likely organizational loss" — requires ALL FIVE of its literally
|
|
150
|
+
// named inputs (scale, industry, record count, control strength, impact),
|
|
151
|
+
// tracked separately as `REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS` so that
|
|
152
|
+
// configuring `familyBaseProb` alone (or any subset of the five) can never
|
|
153
|
+
// unlock that label — only 'scenario_partially_configured', same as before.
|
|
154
|
+
const ORG_SPECIFIC_DIMENSIONS = ['impactUSD', 'familyBaseProb', 'organizationScale', 'industry', 'recordCount', 'controlStrength'];
|
|
155
|
+
const REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS = ['impactUSD', 'organizationScale', 'industry', 'recordCount', 'controlStrength'];
|
|
156
|
+
|
|
157
|
+
function _scenarioDisclosure(cfg) {
|
|
158
|
+
const configured = ORG_SPECIFIC_DIMENSIONS.filter(d => cfg && cfg[d] != null);
|
|
159
|
+
const unconfigured = ORG_SPECIFIC_DIMENSIONS.filter(d => !configured.includes(d));
|
|
160
|
+
const requiredMissing = REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS.filter(d => !(cfg && cfg[d] != null));
|
|
161
|
+
|
|
162
|
+
let status, message;
|
|
163
|
+
if (requiredMissing.length === 0) {
|
|
164
|
+
status = 'scenario_organization_specific';
|
|
165
|
+
message = `All organization-specific inputs are configured (${REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS.join(', ')}) — this estimate reflects a likely organizational loss for your organization, not a generic industry scenario.`;
|
|
166
|
+
} else if (configured.length > 0) {
|
|
167
|
+
status = 'scenario_partially_configured';
|
|
168
|
+
message = `Uses organization-configured values for: ${configured.join(', ')}. Still missing for a likely-organizational-loss estimate: ${requiredMissing.join(', ')}. This is NOT a likely-organizational-loss estimate.`;
|
|
169
|
+
} else {
|
|
170
|
+
status = 'scenario_default';
|
|
171
|
+
message = 'Uses generic industry-wide scenario defaults. No organization-specific inputs are configured (see .agentic-security/risk-config.yml) — this is NOT a likely-organizational-loss estimate.';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
status,
|
|
176
|
+
configuredInputs: configured,
|
|
177
|
+
unconfiguredInputs: unconfigured,
|
|
178
|
+
requiredForOrganizationSpecificLoss: REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS,
|
|
179
|
+
missingRequiredInputs: requiredMissing,
|
|
180
|
+
message,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _baseProb(family, cfg) {
|
|
98
185
|
if (!family) return 0.05;
|
|
186
|
+
const overrides = cfg && cfg.familyBaseProb;
|
|
187
|
+
if (overrides) {
|
|
188
|
+
const hit = overrides[family] ?? overrides[String(family).toLowerCase()];
|
|
189
|
+
if (typeof hit === 'number') return hit;
|
|
190
|
+
}
|
|
99
191
|
return FAMILY_BASE_PROB[family] || FAMILY_BASE_PROB[String(family).toLowerCase()] || 0.05;
|
|
100
192
|
}
|
|
101
193
|
|
|
194
|
+
// FR-803: split out from _impactFor so an assumption can name WHICH tier
|
|
195
|
+
// produced the number, not just the number itself.
|
|
196
|
+
function _impactTierOf(finding) {
|
|
197
|
+
const dc = Array.isArray(finding.dataClasses) ? finding.dataClasses : [];
|
|
198
|
+
if (dc.includes('PHI')) return 'PHI';
|
|
199
|
+
if (dc.includes('PCI')) return 'PCI';
|
|
200
|
+
if (dc.includes('PII')) return 'PII';
|
|
201
|
+
if (dc.includes('Confidential')) return 'Confidential';
|
|
202
|
+
if (finding.threatModel?.crownJewel) return 'crown-jewel';
|
|
203
|
+
return 'default';
|
|
204
|
+
}
|
|
205
|
+
|
|
102
206
|
function _impactFor(finding, cfg) {
|
|
103
207
|
const table = cfg && cfg.impactUSD ? { ...IMPACT_USD, ...cfg.impactUSD } : IMPACT_USD;
|
|
104
|
-
|
|
105
|
-
if (dc.includes('PHI')) return table.PHI;
|
|
106
|
-
if (dc.includes('PCI')) return table.PCI;
|
|
107
|
-
if (dc.includes('PII')) return table.PII;
|
|
108
|
-
if (dc.includes('Confidential')) return table.Confidential;
|
|
109
|
-
if (finding.threatModel?.crownJewel) return table['crown-jewel'];
|
|
110
|
-
return table.default;
|
|
208
|
+
return table[_impactTierOf(finding)];
|
|
111
209
|
}
|
|
112
210
|
|
|
113
211
|
// SCA entries carry reachabilityTier/routeReachable (engine.js's SCA
|
|
@@ -124,12 +222,16 @@ function _relevanceTierToReachTier(relevanceTier) {
|
|
|
124
222
|
}
|
|
125
223
|
}
|
|
126
224
|
|
|
127
|
-
|
|
128
|
-
|
|
225
|
+
// FR-803: split out from _reachDiscount so an assumption can name the tier.
|
|
226
|
+
function _reachTierOf(finding) {
|
|
227
|
+
return finding.reachabilityTier
|
|
129
228
|
|| (finding.routeReachable && 'route-reachable')
|
|
130
229
|
|| _relevanceTierToReachTier(finding.relevanceTier)
|
|
131
230
|
|| 'unknown';
|
|
132
|
-
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function _reachDiscount(finding) {
|
|
234
|
+
return REACH_DISCOUNT[_reachTierOf(finding)] || 0.3;
|
|
133
235
|
}
|
|
134
236
|
|
|
135
237
|
function _epssProb(finding) {
|
|
@@ -138,28 +240,111 @@ function _epssProb(finding) {
|
|
|
138
240
|
return null;
|
|
139
241
|
}
|
|
140
242
|
|
|
243
|
+
// FR-803: names WHERE the probability number came from, for the assumptions
|
|
244
|
+
// list — not just the value.
|
|
245
|
+
function _probSource(finding, cfg) {
|
|
246
|
+
if (_epssProb(finding) != null) return 'EPSS score (finding-specific)';
|
|
247
|
+
const overrides = cfg && cfg.familyBaseProb;
|
|
248
|
+
if (overrides && (overrides[finding.family] != null || overrides[String(finding.family).toLowerCase()] != null)) {
|
|
249
|
+
return 'operator-configured familyBaseProb override';
|
|
250
|
+
}
|
|
251
|
+
return 'built-in industry base-rate table';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Bump ONLY when the EV formula or a table's underlying MEANING changes
|
|
255
|
+
// (e.g. re-deriving FAMILY_BASE_PROB from a new source) — never for an
|
|
256
|
+
// additive field on the output shape, which stays backward compatible.
|
|
257
|
+
export const RISK_MODEL_VERSION = '1.0.0';
|
|
258
|
+
|
|
259
|
+
// FR-805: scenario multipliers apply only to the two GENERIC assumption
|
|
260
|
+
// inputs — probability-of-exploit and impact-per-incident — that FR-801
|
|
261
|
+
// already discloses as industry-wide defaults. Reachability discount and
|
|
262
|
+
// confidence weight are both measured facts about THIS finding in THIS
|
|
263
|
+
// scan, not assumptions with real-world spread, so they stay fixed across
|
|
264
|
+
// scenarios rather than being perturbed for effect.
|
|
265
|
+
const SCENARIO_MULTIPLIERS = {
|
|
266
|
+
conservative: { prob: 0.5, impact: 0.6 },
|
|
267
|
+
base: { prob: 1.0, impact: 1.0 },
|
|
268
|
+
severe: { prob: 1.75, impact: 1.5 },
|
|
269
|
+
};
|
|
270
|
+
|
|
141
271
|
/**
|
|
142
272
|
* Compute EV per finding. Mutates the finding in place: adds
|
|
143
|
-
* .riskDollars = { ev, prob, impact, discount
|
|
273
|
+
* .riskDollars = { ev, prob, impact, discount, confidenceWeight,
|
|
274
|
+
* scenarioStatus, range: {low, base, high}, scenarios: {conservative,
|
|
275
|
+
* base, severe}, assumptions: [...], modelVersion, confidence }.
|
|
144
276
|
*/
|
|
145
277
|
export function annotateRiskDollars(scanRoot, findings) {
|
|
146
|
-
|
|
278
|
+
const disclosure = _scenarioDisclosure(_loadConfig(scanRoot));
|
|
279
|
+
if (!Array.isArray(findings) || findings.length === 0) return { total: 0, sumEv: 0, scenario: disclosure };
|
|
147
280
|
const cfg = _loadConfig(scanRoot);
|
|
148
281
|
let sumEv = 0;
|
|
149
282
|
let critEv = 0, highEv = 0;
|
|
150
283
|
for (const f of findings) {
|
|
151
284
|
const epss = _epssProb(f);
|
|
152
|
-
const prob = epss != null ? epss : _baseProb(f.family);
|
|
285
|
+
const prob = epss != null ? epss : _baseProb(f.family, cfg);
|
|
286
|
+
const impactTier = _impactTierOf(f);
|
|
153
287
|
const impact = _impactFor(f, cfg);
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
f.
|
|
288
|
+
const reachTier = _reachTierOf(f);
|
|
289
|
+
const discount = REACH_DISCOUNT[reachTier] || 0.3;
|
|
290
|
+
// FR-804 (assurance-hardening PRD): this used to be
|
|
291
|
+
// `Math.max(0.4, f.confidence || 0.8)` — TWO stacked bugs. First, `||`
|
|
292
|
+
// treats a real confidence of 0 (a legitimate, very-low value) as falsy
|
|
293
|
+
// and silently substitutes 0.8, the HIGH default — a genuinely
|
|
294
|
+
// near-zero-confidence finding got the same dollar weight as a
|
|
295
|
+
// near-certain one. Second, the unconditional `Math.max(0.4, ...)`
|
|
296
|
+
// floor inflated every OTHER low-confidence finding (0.05, 0.1, ...) up
|
|
297
|
+
// to 0.4 regardless, which is exactly "an artificially high confidence
|
|
298
|
+
// without an explicit reason" FR-804 names. Fixed: use the finding's
|
|
299
|
+
// real confidence whenever it is genuinely present (including 0), with
|
|
300
|
+
// no floor; 0.8 remains the default ONLY when confidence is absent
|
|
301
|
+
// (not a number at all), which is a "we don't know" default, not an
|
|
302
|
+
// inflation of a known low value.
|
|
303
|
+
const confidenceWeight = typeof f.confidence === 'number' ? f.confidence : 0.8;
|
|
304
|
+
|
|
305
|
+
// FR-805: three named scenarios, sharing the SAME discount/confidence —
|
|
306
|
+
// only the two generic inputs (prob, impact) are perturbed. `ev` (base
|
|
307
|
+
// scenario) is unchanged from before this cycle, since base's
|
|
308
|
+
// multipliers are 1.0/1.0.
|
|
309
|
+
const scenarios = {};
|
|
310
|
+
for (const [name, mult] of Object.entries(SCENARIO_MULTIPLIERS)) {
|
|
311
|
+
const scenarioProb = Math.min(1, prob * mult.prob);
|
|
312
|
+
const scenarioImpact = Math.round(impact * mult.impact);
|
|
313
|
+
scenarios[name] = Math.round(scenarioProb * scenarioImpact * discount * confidenceWeight);
|
|
314
|
+
}
|
|
315
|
+
const ev = scenarios.base;
|
|
316
|
+
|
|
317
|
+
// FR-803: every dollar value traces to its own assumptions and a model
|
|
318
|
+
// version — not just a bare number.
|
|
319
|
+
const assumptions = [
|
|
320
|
+
`probability of exploit: ${Number(prob.toFixed(3))} (source: ${_probSource(f, cfg)})`,
|
|
321
|
+
`impact estimate: ${fmtUsd(impact)} (tier: ${impactTier}, ${cfg?.impactUSD?.[impactTier] != null ? 'operator-configured' : 'built-in default'})`,
|
|
322
|
+
`reachability discount: ${discount} (tier: ${reachTier})`,
|
|
323
|
+
`confidence weight: ${Number(confidenceWeight.toFixed(2))} (${typeof f.confidence === 'number' ? 'measured from this scan' : 'unknown — defaulted'})`,
|
|
324
|
+
];
|
|
325
|
+
// FR-803: a distinct confidence indicator on the estimate itself — not
|
|
326
|
+
// the per-finding confidenceWeight multiplier, but a coarse tier
|
|
327
|
+
// reflecting how much of this number is organization-specific vs.
|
|
328
|
+
// generic-default, folded with the finding's own detection confidence.
|
|
329
|
+
const confidence = disclosure.status === 'scenario_organization_specific' && confidenceWeight >= 0.7
|
|
330
|
+
? 'high'
|
|
331
|
+
: (disclosure.status === 'scenario_default' || confidenceWeight < 0.4 ? 'low' : 'medium');
|
|
332
|
+
|
|
333
|
+
f.riskDollars = {
|
|
334
|
+
ev, prob: Number(prob.toFixed(3)), impact, discount,
|
|
335
|
+
confidenceWeight: Number(confidenceWeight.toFixed(2)),
|
|
336
|
+
scenarioStatus: disclosure.status,
|
|
337
|
+
range: { low: scenarios.conservative, base: scenarios.base, high: scenarios.severe },
|
|
338
|
+
scenarios,
|
|
339
|
+
assumptions,
|
|
340
|
+
modelVersion: RISK_MODEL_VERSION,
|
|
341
|
+
confidence,
|
|
342
|
+
};
|
|
158
343
|
sumEv += ev;
|
|
159
344
|
if (f.severity === 'critical') critEv += ev;
|
|
160
345
|
else if (f.severity === 'high') highEv += ev;
|
|
161
346
|
}
|
|
162
|
-
return { total: findings.length, sumEv, critEv, highEv };
|
|
347
|
+
return { total: findings.length, sumEv, critEv, highEv, scenario: disclosure };
|
|
163
348
|
}
|
|
164
349
|
|
|
165
350
|
/**
|
|
@@ -172,4 +357,9 @@ export function fmtUsd(n) {
|
|
|
172
357
|
return `$${n}`;
|
|
173
358
|
}
|
|
174
359
|
|
|
175
|
-
export const _internals = {
|
|
360
|
+
export const _internals = {
|
|
361
|
+
FAMILY_BASE_PROB, IMPACT_USD, REACH_DISCOUNT, ORG_SPECIFIC_DIMENSIONS,
|
|
362
|
+
REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS, SCENARIO_MULTIPLIERS,
|
|
363
|
+
_baseProb, _impactFor, _impactTierOf, _reachDiscount, _reachTierOf,
|
|
364
|
+
_probSource, _loadConfig, _scenarioDisclosure,
|
|
365
|
+
};
|
package/src/posture/sbom-diff.js
CHANGED
|
@@ -24,7 +24,8 @@ import * as fs from 'node:fs';
|
|
|
24
24
|
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
26
|
import * as crypto from 'node:crypto';
|
|
27
|
-
import {
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
28
29
|
|
|
29
30
|
const HISTORY_DIR = 'sbom-history';
|
|
30
31
|
|
|
@@ -32,9 +33,21 @@ function _historyDir(scanRoot) {
|
|
|
32
33
|
return statePath(scanRoot, HISTORY_DIR);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// `scanRoot` is the scanned project's repository, not this project's own
|
|
37
|
+
// trusted checkout — hardened per FR-PROV-024 / the second Finding
|
|
38
|
+
// Provenance PRD audit sweep (found missing here by a follow-up review that
|
|
39
|
+
// grepped for `child_process` usage beyond just `execFileSync('git'` call
|
|
40
|
+
// sites). `rev-parse HEAD` was VERIFIED not to itself trigger
|
|
41
|
+
// `core.fsmonitor`/a hook (it touches neither the working tree nor the
|
|
42
|
+
// index), so this is not a second live RCE — but leaving
|
|
43
|
+
// `GIT_CONFIG_NOSYSTEM`/`GIT_TERMINAL_PROMPT`/`core.hooksPath` unset here
|
|
44
|
+
// still violated this module's own "every git call routes through
|
|
45
|
+
// hardenGitArgs/hardenGitEnv" contract, and `execSync` (a shell string) is
|
|
46
|
+
// gratuitous risk this call never needed — `rev-parse HEAD` has no
|
|
47
|
+
// caller-controlled input to interpolate at all.
|
|
35
48
|
function _gitHead(scanRoot) {
|
|
36
49
|
try {
|
|
37
|
-
return
|
|
50
|
+
return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
|
|
38
51
|
} catch { return null; }
|
|
39
52
|
}
|
|
40
53
|
|