@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
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// `unverified-no-llm-endpoint` and the transcript records only the seed input.
|
|
26
26
|
|
|
27
27
|
import * as crypto from 'node:crypto';
|
|
28
|
+
import { evaluateEgress } from '../egress/policy.js';
|
|
28
29
|
|
|
29
30
|
const MAX_CALLS_DEFAULT = 50;
|
|
30
31
|
const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
|
|
@@ -170,12 +171,23 @@ export async function runAgent(finding, opts = {}) {
|
|
|
170
171
|
const transcript = startTranscript(finding, opts.target);
|
|
171
172
|
const budget = { maxCalls: opts.maxCalls, maxWallMs: opts.maxWallMs };
|
|
172
173
|
|
|
173
|
-
|
|
174
|
+
// FR-601: evaluated before defaultLlmInvoke is even selected, so a denial
|
|
175
|
+
// means its transcript-to-prompt construction (inside defaultLlmInvoke,
|
|
176
|
+
// called later in the loop below) never runs at all.
|
|
177
|
+
let egressDecision = null;
|
|
178
|
+
let llmInvoke = opts.llmInvoke || null;
|
|
179
|
+
if (!llmInvoke && process.env.AGENTIC_SECURITY_LLM_ENDPOINT) {
|
|
180
|
+
egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: process.env.AGENTIC_SECURITY_LLM_ENDPOINT });
|
|
181
|
+
if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
|
|
182
|
+
}
|
|
174
183
|
const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
|
|
175
184
|
|
|
176
185
|
if (typeof llmInvoke !== 'function' || typeof executeTool !== 'function') {
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
const reason = (egressDecision && !egressDecision.allowed)
|
|
187
|
+
? `egress policy denied this call: ${egressDecision.reason}`
|
|
188
|
+
: 'no llmInvoke/executeTool supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
|
|
189
|
+
appendEntry(transcript, { phase: 'init', reason, egressDecision: egressDecision || undefined });
|
|
190
|
+
return { transcript, outcome: 'unverified-no-llm-endpoint', egressDecision };
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
let outcome = null;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// State artifact registry (assurance-hardening PRD, Milestone 0/1, FR-701/FR-703).
|
|
2
|
+
//
|
|
3
|
+
// `bin/agentic-security.js`'s `cmdReset` used to delete from two hardcoded
|
|
4
|
+
// Sets (WIPE / WIPE_DIRS) — an enumeration that had drifted badly behind the
|
|
5
|
+
// state artifacts this codebase actually writes under `.agentic-security/`.
|
|
6
|
+
// This module is the registry FR-701 asks for: every known artifact, with an
|
|
7
|
+
// explicit classification, built by auditing every `statePath(...)` call
|
|
8
|
+
// site in `src/` and `bin/` (not guessed from filenames — several looked
|
|
9
|
+
// like generated output by name but turned out, on reading their actual
|
|
10
|
+
// read/write call sites, to be operator- or agent-authored INPUT).
|
|
11
|
+
//
|
|
12
|
+
// Two classifications:
|
|
13
|
+
// - 'generated': written by the scanner itself, safe to delete — the next
|
|
14
|
+
// scan/command regenerates it. `cmdReset` removes these by default.
|
|
15
|
+
// - 'operator-config': hand-authored (or agent-authored, for
|
|
16
|
+
// logic-claims.json) input the scanner only reads. Deleting it on reset
|
|
17
|
+
// would be data loss, not cleanup — `cmdReset` always preserves these.
|
|
18
|
+
//
|
|
19
|
+
// Corrections this audit made to the assurance-hardening PRD's own evidence
|
|
20
|
+
// table (A-10), which had assumed these were straightforwardly "missing from
|
|
21
|
+
// the wipe list, therefore should be added": `logic-claims.json` is read-only
|
|
22
|
+
// from engine.js (an external reviewing agent authors it); `current-intent.md`
|
|
23
|
+
// has no writer anywhere in src/ or bin/ (developer-authored);
|
|
24
|
+
// `exploit-history.jsonl`'s own header comment calls it an "operator-curated
|
|
25
|
+
// record"; `cve-alerts.json`'s own header comment calls it "Configuration...
|
|
26
|
+
// read from"; `network-policy.json` is documented as an Inputs-section
|
|
27
|
+
// artifact in network-policy-import.js. All five are classified
|
|
28
|
+
// 'operator-config' here — the registry closes the reset-completeness gap
|
|
29
|
+
// without introducing a NEW data-loss bug in the process.
|
|
30
|
+
//
|
|
31
|
+
// This module implements FR-701 (this registry) and FR-703 (registry-driven
|
|
32
|
+
// reset). FR-702 (TTL by artifact class) is implemented here too, as an
|
|
33
|
+
// additive `retentionClass` field: 'cache' | 'scan' | 'evidence' | 'ticket'
|
|
34
|
+
// | 'backup' | undefined, matching the acceptance criterion's own named
|
|
35
|
+
// list verbatim ("expired caches, scans, evidence, tickets, and backups").
|
|
36
|
+
// DELIBERATELY CONSERVATIVE: only entries that unambiguously fit one of
|
|
37
|
+
// those five categories carry a class. Ongoing accumulated state whose
|
|
38
|
+
// deletion would be a real loss rather than cleanup — calibration data
|
|
39
|
+
// (validator-metrics.json, triage-feedback.json), the continual-learning
|
|
40
|
+
// memory file (AGENTS.md), daemon dedup state (cve-alerts-state.json), the
|
|
41
|
+
// gamification streak counter, an operator-set regression baseline
|
|
42
|
+
// (baseline.json, set via --set-baseline — functionally closer to
|
|
43
|
+
// operator intent than scanner output even though it is written by the
|
|
44
|
+
// scanner) — are left with NO retention class rather than forced into the
|
|
45
|
+
// nearest-sounding bucket. A class-less 'generated' artifact is completely
|
|
46
|
+
// unaffected by FR-702's enforcement; it is still deleted unconditionally
|
|
47
|
+
// by an ordinary `reset` (FR-703's own behavior, unchanged).
|
|
48
|
+
//
|
|
49
|
+
// See retention-policy.js for the default/max TTL values per class, the
|
|
50
|
+
// optional operator-override file, and the actual expiry check — kept in
|
|
51
|
+
// a separate module rather than grown into this one, the same "registry
|
|
52
|
+
// vs. policy" separation this session's egress/policy.js and
|
|
53
|
+
// compliance-policy.js already establish.
|
|
54
|
+
//
|
|
55
|
+
// FR-706 (export/deletion manifests) is implemented in
|
|
56
|
+
// posture/state-lifecycle-report.js, consuming this registry directly (an
|
|
57
|
+
// export walks the FULL registry, not just the 'generated' half `reset`
|
|
58
|
+
// acts on — see that module's header).
|
|
59
|
+
//
|
|
60
|
+
// FR-705 (encryption of confidential state classes): an additive
|
|
61
|
+
// `confidential: true` field, enforced by posture/encryption-provider.js.
|
|
62
|
+
// DELIBERATELY CONSERVATIVE, Phase 1 of a staged rollout (see that
|
|
63
|
+
// module's own header for the full rationale): only `compliance-
|
|
64
|
+
// evidence.json`/`.md` are marked in this pass — both have exactly one,
|
|
65
|
+
// well-isolated writer function (compliance-policy.js's emitEvidenceJsonLd/
|
|
66
|
+
// emitEvidenceMarkdown) and zero other production readers besides that
|
|
67
|
+
// same module and the verify-attestation CLI path, both already updated to
|
|
68
|
+
// transparently decrypt. Candidates explicitly DEFERRED, with reasons:
|
|
69
|
+
// `mcp-audit.log`/`egress-audit.log` (hash-CHAINED NDJSON — each entry's
|
|
70
|
+
// hash covers the previous entry, so per-write whole-file encryption would
|
|
71
|
+
// need the chain to be computed over plaintext before encrypting, a real
|
|
72
|
+
// design question left for its own pass), `dpia.md`/`ropa.md`/`data-
|
|
73
|
+
// inventory.json` (written via engine.js's own internal helper, a larger
|
|
74
|
+
// blast radius to verify safely), `last-scan.json`/`findings.json` (read
|
|
75
|
+
// directly as plain JSON by dozens of commands — encrypting these needs a
|
|
76
|
+
// decrypt-on-read hook at every one of those call sites, a much larger,
|
|
77
|
+
// separate migration in the same spirit as E2's own deferred scope).
|
|
78
|
+
|
|
79
|
+
export const ARTIFACT_REGISTRY = [
|
|
80
|
+
// ── Generated: scan output, caches, and system-maintained ledgers ──────
|
|
81
|
+
{ name: 'validator-metrics.json', kind: 'file', classification: 'generated' },
|
|
82
|
+
{ name: 'triage-feedback.json', kind: 'file', classification: 'generated' },
|
|
83
|
+
{ name: 'scan-history.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
84
|
+
{ name: 'last-scan.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
85
|
+
{ name: 'last-scan.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
86
|
+
{ name: 'shadow-findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
87
|
+
{ name: 'mcp-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence' },
|
|
88
|
+
{ name: 'egress-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence', note: "FR-604 per-call egress audit log — hash-chained NDJSON written by egress/audit.js's recordEgressCall, never read as config" },
|
|
89
|
+
{ name: 'hook-throttle.json', kind: 'file', classification: 'generated', retentionClass: 'cache' },
|
|
90
|
+
{ name: 'tickets.json', kind: 'file', classification: 'generated', retentionClass: 'ticket' },
|
|
91
|
+
{ name: 'streak.json', kind: 'file', classification: 'generated' },
|
|
92
|
+
{ name: 'findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
93
|
+
{ name: 'findings.sarif', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
94
|
+
{ name: 'findings.csv', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
95
|
+
{ name: 'llm-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache' },
|
|
96
|
+
{ name: 'fix-history', kind: 'dir', classification: 'generated', retentionClass: 'backup' },
|
|
97
|
+
{ name: 'fix-plans', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
|
|
98
|
+
// The following were confirmed missing from the old hardcoded WIPE/
|
|
99
|
+
// WIPE_DIRS sets (A-10) and confirmed GENERATED by reading their write
|
|
100
|
+
// call sites (engine.js, or the module named in `source`).
|
|
101
|
+
{ name: 'dpia.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState)' },
|
|
102
|
+
{ name: 'ropa.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-407 RoPA scaffold, dataflow/privacy-governance.js' },
|
|
103
|
+
{ name: 'data-inventory.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 code-derived data inventory, dataflow/privacy-inventory.js' },
|
|
104
|
+
{ name: 'data-flow-graph.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 mermaid flow graph, dataflow/privacy-inventory.js' },
|
|
105
|
+
{ name: 'privacy-framework.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
|
|
106
|
+
{ name: 'privacy-framework.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
|
|
107
|
+
{ name: 'ifds-summaries.json', kind: 'file', classification: 'generated', retentionClass: 'cache', source: 'dataflow/ifds-precise.js (cache)' },
|
|
108
|
+
{ name: 'exploit-bundles.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'engine.js (_safeWriteState)' },
|
|
109
|
+
{ name: 'cve-alerts-state.json', kind: 'file', classification: 'generated', source: 'posture/cve-alert-daemon.js' },
|
|
110
|
+
{ name: 'compliance-evidence.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
|
|
111
|
+
{ name: 'compliance-evidence.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
|
|
112
|
+
{ name: 'ATTRIBUTIONS.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
|
|
113
|
+
{ name: 'NOTICE', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
|
|
114
|
+
{ name: 'accepted.json', kind: 'file', classification: 'generated', source: 'posture/suppressions.js (soft-accept save path)', note: 'already self-managing per-entry expiry (FR-1004-adjacent) — no additional class-level TTL' },
|
|
115
|
+
{ name: 'triage.json', kind: 'file', classification: 'generated', source: 'posture/triage.js (_save)' },
|
|
116
|
+
{ name: 'pqc-migration-plan.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
|
|
117
|
+
{ name: 'pqc-migration-plan.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
|
|
118
|
+
// controls.json lives under compliance/<framework>/ — registering the
|
|
119
|
+
// parent directory covers it and any other per-framework artifact.
|
|
120
|
+
{ name: 'compliance', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/auditor-walkthrough.js' },
|
|
121
|
+
{ name: 'attestations', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/evidence-bundle.js' },
|
|
122
|
+
{ name: 'auditor-walkthroughs', kind: 'dir', classification: 'generated', retentionClass: 'evidence' },
|
|
123
|
+
{ name: 'incremental', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'dataflow/incremental.js (cache)' },
|
|
124
|
+
{ name: 'model-rescan', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
|
|
125
|
+
{ name: 'sca-upgrade-history', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
|
|
126
|
+
{ name: 'scan-baselines', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'posture/pr-augment.js' },
|
|
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.' },
|
|
150
|
+
{ name: 'AGENTS.md', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
|
|
151
|
+
{ name: 'AGENTS.md.archive', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
|
|
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' },
|
|
153
|
+
// These two were found by the completeness guard (test/artifact-registry-
|
|
154
|
+
// completeness.test.js) to have a read call site (leaderboard.js,
|
|
155
|
+
// posture/findings-memory.js respectively) but NO writer anywhere in src/
|
|
156
|
+
// or bin/ — likely dead/aspirational read paths from a scan-history
|
|
157
|
+
// storage scheme that was refactored away. Registered as 'generated'
|
|
158
|
+
// rather than left unclassified: nothing about a per-scan history log/
|
|
159
|
+
// directory suggests hand-authored config, so if a future change starts
|
|
160
|
+
// writing either, the safe default (delete on reset, like scan-history.json)
|
|
161
|
+
// is already in place rather than accidentally falling to operator-config.
|
|
162
|
+
{ name: 'scan-history.jsonl', kind: 'file', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
|
|
163
|
+
{ name: 'scan-history', kind: 'dir', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
|
|
164
|
+
|
|
165
|
+
// FR-706: the last-action proof artifacts `reset` and `export` write —
|
|
166
|
+
// see posture/state-lifecycle-report.js's header for why each is a single
|
|
167
|
+
// overwritten "last action" file rather than an ever-growing log.
|
|
168
|
+
{ name: 'deletion-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdReset)' },
|
|
169
|
+
{ name: 'export-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdExport)' },
|
|
170
|
+
|
|
171
|
+
// ── Operator-config: hand-authored (or agent-authored) input, never wiped ──
|
|
172
|
+
{ name: 'rules.yml', kind: 'file', classification: 'operator-config' },
|
|
173
|
+
{ name: 'rules', kind: 'dir', classification: 'operator-config' },
|
|
174
|
+
{ name: 'rules-proposed', kind: 'dir', classification: 'operator-config', note: 'proposed rules awaiting human review — not yet approved config, but not scanner-regenerable either' },
|
|
175
|
+
{ name: 'license-policy.yml', kind: 'file', classification: 'operator-config' },
|
|
176
|
+
{ name: 'trusted-keys.json', kind: 'file', classification: 'operator-config' },
|
|
177
|
+
{ name: 'ruleset-version.json', kind: 'file', classification: 'operator-config', note: 'pinning intent, hand-set' },
|
|
178
|
+
{ name: 'risk-config.yml', kind: 'file', classification: 'operator-config' },
|
|
179
|
+
{ name: 'egress-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-601 egress policy (mode: allow/deny/local-only, allowedProviders/deniedProviders) — read by egress/policy.js, never written by the scanner' },
|
|
180
|
+
{ name: 'integrations.yml', kind: 'file', classification: 'operator-config' },
|
|
181
|
+
{ name: 'profile.yml', kind: 'file', classification: 'operator-config' },
|
|
182
|
+
{ name: 'sca-policy.yml', kind: 'file', classification: 'operator-config' },
|
|
183
|
+
{ name: 'suppressions.yml', kind: 'file', classification: 'operator-config', note: 'audit-tier suppression config; only ever loaded, never saved, by posture/suppressions.js' },
|
|
184
|
+
{ name: 'network-policy.json', kind: 'file', classification: 'operator-config', note: 'documented as an Inputs-section artifact in posture/network-policy-import.js, not a scanner-written digest' },
|
|
185
|
+
{ name: 'privacy-taxonomy.json', kind: 'file', classification: 'operator-config', note: 'FR-402 privacy data-classification taxonomy overrides/additions — read by dataflow/privacy-taxonomy.js, never written by the scanner' },
|
|
186
|
+
{ name: 'privacy-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-404 privacy sink policy (which class-to-sink flows are explicitly permitted) — read by dataflow/privacy-sink-policy.js, never written by the scanner' },
|
|
187
|
+
{ name: 'privacy-governance.json', kind: 'file', classification: 'operator-config', note: 'FR-407 DPIA/RoPA governance field overrides (purpose, lawful basis, retention, etc.) — read by dataflow/privacy-governance.js, never written by the scanner' },
|
|
188
|
+
{ name: 'compliance-severity-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-502 per-framework/default open-finding severity threshold override — read by posture/auditor-walkthrough.js, never written by the scanner' },
|
|
189
|
+
{ name: 'authorized-approvers.json', kind: 'file', classification: 'operator-config', note: 'FR-1002 identity/role registry for high-impact fix approvals — read by fix/approver-registry.js, never written by the scanner' },
|
|
190
|
+
{ name: 'policy-bundles', kind: 'dir', classification: 'operator-config', note: 'FR-1001 signed organization/repository/environment policy bundles (organization.json/repository.json/environment.json) — distributed by an org and placed by the operator, read by posture/policy-bundle.js, never written by the scanner' },
|
|
191
|
+
{ name: 'policy-bundle-public-key.pem', kind: 'file', classification: 'operator-config', note: 'FR-1001 public key an operator installs to verify org-distributed policy bundles — read by posture/policy-bundle.js, never written by the scanner' },
|
|
192
|
+
{ name: 'retention-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-702 per-retention-class TTL overrides (clamped to a built-in per-class maximum) — read by posture/retention-policy.js, never written by the scanner' },
|
|
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' },
|
|
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' },
|
|
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' },
|
|
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)' },
|
|
199
|
+
{ name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
|
|
200
|
+
{ name: 'exploit-history.jsonl', kind: 'file', classification: 'operator-config', note: 'own header comment: "operator-curated record of past confirmed exploits"' },
|
|
201
|
+
{ name: 'cve-alerts.json', kind: 'file', classification: 'operator-config', note: 'own header comment: "Configuration is read from"; state lives in the separate cve-alerts-state.json, which IS generated' },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
export function listGeneratedArtifacts() {
|
|
205
|
+
return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function listOperatorConfigArtifacts() {
|
|
209
|
+
return ARTIFACT_REGISTRY.filter(a => a.classification === 'operator-config');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function isRegisteredArtifact(name) {
|
|
213
|
+
return ARTIFACT_REGISTRY.some(a => a.name === name);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function classificationOf(name) {
|
|
217
|
+
return ARTIFACT_REGISTRY.find(a => a.name === name)?.classification ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// FR-705: is this artifact marked as containing sensitive content an
|
|
221
|
+
// operator may want encrypted at rest? Deliberately a SEPARATE flag from
|
|
222
|
+
// classification/retentionClass — confidentiality is about content
|
|
223
|
+
// sensitivity, not about who writes it or how long it lives. See
|
|
224
|
+
// encryption-provider.js for the enforcement side (the fail-closed gate
|
|
225
|
+
// this flag feeds) and its own header for which artifacts are marked here
|
|
226
|
+
// in this first, deliberately conservative pass, and why others are not.
|
|
227
|
+
export function confidentialOf(name) {
|
|
228
|
+
return ARTIFACT_REGISTRY.find(a => a.name === name)?.confidential === true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// FR-702: which retention class (if any) governs this artifact's TTL. Only
|
|
232
|
+
// 'generated' artifacts can carry one — an 'operator-config' entry is never
|
|
233
|
+
// auto-expired regardless of what this returns (retention-policy.js's own
|
|
234
|
+
// caller enforces that ordering, not this function).
|
|
235
|
+
export function retentionClassOf(name) {
|
|
236
|
+
return ARTIFACT_REGISTRY.find(a => a.name === name)?.retentionClass ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function listArtifactsWithRetentionClass() {
|
|
240
|
+
return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated' && a.retentionClass);
|
|
241
|
+
}
|
|
@@ -27,19 +27,95 @@
|
|
|
27
27
|
// User supplies their own control mapping in the same shape as the
|
|
28
28
|
// bundled ones. The auditor-walkthrough renders evidence against it.
|
|
29
29
|
//
|
|
30
|
-
// Disclaimer: this module organizes scanner evidence into a narrative.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
// Disclaimer: this module organizes scanner evidence into a narrative. It
|
|
31
|
+
// does not certify compliance. See evidence-grade-wording.js for why the
|
|
32
|
+
// emitted disclaimer names all three assurance tiers explicitly (this
|
|
33
|
+
// module's own is one of the ones that used to get the terminology
|
|
34
|
+
// backwards — "a licensed assessor is responsible for the final
|
|
35
|
+
// attestation" describes independent certification, not attestation).
|
|
33
36
|
|
|
34
37
|
import * as fs from 'node:fs';
|
|
35
38
|
import * as path from 'node:path';
|
|
36
39
|
|
|
37
40
|
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
41
|
+
import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
|
|
38
42
|
import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
|
|
39
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
|
+
}
|
|
40
74
|
|
|
41
75
|
// Re-exported so existing callers/tests keep importing these from here.
|
|
42
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
|
+
|
|
43
119
|
const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
|
|
44
120
|
function _readJson(fp) {
|
|
45
121
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
@@ -187,7 +263,64 @@ export const COMPLIANCE_FAMILY_GAPS = {
|
|
|
187
263
|
// works, which is the mirror image of the bug this prevents.
|
|
188
264
|
};
|
|
189
265
|
|
|
266
|
+
// FR-501/FR-502 (assurance-hardening PRD, A-07): a `family:` mapping used to
|
|
267
|
+
// only count 'critical'/'high' findings as "open" — a control with 50 open
|
|
268
|
+
// MEDIUM findings on its mapped family rendered as
|
|
269
|
+
// "✓ no open critical/high findings", identical to a genuinely clean
|
|
270
|
+
// control. posture/privacy-framework.js already solved the analogous problem
|
|
271
|
+
// for its own four-bucket model (its header calls this out directly: a
|
|
272
|
+
// vacuous pass is "the same false assurance... arriving by a different
|
|
273
|
+
// route"); this raises the floor here to match rather than leaving two
|
|
274
|
+
// different standards for what counts as "open" across compliance surfaces.
|
|
275
|
+
// A named, ordered rank (not a hardcoded pair of string literals) so a
|
|
276
|
+
// future per-framework/per-policy threshold (FR-502's fuller scope) is a
|
|
277
|
+
// one-line change here rather than another hunt through the evaluator.
|
|
278
|
+
const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
279
|
+
const OPEN_FINDING_MIN_SEVERITY = 'medium';
|
|
280
|
+
|
|
281
|
+
// FR-502's fuller scope, delivered: "policy-specific rather than globally
|
|
282
|
+
// high/critical." An operator can lower (or raise) the open-finding floor
|
|
283
|
+
// per framework via .agentic-security/compliance-severity-policy.json:
|
|
284
|
+
// { "default": "medium", "byFramework": { "gdpr": "low" } }
|
|
285
|
+
// `default` overrides OPEN_FINDING_MIN_SEVERITY for every framework that
|
|
286
|
+
// has no more specific `byFramework` entry; a framework entry wins over
|
|
287
|
+
// `default`. Never inferred — an operator decision, same as
|
|
288
|
+
// dataflow/privacy-taxonomy.js's (FR-402) taxonomy customization and
|
|
289
|
+
// egress/policy.js's (FR-602) config-file precedent. A missing file, a
|
|
290
|
+
// malformed one, or a value that is not one of SEVERITY_RANK's five known
|
|
291
|
+
// keys all degrade to the built-in 'medium' floor — falling back to a rank
|
|
292
|
+
// of `undefined` would make the `>=` comparison always false, silently
|
|
293
|
+
// treating EVERY finding as "not open" (the exact vacuous-pass bug this
|
|
294
|
+
// threshold exists to prevent), so an invalid override must never reach
|
|
295
|
+
// the comparison at all.
|
|
296
|
+
const SEVERITY_POLICY_FILE = 'compliance-severity-policy.json';
|
|
297
|
+
|
|
298
|
+
function _resolveOpenFindingMinSeverity(scanRoot, frameworkId) {
|
|
299
|
+
if (!scanRoot) return OPEN_FINDING_MIN_SEVERITY;
|
|
300
|
+
let raw;
|
|
301
|
+
try {
|
|
302
|
+
raw = fs.readFileSync(statePath(scanRoot, SEVERITY_POLICY_FILE), 'utf8');
|
|
303
|
+
} catch {
|
|
304
|
+
return OPEN_FINDING_MIN_SEVERITY; // ENOENT (the common case) or any other read failure
|
|
305
|
+
}
|
|
306
|
+
let doc;
|
|
307
|
+
try {
|
|
308
|
+
doc = JSON.parse(raw);
|
|
309
|
+
} catch {
|
|
310
|
+
return OPEN_FINDING_MIN_SEVERITY; // malformed config — never throws, never blocks evaluation
|
|
311
|
+
}
|
|
312
|
+
if (!doc || typeof doc !== 'object') return OPEN_FINDING_MIN_SEVERITY;
|
|
313
|
+
const byFramework = (doc.byFramework && typeof doc.byFramework === 'object') ? doc.byFramework : {};
|
|
314
|
+
const candidate = (frameworkId && typeof byFramework[frameworkId] === 'string')
|
|
315
|
+
? byFramework[frameworkId]
|
|
316
|
+
: (typeof doc.default === 'string' ? doc.default : null);
|
|
317
|
+
return (candidate && Object.prototype.hasOwnProperty.call(SEVERITY_RANK, candidate))
|
|
318
|
+
? candidate
|
|
319
|
+
: OPEN_FINDING_MIN_SEVERITY;
|
|
320
|
+
}
|
|
321
|
+
|
|
190
322
|
export function evaluateFramework(scanRoot, fw, scan) {
|
|
323
|
+
const minSeverity = _resolveOpenFindingMinSeverity(scanRoot, fw && fw.id);
|
|
191
324
|
// CMP-2: last-scan.json (what this is actually handed in production) carries
|
|
192
325
|
// findings across four separate channels — SAST (`findings`), secrets,
|
|
193
326
|
// business-logic, and SCA (`supplyChain`) — because report/index.js's
|
|
@@ -214,24 +347,30 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
214
347
|
const results = [];
|
|
215
348
|
for (const c of fw.controls || []) {
|
|
216
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 = [];
|
|
217
359
|
let status = 'manual';
|
|
218
360
|
const maps = Array.isArray(c.mapsTo) ? c.mapsTo : [];
|
|
219
361
|
|
|
220
362
|
if (maps.length === 0) {
|
|
221
363
|
obs.push('No automated mapping — requires manual evidence collection.');
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
observations: obs,
|
|
233
|
-
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
234
|
-
});
|
|
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
|
+
});
|
|
235
374
|
continue;
|
|
236
375
|
}
|
|
237
376
|
|
|
@@ -291,12 +430,14 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
291
430
|
const candidates = resolveFamilyKeys(fam, families.keys())
|
|
292
431
|
.flatMap(k => families.get(k) || []);
|
|
293
432
|
const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
|
|
294
|
-
const
|
|
433
|
+
const minRank = SEVERITY_RANK[minSeverity];
|
|
434
|
+
const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
|
|
295
435
|
if (open.length) {
|
|
296
436
|
allCleared = false;
|
|
297
|
-
|
|
437
|
+
contributingFindings.push(...open);
|
|
438
|
+
obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
|
|
298
439
|
} else {
|
|
299
|
-
obs.push(`✓ ${fam}: no open
|
|
440
|
+
obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
|
|
300
441
|
anyCleared = true;
|
|
301
442
|
}
|
|
302
443
|
anySignal = true;
|
|
@@ -399,10 +540,13 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
399
540
|
// artifacts are absent (they degrade to `unmeasured`, never to a default).
|
|
400
541
|
let evidence = null;
|
|
401
542
|
try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
|
|
543
|
+
const dedupedRefs = [...new Set(contributingFindings.map((f) => f.id).filter(Boolean))];
|
|
402
544
|
results.push({
|
|
403
545
|
control: c,
|
|
404
546
|
status,
|
|
405
547
|
observations: obs,
|
|
548
|
+
controlRefs: dedupedRefs,
|
|
549
|
+
derivedProvenance: deriveComplianceProvenance(contributingFindings),
|
|
406
550
|
...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
|
|
407
551
|
});
|
|
408
552
|
}
|
|
@@ -420,7 +564,7 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
|
|
|
420
564
|
lines.push(`> License: ${fw.license}`);
|
|
421
565
|
if (fw.url) lines.push(`> Source: ${fw.url}`);
|
|
422
566
|
lines.push('');
|
|
423
|
-
lines.push(
|
|
567
|
+
lines.push(`> **This walkthrough organizes scanner evidence into a narrative for an external auditor.** ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
|
|
424
568
|
lines.push('');
|
|
425
569
|
|
|
426
570
|
const present = evaluation.filter(e => e.status === 'present').length;
|
|
@@ -457,6 +601,27 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
|
|
|
457
601
|
if (ev.status === 'absent' || ev.status === 'partial') {
|
|
458
602
|
lines.push(`**Remediation:** address the bullet(s) above, then re-run \`/compliance --walkthrough ${fw.id}\` to update this report.`);
|
|
459
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
|
+
}
|
|
460
625
|
}
|
|
461
626
|
}
|
|
462
627
|
|
|
@@ -475,4 +640,4 @@ export function persistWalkthrough(scanRoot, fw, body) {
|
|
|
475
640
|
return fp;
|
|
476
641
|
}
|
|
477
642
|
|
|
478
|
-
export const _internals = { _readJson };
|
|
643
|
+
export const _internals = { _readJson, _resolveOpenFindingMinSeverity, SEVERITY_POLICY_FILE };
|