@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- 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/obligation-evidence-pack.js +202 -0
- 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/remediation-ledger.js +337 -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/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -41,9 +41,82 @@ 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
|
+
import { evaluateGraphFlowPredicate, buildObligationMappingFromGraphPredicate } from '../lineage/obligation-predicates.js';
|
|
57
|
+
|
|
58
|
+
// Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
|
|
59
|
+
// safety) but never honoured `--pseudonymize-authors` at all — an operator
|
|
60
|
+
// who set that policy still saw a raw committer name in walkthrough output,
|
|
61
|
+
// the one boundary that gap missed. Reads the SAME env var
|
|
62
|
+
// `report/index.js`'s `_normalizedProvenance` and `mcp/tools.js`'s
|
|
63
|
+
// `providerEnrichment`-aware call read back
|
|
64
|
+
// (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors). Keyed
|
|
65
|
+
// on name only, not email: `deriveComplianceProvenance`'s `earliestOrigin`
|
|
66
|
+
// deliberately never carries `authorEmail` (see its own comment — that
|
|
67
|
+
// object bypasses the `redactFindingProvenance` sweep entirely), so the
|
|
68
|
+
// pseudonym here is stable across repeated runs for the same author name but
|
|
69
|
+
// not necessarily identical to the email-keyed pseudonym shown at other
|
|
70
|
+
// output boundaries for the same person.
|
|
71
|
+
function _maybePseudonymizeName(name) {
|
|
72
|
+
if (!name) return name;
|
|
73
|
+
return process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1' ? pseudonymizeAuthor(name, null) : name;
|
|
74
|
+
}
|
|
44
75
|
|
|
45
76
|
// Re-exported so existing callers/tests keep importing these from here.
|
|
46
77
|
export { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys };
|
|
78
|
+
|
|
79
|
+
// FR-PROV-016 (M2): "earliest proven open condition" among a control's
|
|
80
|
+
// contributing findings. Prefers a finding whose findingProvenance resolved
|
|
81
|
+
// findingOrigin.status:'complete' (the OLDEST such authorDate wins); falls
|
|
82
|
+
// back to 'partial' entries with a resolved findingOrigin.authorDate when no
|
|
83
|
+
// complete one exists. Never fabricates an origin — zero usable entries is
|
|
84
|
+
// reported as null/'unknown', not the repo's first commit or "now".
|
|
85
|
+
export function deriveComplianceProvenance(findings) {
|
|
86
|
+
const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
|
|
87
|
+
const withOrigin = list
|
|
88
|
+
.map((f) => ({ f, fp: f && f.findingProvenance }))
|
|
89
|
+
.filter((x) => x.fp && x.fp.findingOrigin && x.fp.findingOrigin.authorDate);
|
|
90
|
+
const complete = withOrigin.filter((x) => x.fp.status === 'complete');
|
|
91
|
+
const partial = withOrigin.filter((x) => x.fp.status === 'partial');
|
|
92
|
+
// authorDate is git's `%aI` (strict ISO-8601, author's LOCAL UTC offset —
|
|
93
|
+
// see git-evidence.js's commitMeta), never normalized to Z. Two commits
|
|
94
|
+
// authored in different timezones near a day boundary can lexically sort
|
|
95
|
+
// in the wrong chronological order, so compare actual instants via
|
|
96
|
+
// Date.parse, never the raw strings.
|
|
97
|
+
const pickEarliest = (arr) => arr.reduce(
|
|
98
|
+
(min, x) => (!min || Date.parse(x.fp.findingOrigin.authorDate) < Date.parse(min.fp.findingOrigin.authorDate)) ? x : min,
|
|
99
|
+
null,
|
|
100
|
+
);
|
|
101
|
+
const best = complete.length ? pickEarliest(complete) : (partial.length ? pickEarliest(partial) : null);
|
|
102
|
+
return {
|
|
103
|
+
derivedFrom: [...new Set(list.map((f) => f && f.id).filter(Boolean))],
|
|
104
|
+
// Only commit/authorDate/authorName are ever read from findingOrigin
|
|
105
|
+
// here — this object is a SIBLING field to findingProvenance (not
|
|
106
|
+
// nested inside it), so it bypasses the redactFindingProvenance sweep
|
|
107
|
+
// that runs at report/mcp output boundaries. authorEmail must never be
|
|
108
|
+
// added to this shape without first routing it through
|
|
109
|
+
// redactFindingProvenance.
|
|
110
|
+
earliestOrigin: best ? {
|
|
111
|
+
commit: best.fp.findingOrigin.commit || null,
|
|
112
|
+
authorDate: best.fp.findingOrigin.authorDate,
|
|
113
|
+
authorName: best.fp.findingOrigin.authorName || null,
|
|
114
|
+
} : null,
|
|
115
|
+
confidence: complete.length ? 'high' : (partial.length ? 'low' : 'unknown'),
|
|
116
|
+
limitations: best ? [] : ['no contributing finding resolved a verified origin'],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
47
120
|
const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
|
|
48
121
|
function _readJson(fp) {
|
|
49
122
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
@@ -275,24 +348,31 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
275
348
|
const results = [];
|
|
276
349
|
for (const c of fw.controls || []) {
|
|
277
350
|
const obs = [];
|
|
351
|
+
// FR-PROV-016: findings that contributed an OPEN condition to this
|
|
352
|
+
// control's `family:` mapping(s) — the exact objects `open` below
|
|
353
|
+
// filters to, not just their ids, so deriveComplianceProvenance can read
|
|
354
|
+
// .findingProvenance off them. Naturally empty for a control that ends
|
|
355
|
+
// up 'present' (present requires zero open findings across every
|
|
356
|
+
// mapping) or 'manual' (no family: mapping ever populates it) — so a
|
|
357
|
+
// consumer can treat a non-empty controlRefs as "this control has an
|
|
358
|
+
// attributable gap" without re-deriving the bucket classification.
|
|
359
|
+
const contributingFindings = [];
|
|
278
360
|
let status = 'manual';
|
|
279
361
|
const maps = Array.isArray(c.mapsTo) ? c.mapsTo : [];
|
|
280
362
|
|
|
281
363
|
if (maps.length === 0) {
|
|
282
364
|
obs.push('No automated mapping — requires manual evidence collection.');
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
295
|
-
});
|
|
365
|
+
let evidence = null;
|
|
366
|
+
try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
|
|
367
|
+
results.push({
|
|
368
|
+
control: c,
|
|
369
|
+
status,
|
|
370
|
+
observations: obs,
|
|
371
|
+
controlRefs: [],
|
|
372
|
+
derivedProvenance: deriveComplianceProvenance([]),
|
|
373
|
+
obligationMappings: [],
|
|
374
|
+
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
375
|
+
});
|
|
296
376
|
continue;
|
|
297
377
|
}
|
|
298
378
|
|
|
@@ -313,6 +393,15 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
313
393
|
// control at 'partial' — never 'present' — regardless of what the
|
|
314
394
|
// family:/module: mappings in the same control found.
|
|
315
395
|
let hasUnverifiableMapping = false;
|
|
396
|
+
// Sub-project 6b: real ObligationMapping records minted by graph:
|
|
397
|
+
// predicates on this control, collected separately from the
|
|
398
|
+
// present/partial/absent/manual status machinery above — a graph:
|
|
399
|
+
// mapping never touches anySignal/allCleared/anyCleared/
|
|
400
|
+
// hasUnverifiableMapping (purely additive, per the plan's own Global
|
|
401
|
+
// Constraints). Always an array, never undefined, so a caller can
|
|
402
|
+
// safely read `.length` regardless of whether this control has any
|
|
403
|
+
// graph: mappings at all.
|
|
404
|
+
const obligationMappings = [];
|
|
316
405
|
for (const m of maps) {
|
|
317
406
|
if (m.startsWith('family:')) {
|
|
318
407
|
// `family:X` and the subfamily-qualified `family:X:Y` (used by
|
|
@@ -356,6 +445,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
356
445
|
const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
|
|
357
446
|
if (open.length) {
|
|
358
447
|
allCleared = false;
|
|
448
|
+
contributingFindings.push(...open);
|
|
359
449
|
obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
|
|
360
450
|
} else {
|
|
361
451
|
obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
|
|
@@ -413,6 +503,70 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
413
503
|
obs.push(`(rule mapping) ${m} — verify manually that the bodyguard rule is enabled.`);
|
|
414
504
|
anySignal = true;
|
|
415
505
|
hasUnverifiableMapping = true;
|
|
506
|
+
} else if (m.startsWith('graph:')) {
|
|
507
|
+
// A graph: mapping never contributes to anySignal/allCleared/
|
|
508
|
+
// anyCleared/hasUnverifiableMapping — the existing
|
|
509
|
+
// present/partial/absent/manual status stays driven entirely by
|
|
510
|
+
// the family:/module:/rule: mappings a control already has (this
|
|
511
|
+
// task's own Global Constraint: purely additive). It instead
|
|
512
|
+
// mints a real ObligationMapping record, collected separately.
|
|
513
|
+
//
|
|
514
|
+
// First real predicate (scoping doc ruling 6): PHI/PII flowing to
|
|
515
|
+
// an external sink must cross a protected transit edge. The
|
|
516
|
+
// predicate STRING itself is currently a fixed label (not yet a
|
|
517
|
+
// parsed mini-language) — the first real graph: mapping this
|
|
518
|
+
// sub-project ships is HIPAA §164.312(e)'s
|
|
519
|
+
// "graph:transit-protection:PHI:external:transit:protected",
|
|
520
|
+
// hardcoded to this one spec until a second real case proves the
|
|
521
|
+
// parsing is worth generalizing (YAGNI — do not invent a parser
|
|
522
|
+
// for one caller).
|
|
523
|
+
const spec = { type: 'graph-flow', dataClass: 'PHI', sinkKind: 'external', dimension: 'transit', requiredVerdict: 'protected' };
|
|
524
|
+
const graph = scan.lineageGraph ?? null;
|
|
525
|
+
// Belt-and-suspenders, mirroring the sibling _strengthOfControl
|
|
526
|
+
// call above: obligation-predicates.js is now defensively
|
|
527
|
+
// hardened to never throw (see its own header), but this wrap
|
|
528
|
+
// keeps a future regression there from taking down the whole
|
|
529
|
+
// evaluateFramework call for every control in the framework
|
|
530
|
+
// (found by the final whole-branch review).
|
|
531
|
+
let mapping = null;
|
|
532
|
+
try {
|
|
533
|
+
const evaluation = graph ? evaluateGraphFlowPredicate(spec, graph) : null;
|
|
534
|
+
mapping = buildObligationMappingFromGraphPredicate({
|
|
535
|
+
framework: fw.id,
|
|
536
|
+
frameworkVersion: fw.controlsDigest,
|
|
537
|
+
requirementId: c.id,
|
|
538
|
+
requirementSource: fw.url ?? null,
|
|
539
|
+
predicateLabel: m,
|
|
540
|
+
graph,
|
|
541
|
+
evaluation,
|
|
542
|
+
});
|
|
543
|
+
} catch { /* graph obligation mapping is additive; never block evaluation */ }
|
|
544
|
+
if (mapping) {
|
|
545
|
+
obligationMappings.push(mapping);
|
|
546
|
+
// Found by the final whole-branch review of sub-project 6b: a
|
|
547
|
+
// graph: mapping's state is intentionally independent of the
|
|
548
|
+
// pre-existing present/partial/absent/manual status glyph
|
|
549
|
+
// (Global Constraint — see the block comment above), so a
|
|
550
|
+
// control can render a green ✅ header with a 'gap_detected'
|
|
551
|
+
// line buried underneath it in the observations. A ⚠️ prefix on
|
|
552
|
+
// a genuine gap keeps that visible to a reader who only scans
|
|
553
|
+
// headers, without touching the status computation itself.
|
|
554
|
+
// RE-CONFIRMED (sub-project 6c's own final review, F4): at the
|
|
555
|
+
// time this ⚠️ prefix was written, `graph:` mappings could only
|
|
556
|
+
// ever read 'unknown' end-to-end through the real CLI (a
|
|
557
|
+
// separate, then-undiscovered bug — see obligation-predicates.js
|
|
558
|
+
// and cmdCompliance/cmdAttest's own fix history), so this exact
|
|
559
|
+
// ✅-header-hiding-a-real-gap scenario was structurally
|
|
560
|
+
// unreachable. Sub-project 6c's CLI fixes make it genuinely
|
|
561
|
+
// reachable for the first time; the ruling above was written
|
|
562
|
+
// anticipating exactly this and needs no change — confirmed
|
|
563
|
+
// live via a real assessed HIPAA §164.312(e) gap
|
|
564
|
+
// (test/cli/attest-obligations.test.js's own gap_detected case).
|
|
565
|
+
const flag = mapping.state === 'gap_detected' ? '⚠️ ' : '';
|
|
566
|
+
obs.push(`${flag}(graph mapping) ${m} -> ${mapping.state}.`);
|
|
567
|
+
} else {
|
|
568
|
+
obs.push(`(graph mapping) ${m} -> evaluation failed, skipped.`);
|
|
569
|
+
}
|
|
416
570
|
}
|
|
417
571
|
}
|
|
418
572
|
|
|
@@ -461,10 +615,14 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
461
615
|
// artifacts are absent (they degrade to `unmeasured`, never to a default).
|
|
462
616
|
let evidence = null;
|
|
463
617
|
try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
|
|
618
|
+
const dedupedRefs = [...new Set(contributingFindings.map((f) => f.id).filter(Boolean))];
|
|
464
619
|
results.push({
|
|
465
620
|
control: c,
|
|
466
621
|
status,
|
|
467
622
|
observations: obs,
|
|
623
|
+
controlRefs: dedupedRefs,
|
|
624
|
+
derivedProvenance: deriveComplianceProvenance(contributingFindings),
|
|
625
|
+
obligationMappings,
|
|
468
626
|
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
469
627
|
});
|
|
470
628
|
}
|
|
@@ -519,6 +677,27 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
|
|
|
519
677
|
if (ev.status === 'absent' || ev.status === 'partial') {
|
|
520
678
|
lines.push(`**Remediation:** address the bullet(s) above, then re-run \`/compliance --walkthrough ${fw.id}\` to update this report.`);
|
|
521
679
|
lines.push('');
|
|
680
|
+
if (Array.isArray(ev.controlRefs) && ev.controlRefs.length) {
|
|
681
|
+
lines.push(`**Contributing findings:** ${ev.controlRefs.join(', ')}`);
|
|
682
|
+
const dp = ev.derivedProvenance;
|
|
683
|
+
if (dp && dp.earliestOrigin) {
|
|
684
|
+
const short = String(dp.earliestOrigin.commit || '').slice(0, 7) || 'unknown';
|
|
685
|
+
const day = String(dp.earliestOrigin.authorDate || '').slice(0, 10);
|
|
686
|
+
// Only commit/authorDate/authorName are ever read here — same
|
|
687
|
+
// caveat as deriveComplianceProvenance's earliestOrigin: this
|
|
688
|
+
// object bypasses redactFindingProvenance, so authorEmail must
|
|
689
|
+
// never be surfaced from it without routing through that function
|
|
690
|
+
// first.
|
|
691
|
+
lines.push(`**Earliest proven origin:** ${short} — ${day} — ${sanitizeForTerminal(_maybePseudonymizeName(dp.earliestOrigin.authorName)) || 'unknown'} (confidence: ${dp.confidence})`);
|
|
692
|
+
// PRD Section 8 REQUIRED DISCLAIMER, alongside the claim it
|
|
693
|
+
// qualifies (not just once at the top of the document) — a reader
|
|
694
|
+
// who skips straight to a control's evidence must still see it.
|
|
695
|
+
lines.push(`_${PROVENANCE_COMPLIANCE_DISCLAIMER}_`);
|
|
696
|
+
} else if (dp) {
|
|
697
|
+
lines.push(`**Earliest proven origin:** unresolved (confidence: ${dp.confidence})`);
|
|
698
|
+
}
|
|
699
|
+
lines.push('');
|
|
700
|
+
}
|
|
522
701
|
}
|
|
523
702
|
}
|
|
524
703
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"license": "US Federal regulation (public)",
|
|
6
6
|
"url": "https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164",
|
|
7
7
|
"scope": "SELECTIVE SUBSET. 8 of the Security Rule technical safeguards. Administrative and physical safeguards are outside what a code scanner can observe and are NOT represented here.",
|
|
8
|
-
"controlsDigest": "
|
|
8
|
+
"controlsDigest": "7e94038dd9a64d3d",
|
|
9
9
|
"controlCount": 8,
|
|
10
10
|
"controls": [
|
|
11
11
|
{
|
|
@@ -102,7 +102,8 @@
|
|
|
102
102
|
],
|
|
103
103
|
"mapsTo": [
|
|
104
104
|
"family:crypto-tls-no-verify",
|
|
105
|
-
"family:crypto-tls-version"
|
|
105
|
+
"family:crypto-tls-version",
|
|
106
|
+
"graph:transit-protection:PHI:external:transit:protected"
|
|
106
107
|
]
|
|
107
108
|
}
|
|
108
109
|
]
|
|
@@ -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
|
|