@clear-capabilities/agentic-security-scanner 0.144.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 +251 -0
- package/bin/agentic-security.js +294 -3
- package/dist/113.index.js +11 -3
- package/dist/178.index.js +24 -6
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +22 -0
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +11 -3
- package/dist/637.index.js +27 -5
- package/dist/970.index.js +65 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -8
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +488 -29
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +20 -0
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/posture/CLAUDE.md +121 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +24 -0
- package/src/posture/auditor-walkthrough.js +116 -13
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -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/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/util/git-hardening.js +128 -0
|
@@ -125,6 +125,28 @@ export const ARTIFACT_REGISTRY = [
|
|
|
125
125
|
{ name: 'sca-upgrade-history', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
|
|
126
126
|
{ name: 'scan-baselines', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'posture/pr-augment.js' },
|
|
127
127
|
{ name: 'agent-scratchpad', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'mcp/tools.js (append_scratchpad)' },
|
|
128
|
+
// Finding Provenance (M0/M1, split per PRD Section 8 retention task). Used
|
|
129
|
+
// to be one directory with two writers sharing it, which meant they could
|
|
130
|
+
// not get different retention treatment — `cmdReset`/`findExpiredArtifacts`
|
|
131
|
+
// only ever operate on exact TOP-LEVEL `.agentic-security/` directory
|
|
132
|
+
// names, never on a `/`-qualified sub-path. Now physically split:
|
|
133
|
+
// - posture/provenance/cache.js writes provenance-cache/<hash>.json — a
|
|
134
|
+
// pure HEAD-keyed memo of resolved origins, safely regenerable, no
|
|
135
|
+
// correctness dependency on being preserved. Gets retentionClass:
|
|
136
|
+
// 'cache' (7-day default / 30-day max TTL, RETENTION_DEFAULTS.cache).
|
|
137
|
+
// - posture/provenance/lifecycle.js writes provenance/lifecycle.json +
|
|
138
|
+
// .lock — the introduce/remediate/reintroduce ledger. Deliberately NO
|
|
139
|
+
// retentionClass: this is permanent history, not a cache; auto-expiring
|
|
140
|
+
// it would silently lose lifecycle events a report may already have
|
|
141
|
+
// cited. `reset` (without `--expired`) still clears it, which is
|
|
142
|
+
// explicit operator action, unlike TTL-driven auto-expiry.
|
|
143
|
+
// Old-location cache files (`provenance/cache/*.json`, written before this
|
|
144
|
+
// split) are DELIBERATELY NOT migrated — see cache.js's own comment and the
|
|
145
|
+
// commit that introduced this split. They are simply orphaned: invisible to
|
|
146
|
+
// this registry, un-swept by reset/retention, silently ignored by the new
|
|
147
|
+
// code, and harmless to leave until a human deletes them by hand.
|
|
148
|
+
{ name: 'provenance-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'posture/provenance/cache.js -- pure HEAD-keyed memo, safely regenerable, no correctness dependency on being preserved' },
|
|
149
|
+
{ name: 'provenance', kind: 'dir', classification: 'generated', source: 'posture/provenance/lifecycle.js -- the introduce/remediate/reintroduce ledger. Deliberately NO retentionClass: this is permanent history, not a cache; auto-expiring it would silently lose lifecycle events a report may already have cited.' },
|
|
128
150
|
{ name: 'AGENTS.md', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
|
|
129
151
|
{ name: 'AGENTS.md.archive', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
|
|
130
152
|
{ name: 'baseline.json', kind: 'file', classification: 'generated', source: 'bin/agentic-security.js (--set-baseline)', note: 'operator-set intent, functionally closer to operator-config than scan output — no auto-expiry' },
|
|
@@ -171,6 +193,8 @@ export const ARTIFACT_REGISTRY = [
|
|
|
171
193
|
{ name: 'legal-holds.json', kind: 'file', classification: 'operator-config', note: 'FR-707 legal holds ({artifact, owner, reason, expires_at}) — read by posture/retention-policy.js and bin/agentic-security.js cmdReset; WRITTEN by the CLI (legal-hold add/remove), but classified operator-config (not generated) deliberately: a plain `reset` must never be able to delete the very record protecting other artifacts from deletion' },
|
|
172
194
|
{ name: 'calibration-feedback.jsonl', kind: 'file', classification: 'operator-config', note: 'FR-806 opt-in calibration ground truth ({at, findingId, outcome: accept-risk|realized-incident, predicted*, note}) — WRITTEN by the CLI (calibration-feedback record), but classified operator-config like exploit-history.jsonl: real, hard-to-recreate customer-reported ground truth, never scanner-regenerable, so a routine reset must never delete it' },
|
|
173
195
|
{ name: 'encryption-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-705 encryption provider/required opt-in policy ({provider: local-key, required: true|false}) — read by posture/encryption-provider.js, never written by the scanner' },
|
|
196
|
+
{ name: 'provenance-providers.yml', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M3 §3.4 GitHub/GitLab provider enrichment opt-in ({token} or provider-scoped tokens) — read by posture/provenance/providers/config.js, never written by the scanner; env vars (AGENTIC_SECURITY_GITHUB_TOKEN/AGENTIC_SECURITY_GITLAB_TOKEN) take precedence when set' },
|
|
197
|
+
{ name: 'repo-lineage.json', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M4 §4.2 cross-repository lineage declaration ({linkedFrom: {path, atCommit}}) — read by posture/provenance/repo-lineage.js, never written by the scanner; the linked path is verified as a real local git repo before use, no remote fetch' },
|
|
174
198
|
{ name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
|
|
175
199
|
{ name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
|
|
176
200
|
{ name: 'exploit-history.jsonl', kind: 'file', classification: 'operator-config', note: 'own header comment: "operator-curated record of past confirmed exploits"' },
|
|
@@ -41,9 +41,81 @@ import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
|
41
41
|
import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
|
|
42
42
|
import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
|
|
43
43
|
import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
|
|
44
|
+
// FR-PROV-026: earliestOrigin.authorName below is untrusted git commit
|
|
45
|
+
// metadata. renderWalkthrough()'s output is console.log'd verbatim by
|
|
46
|
+
// bin/agentic-security.js's `compliance --walkthrough` — the ONLY live
|
|
47
|
+
// consumer today (persistWalkthrough below is exported and tested but has
|
|
48
|
+
// zero callers anywhere in the CLI/command surface; no code in this repo
|
|
49
|
+
// ever runs this text through a real Markdown renderer). sanitizeForTerminal
|
|
50
|
+
// is therefore the correct sanitizer here, not a Markdown-escaping one — a
|
|
51
|
+
// backslash-escaping sibling was tried and reverted (see schema.js's header
|
|
52
|
+
// comment): printed raw or read as plain text, `Jean-Luc Picard` rendering
|
|
53
|
+
// as `Jean\-Luc Picard` and `dependabot[bot]` as `dependabot\[bot\]` is a
|
|
54
|
+
// visible regression on common real-world author names, not a fix.
|
|
55
|
+
import { sanitizeForTerminal, pseudonymizeAuthor, PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
|
|
56
|
+
|
|
57
|
+
// Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
|
|
58
|
+
// safety) but never honoured `--pseudonymize-authors` at all — an operator
|
|
59
|
+
// who set that policy still saw a raw committer name in walkthrough output,
|
|
60
|
+
// the one boundary that gap missed. Reads the SAME env var
|
|
61
|
+
// `report/index.js`'s `_normalizedProvenance` and `mcp/tools.js`'s
|
|
62
|
+
// `providerEnrichment`-aware call read back
|
|
63
|
+
// (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors). Keyed
|
|
64
|
+
// on name only, not email: `deriveComplianceProvenance`'s `earliestOrigin`
|
|
65
|
+
// deliberately never carries `authorEmail` (see its own comment — that
|
|
66
|
+
// object bypasses the `redactFindingProvenance` sweep entirely), so the
|
|
67
|
+
// pseudonym here is stable across repeated runs for the same author name but
|
|
68
|
+
// not necessarily identical to the email-keyed pseudonym shown at other
|
|
69
|
+
// output boundaries for the same person.
|
|
70
|
+
function _maybePseudonymizeName(name) {
|
|
71
|
+
if (!name) return name;
|
|
72
|
+
return process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1' ? pseudonymizeAuthor(name, null) : name;
|
|
73
|
+
}
|
|
44
74
|
|
|
45
75
|
// Re-exported so existing callers/tests keep importing these from here.
|
|
46
76
|
export { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys };
|
|
77
|
+
|
|
78
|
+
// FR-PROV-016 (M2): "earliest proven open condition" among a control's
|
|
79
|
+
// contributing findings. Prefers a finding whose findingProvenance resolved
|
|
80
|
+
// findingOrigin.status:'complete' (the OLDEST such authorDate wins); falls
|
|
81
|
+
// back to 'partial' entries with a resolved findingOrigin.authorDate when no
|
|
82
|
+
// complete one exists. Never fabricates an origin — zero usable entries is
|
|
83
|
+
// reported as null/'unknown', not the repo's first commit or "now".
|
|
84
|
+
export function deriveComplianceProvenance(findings) {
|
|
85
|
+
const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
|
|
86
|
+
const withOrigin = list
|
|
87
|
+
.map((f) => ({ f, fp: f && f.findingProvenance }))
|
|
88
|
+
.filter((x) => x.fp && x.fp.findingOrigin && x.fp.findingOrigin.authorDate);
|
|
89
|
+
const complete = withOrigin.filter((x) => x.fp.status === 'complete');
|
|
90
|
+
const partial = withOrigin.filter((x) => x.fp.status === 'partial');
|
|
91
|
+
// authorDate is git's `%aI` (strict ISO-8601, author's LOCAL UTC offset —
|
|
92
|
+
// see git-evidence.js's commitMeta), never normalized to Z. Two commits
|
|
93
|
+
// authored in different timezones near a day boundary can lexically sort
|
|
94
|
+
// in the wrong chronological order, so compare actual instants via
|
|
95
|
+
// Date.parse, never the raw strings.
|
|
96
|
+
const pickEarliest = (arr) => arr.reduce(
|
|
97
|
+
(min, x) => (!min || Date.parse(x.fp.findingOrigin.authorDate) < Date.parse(min.fp.findingOrigin.authorDate)) ? x : min,
|
|
98
|
+
null,
|
|
99
|
+
);
|
|
100
|
+
const best = complete.length ? pickEarliest(complete) : (partial.length ? pickEarliest(partial) : null);
|
|
101
|
+
return {
|
|
102
|
+
derivedFrom: [...new Set(list.map((f) => f && f.id).filter(Boolean))],
|
|
103
|
+
// Only commit/authorDate/authorName are ever read from findingOrigin
|
|
104
|
+
// here — this object is a SIBLING field to findingProvenance (not
|
|
105
|
+
// nested inside it), so it bypasses the redactFindingProvenance sweep
|
|
106
|
+
// that runs at report/mcp output boundaries. authorEmail must never be
|
|
107
|
+
// added to this shape without first routing it through
|
|
108
|
+
// redactFindingProvenance.
|
|
109
|
+
earliestOrigin: best ? {
|
|
110
|
+
commit: best.fp.findingOrigin.commit || null,
|
|
111
|
+
authorDate: best.fp.findingOrigin.authorDate,
|
|
112
|
+
authorName: best.fp.findingOrigin.authorName || null,
|
|
113
|
+
} : null,
|
|
114
|
+
confidence: complete.length ? 'high' : (partial.length ? 'low' : 'unknown'),
|
|
115
|
+
limitations: best ? [] : ['no contributing finding resolved a verified origin'],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
47
119
|
const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
|
|
48
120
|
function _readJson(fp) {
|
|
49
121
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
@@ -275,24 +347,30 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
275
347
|
const results = [];
|
|
276
348
|
for (const c of fw.controls || []) {
|
|
277
349
|
const obs = [];
|
|
350
|
+
// FR-PROV-016: findings that contributed an OPEN condition to this
|
|
351
|
+
// control's `family:` mapping(s) — the exact objects `open` below
|
|
352
|
+
// filters to, not just their ids, so deriveComplianceProvenance can read
|
|
353
|
+
// .findingProvenance off them. Naturally empty for a control that ends
|
|
354
|
+
// up 'present' (present requires zero open findings across every
|
|
355
|
+
// mapping) or 'manual' (no family: mapping ever populates it) — so a
|
|
356
|
+
// consumer can treat a non-empty controlRefs as "this control has an
|
|
357
|
+
// attributable gap" without re-deriving the bucket classification.
|
|
358
|
+
const contributingFindings = [];
|
|
278
359
|
let status = 'manual';
|
|
279
360
|
const maps = Array.isArray(c.mapsTo) ? c.mapsTo : [];
|
|
280
361
|
|
|
281
362
|
if (maps.length === 0) {
|
|
282
363
|
obs.push('No automated mapping — requires manual evidence collection.');
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
observations: obs,
|
|
294
|
-
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
295
|
-
});
|
|
364
|
+
let evidence = null;
|
|
365
|
+
try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
|
|
366
|
+
results.push({
|
|
367
|
+
control: c,
|
|
368
|
+
status,
|
|
369
|
+
observations: obs,
|
|
370
|
+
controlRefs: [],
|
|
371
|
+
derivedProvenance: deriveComplianceProvenance([]),
|
|
372
|
+
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
373
|
+
});
|
|
296
374
|
continue;
|
|
297
375
|
}
|
|
298
376
|
|
|
@@ -356,6 +434,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
356
434
|
const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
|
|
357
435
|
if (open.length) {
|
|
358
436
|
allCleared = false;
|
|
437
|
+
contributingFindings.push(...open);
|
|
359
438
|
obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
|
|
360
439
|
} else {
|
|
361
440
|
obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
|
|
@@ -461,10 +540,13 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
461
540
|
// artifacts are absent (they degrade to `unmeasured`, never to a default).
|
|
462
541
|
let evidence = null;
|
|
463
542
|
try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
|
|
543
|
+
const dedupedRefs = [...new Set(contributingFindings.map((f) => f.id).filter(Boolean))];
|
|
464
544
|
results.push({
|
|
465
545
|
control: c,
|
|
466
546
|
status,
|
|
467
547
|
observations: obs,
|
|
548
|
+
controlRefs: dedupedRefs,
|
|
549
|
+
derivedProvenance: deriveComplianceProvenance(contributingFindings),
|
|
468
550
|
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
469
551
|
});
|
|
470
552
|
}
|
|
@@ -519,6 +601,27 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
|
|
|
519
601
|
if (ev.status === 'absent' || ev.status === 'partial') {
|
|
520
602
|
lines.push(`**Remediation:** address the bullet(s) above, then re-run \`/compliance --walkthrough ${fw.id}\` to update this report.`);
|
|
521
603
|
lines.push('');
|
|
604
|
+
if (Array.isArray(ev.controlRefs) && ev.controlRefs.length) {
|
|
605
|
+
lines.push(`**Contributing findings:** ${ev.controlRefs.join(', ')}`);
|
|
606
|
+
const dp = ev.derivedProvenance;
|
|
607
|
+
if (dp && dp.earliestOrigin) {
|
|
608
|
+
const short = String(dp.earliestOrigin.commit || '').slice(0, 7) || 'unknown';
|
|
609
|
+
const day = String(dp.earliestOrigin.authorDate || '').slice(0, 10);
|
|
610
|
+
// Only commit/authorDate/authorName are ever read here — same
|
|
611
|
+
// caveat as deriveComplianceProvenance's earliestOrigin: this
|
|
612
|
+
// object bypasses redactFindingProvenance, so authorEmail must
|
|
613
|
+
// never be surfaced from it without routing through that function
|
|
614
|
+
// first.
|
|
615
|
+
lines.push(`**Earliest proven origin:** ${short} — ${day} — ${sanitizeForTerminal(_maybePseudonymizeName(dp.earliestOrigin.authorName)) || 'unknown'} (confidence: ${dp.confidence})`);
|
|
616
|
+
// PRD Section 8 REQUIRED DISCLAIMER, alongside the claim it
|
|
617
|
+
// qualifies (not just once at the top of the document) — a reader
|
|
618
|
+
// who skips straight to a control's evidence must still see it.
|
|
619
|
+
lines.push(`_${PROVENANCE_COMPLIANCE_DISCLAIMER}_`);
|
|
620
|
+
} else if (dp) {
|
|
621
|
+
lines.push(`**Earliest proven origin:** unresolved (confidence: ${dp.confidence})`);
|
|
622
|
+
}
|
|
623
|
+
lines.push('');
|
|
624
|
+
}
|
|
522
625
|
}
|
|
523
626
|
}
|
|
524
627
|
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
import * as fs from 'node:fs';
|
|
65
65
|
import * as path from 'node:path';
|
|
66
66
|
import * as crypto from 'node:crypto';
|
|
67
|
-
import {
|
|
67
|
+
import { execFileSync } from 'node:child_process';
|
|
68
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
68
69
|
import * as yaml from '../util/yaml.js';
|
|
69
70
|
import { statePath, safeWriteState, STATE_DIR_NAME } from './state-dir.js';
|
|
70
71
|
import { SCANNER_VERSION } from './version.js';
|
|
@@ -367,10 +368,19 @@ export function verifyPolicy(policy, ctx) {
|
|
|
367
368
|
return { framework: policy.framework, version: policy.version, controls: results, summary, evidenceDigest };
|
|
368
369
|
}
|
|
369
370
|
|
|
371
|
+
// `scanRoot` is the scanned project's repository, not this project's own
|
|
372
|
+
// trusted checkout — hardened per FR-PROV-024 / the second Finding
|
|
373
|
+
// Provenance PRD audit sweep (found missing here by a follow-up review that
|
|
374
|
+
// grepped for `child_process` usage beyond just `execFileSync('git'` call
|
|
375
|
+
// sites). `rev-parse HEAD` was VERIFIED not to itself trigger
|
|
376
|
+
// `core.fsmonitor`/a hook, so this is not a second live RCE — but the
|
|
377
|
+
// shell-string `execSync` form was gratuitous risk with no upside (no
|
|
378
|
+
// caller-controlled input to interpolate), and left this call outside the
|
|
379
|
+
// config/env hardening every other git call in this codebase now has.
|
|
370
380
|
function _currentCommit(scanRoot) {
|
|
371
381
|
if (!scanRoot) return null;
|
|
372
382
|
try {
|
|
373
|
-
return
|
|
383
|
+
return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
|
|
374
384
|
} catch { return null; } // not a git repo, or git unavailable — not an error condition
|
|
375
385
|
}
|
|
376
386
|
|
|
@@ -24,6 +24,7 @@ import * as cp from 'node:child_process';
|
|
|
24
24
|
import * as fs from 'node:fs';
|
|
25
25
|
import * as crypto from 'node:crypto';
|
|
26
26
|
import * as path from 'node:path';
|
|
27
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
27
28
|
|
|
28
29
|
// Lazy — process.env.HOME may be mutated mid-process (e.g. tests isolating).
|
|
29
30
|
function _storeDir() {
|
|
@@ -43,8 +44,12 @@ function _ensureDir() { try { fs.mkdirSync(_storeDir(), { recursive: true }); }
|
|
|
43
44
|
export function repoFingerprint(scanRoot) {
|
|
44
45
|
let source = String(scanRoot || '');
|
|
45
46
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
// `scanRoot` is the scanned project's repository, not this project's
|
|
48
|
+
// own trusted checkout — hardened per FR-PROV-024 / the second Finding
|
|
49
|
+
// Provenance PRD audit (same exposure class as
|
|
50
|
+
// provenance/git-evidence.js's `_run`).
|
|
51
|
+
const remote = cp.execFileSync('git', hardenGitArgs(['remote', 'get-url', 'origin']),
|
|
52
|
+
{ cwd: scanRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
|
|
48
53
|
if (remote) source = remote;
|
|
49
54
|
} catch {}
|
|
50
55
|
return crypto.createHash('sha256').update(source).digest('hex').slice(0, 12);
|
|
@@ -13,6 +13,7 @@ import * as fsp from 'node:fs/promises';
|
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import * as crypto from 'node:crypto';
|
|
15
15
|
import { isSafeStateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
16
|
+
import { AGE_BASIS } from './provenance/schema.js';
|
|
16
17
|
|
|
17
18
|
function historyDir(scanRoot) {
|
|
18
19
|
return statePath(scanRoot, 'fix-history');
|
|
@@ -238,6 +239,26 @@ function _countPriorAttempts(log, stableId, findingId) {
|
|
|
238
239
|
return n;
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
// FR-PROV §7.4 / M2 §2.2: how old was this finding, by which basis, at the
|
|
243
|
+
// moment it was fixed. Computed ONCE, at fix time, and never re-derived
|
|
244
|
+
// later — a finding's origin doesn't change, but re-computing "age at fix"
|
|
245
|
+
// from a LATER read of findingProvenance would silently answer "how old is
|
|
246
|
+
// it now", not "how old was it when fixed". Mirrors mttr.js's ageBasis
|
|
247
|
+
// tiering (Task 6) so the two surfaces agree on vocabulary.
|
|
248
|
+
function _snapshotProvenanceAtFix(findingProvenance, appliedAt) {
|
|
249
|
+
if (!findingProvenance) return null;
|
|
250
|
+
const status = findingProvenance.status;
|
|
251
|
+
const origin = findingProvenance.findingOrigin;
|
|
252
|
+
const observedAt = findingProvenance.firstObserved?.observedAt || null;
|
|
253
|
+
let ageBasis, basisDate;
|
|
254
|
+
if (status === 'complete' && origin?.authorDate) { ageBasis = AGE_BASIS.FINDING_ORIGIN; basisDate = origin.authorDate; }
|
|
255
|
+
else if (status === 'partial' && origin?.authorDate) { ageBasis = AGE_BASIS.EARLIEST_OBSERVABLE; basisDate = origin.authorDate; }
|
|
256
|
+
else if (status === 'uncommitted') { ageBasis = AGE_BASIS.UNCOMMITTED; basisDate = observedAt; }
|
|
257
|
+
else { ageBasis = AGE_BASIS.FIRST_OBSERVED; basisDate = observedAt; }
|
|
258
|
+
const ageDays = basisDate ? Math.max(0, Math.floor((Date.parse(appliedAt) - Date.parse(basisDate)) / 86400000)) : null;
|
|
259
|
+
return { commit: origin?.commit || null, authorDate: basisDate, ageBasis, ageDays };
|
|
260
|
+
}
|
|
261
|
+
|
|
241
262
|
// @param {boolean} [fileExisted] - did `file` exist on disk before this call?
|
|
242
263
|
// Determines what "restore" means on rollback: write `originalContent`
|
|
243
264
|
// back for a file that existed (default, for backward compatibility with
|
|
@@ -247,7 +268,7 @@ function _countPriorAttempts(log, stableId, findingId) {
|
|
|
247
268
|
// real-world meaning in this codebase's callers — writing '' back would
|
|
248
269
|
// leave a phantom empty file where none existed before, not a true
|
|
249
270
|
// rollback).
|
|
250
|
-
export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true }) {
|
|
271
|
+
export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true, findingProvenance = null }) {
|
|
251
272
|
return _withLogLock(scanRoot, async () => {
|
|
252
273
|
ensure(scanRoot);
|
|
253
274
|
const absFile = path.resolve(scanRoot, file);
|
|
@@ -269,6 +290,7 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
|
|
|
269
290
|
// is below — a corrupted backup is worse than no backup, because it
|
|
270
291
|
// silently defeats rollback.
|
|
271
292
|
await _writeAtomicAndSync(bakPath, originalContent);
|
|
293
|
+
const appliedAt = new Date().toISOString();
|
|
272
294
|
const entry = {
|
|
273
295
|
id,
|
|
274
296
|
findingId,
|
|
@@ -280,10 +302,11 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
|
|
|
280
302
|
backupPath: path.relative(scanRoot, bakPath),
|
|
281
303
|
originalSha: sha(originalContent),
|
|
282
304
|
newSha: sha(newContent),
|
|
283
|
-
appliedAt
|
|
305
|
+
appliedAt,
|
|
284
306
|
status: 'pending',
|
|
285
307
|
reverted: false,
|
|
286
308
|
attemptOrdinal: priorAttempts + 1,
|
|
309
|
+
provenanceAtFix: _snapshotProvenanceAtFix(findingProvenance, appliedAt),
|
|
287
310
|
};
|
|
288
311
|
// Phase 2: log entry marked pending + fsync.
|
|
289
312
|
const log = priorLog;
|
|
@@ -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
|
}
|
package/src/posture/fleet.js
CHANGED
|
Binary file
|
|
@@ -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
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
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';
|
|
14
15
|
import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
|
|
15
16
|
|
|
16
17
|
// Patterns that fire on the deletion side (auth/check removed).
|
|
@@ -196,11 +197,29 @@ function summarize(findings) {
|
|
|
196
197
|
}
|
|
197
198
|
|
|
198
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.
|
|
199
218
|
export function classifyGitDiff(rootDir, ref) {
|
|
200
219
|
let out;
|
|
201
220
|
try {
|
|
202
|
-
out = cp.execFileSync('git', ['diff', '--unified=0', `${ref}...HEAD`], {
|
|
203
|
-
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(),
|
|
204
223
|
});
|
|
205
224
|
} catch (e) {
|
|
206
225
|
return { materialRisk: 'unknown', error: 'git diff failed: ' + (e.message || e), findings: [], perKindCounts: {}, byFile: {} };
|
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
|
|