@clear-capabilities/agentic-security-scanner 0.142.0 → 0.144.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 +399 -0
- package/bin/agentic-security.js +530 -54
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +108 -17
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +165 -52
- package/dist/526.index.js +108 -17
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +1 -1
- 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 +109 -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 +19 -11
- 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 +575 -288
- package/src/fix/apply-fix-service.js +403 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/mcp/tools.js +157 -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 +91 -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 +101 -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 +2 -0
- package/src/posture/accuracy-scorecard.js +96 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +217 -0
- package/src/posture/auditor-walkthrough.js +70 -8
- 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 +314 -17
- 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 +113 -19
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +56 -7
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +90 -0
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/privacy-framework.js +33 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/state-dir.js +36 -1
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +83 -18
- package/src/report/oscal.js +635 -0
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
package/bin/agentic-security.js
CHANGED
|
@@ -39,6 +39,17 @@ function writeStdout(s) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
import { toJSON, toMarkdown, toSARIF, toSTIX, toCSV, toJUnit, toCLI, toCLIByProfile, toShipVerdict, toProTable, toHTML, toSummary, toVex, exitCodeFor, normalizeFindings } from '../src/report/index.js';
|
|
42
|
+
import { toOSCAL } from '../src/report/oscal.js';
|
|
43
|
+
|
|
44
|
+
// Formats whose output is a machine artifact, not something a human reads in a
|
|
45
|
+
// terminal. Three separate copies of this list used to be spelled out inline —
|
|
46
|
+
// the MTTR line, the fix-duration line and the streak line — and each new
|
|
47
|
+
// format had to be added to all three or it silently got human chatter
|
|
48
|
+
// interleaved into its stdout/stderr. One list, one place to update.
|
|
49
|
+
const MACHINE_FORMATS = new Set([
|
|
50
|
+
'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom',
|
|
51
|
+
]);
|
|
52
|
+
function isMachineFormat(fmt) { return MACHINE_FORMATS.has(String(fmt)); }
|
|
42
53
|
import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
|
|
43
54
|
import { toPBOM } from '../src/sast/pipeline.js';
|
|
44
55
|
import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
|
|
@@ -50,14 +61,16 @@ import { listPacks, loadPack, applyPacks } from '../src/posture/rule-packs.js';
|
|
|
50
61
|
import { writeLockfile, verifyLockfile, makeDeterministic, isDeterministic } from '../src/posture/deterministic.js';
|
|
51
62
|
import { enrichWithEPSS } from '../src/posture/epss.js';
|
|
52
63
|
import { enrichWithBlastRadius } from '../src/posture/blast-radius.js';
|
|
53
|
-
import { applyCustomRules, runRuleTests, loadCustomRules } from '../src/posture/custom-rules.js';
|
|
54
|
-
import {
|
|
64
|
+
import { applyCustomRules, runRuleTests, loadCustomRules, customRulesFreshness } from '../src/posture/custom-rules.js';
|
|
65
|
+
import { undoLast, undoAll, listHistory, preview as previewDiff, compactLog } from '../src/posture/fix-history.js';
|
|
66
|
+
import { applyVerifiedFix, confinePath } from '../src/fix/apply-fix-service.js';
|
|
55
67
|
import { syncTickets } from '../src/integrations/tickets.js';
|
|
56
68
|
import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
|
|
57
69
|
import * as triage from '../src/posture/triage.js';
|
|
58
70
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
59
71
|
|
|
60
72
|
import { stateDir, statePath } from '../src/posture/state-dir.js';
|
|
73
|
+
import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
|
|
61
74
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
62
75
|
// so the MCP server tools can share verification.
|
|
63
76
|
function _verifyLastScan(body, sigFile) {
|
|
@@ -92,12 +105,28 @@ Commands:
|
|
|
92
105
|
validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
|
|
93
106
|
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
94
107
|
reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
|
|
108
|
+
--expired only remove artifacts past their retention-class TTL
|
|
109
|
+
Every run writes a deletion-report.json proving what was planned/deleted/preserved/failed.
|
|
110
|
+
export --out <dir> Copy every present .agentic-security/ artifact to <dir> with a manifest (export-manifest.json,
|
|
111
|
+
unsigned) proving what was exported and what failed — for migration or legal-preservation purposes.
|
|
112
|
+
legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]
|
|
113
|
+
Exempt a registered artifact from retention TTL and reset (both --expired and plain)
|
|
114
|
+
legal-hold remove --artifact <name> Lift a hold
|
|
115
|
+
legal-hold list [--all] List active holds (--all also shows past holds whose expires_at has passed)
|
|
116
|
+
calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]
|
|
117
|
+
Opt-in: report a real-world outcome for a past finding, for calibration validation
|
|
118
|
+
calibration-report [--format cli|json] Aggregated, privacy-preserving calibration report from recorded feedback
|
|
95
119
|
rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
|
|
96
120
|
compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
|
|
97
121
|
--list show bundled + BYO frameworks
|
|
98
122
|
--walkthrough <id> auditor narrative for any framework
|
|
123
|
+
--report <id> synonym for --walkthrough
|
|
99
124
|
--gap only the failing controls
|
|
100
|
-
--format cli|json|md (default cli)
|
|
125
|
+
--format cli|json|md|oscal (default cli)
|
|
126
|
+
--format oscal NIST OSCAL assessment-results.
|
|
127
|
+
Controls the engine could not
|
|
128
|
+
decide carry NO finding — see
|
|
129
|
+
docs/OSCAL.md.
|
|
101
130
|
--fail-on gap exit 1 when a control is failing
|
|
102
131
|
Reads .agentic-security/last-scan.json — run a scan first.
|
|
103
132
|
version Print version
|
|
@@ -113,13 +142,16 @@ Commands:
|
|
|
113
142
|
Options:
|
|
114
143
|
--profile vibecoder|pro Override profile for this run
|
|
115
144
|
--only sast|sca|secrets Limit scan to one pillar
|
|
116
|
-
--format <fmt> cli | json | md | sarif | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
|
|
145
|
+
--format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
|
|
117
146
|
--pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
|
|
118
147
|
--baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
|
|
119
148
|
--fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
|
|
120
149
|
--fail-on-new (ci) block ONLY on findings this PR introduced vs
|
|
121
150
|
the baseline ref — never the pre-existing backlog
|
|
122
151
|
--policy <file.rego> ci-mode policy-as-code gate; deny[] rules fail the build (FR-SDLC-9)
|
|
152
|
+
--assurance advisory|standard|strict (ci) incomplete-analysis behavior (default: standard).
|
|
153
|
+
strict fails the build when any analyzer failed, timed out,
|
|
154
|
+
or was silently skipped by policy — independent of --fail-on (FR-204)
|
|
123
155
|
--columns standard|mitre|capec|owasp Pro-mode column set (default: standard)
|
|
124
156
|
--confidence <0..1> Override per-profile confidence threshold
|
|
125
157
|
--firehose Show ALL findings (ignore confidence threshold)
|
|
@@ -584,6 +616,18 @@ async function cmdScan(args) {
|
|
|
584
616
|
}
|
|
585
617
|
} catch {}
|
|
586
618
|
|
|
619
|
+
// FR-207: custom rule pack freshness. This is the one freshness leg that
|
|
620
|
+
// cannot be computed inside engine.js -- the pattern-rule DSL only runs
|
|
621
|
+
// here, after scan.scanHealth already exists -- so it's patched on via
|
|
622
|
+
// the same applyFreshness() engine.js uses for the other four legs (see
|
|
623
|
+
// that function's header comment in pipeline/scan-health.js).
|
|
624
|
+
if (scan.scanHealth) {
|
|
625
|
+
try {
|
|
626
|
+
const { applyFreshness } = await import('../src/pipeline/scan-health.js');
|
|
627
|
+
scan.scanHealth = applyFreshness(scan.scanHealth, { customRules: customRulesFreshness(targetAbs) });
|
|
628
|
+
} catch {}
|
|
629
|
+
}
|
|
630
|
+
|
|
587
631
|
// EPSS exploit-prediction enrichment (skipped under --no-network / --deterministic).
|
|
588
632
|
// Bumps severity on actively-exploited CVEs so they sort to the top.
|
|
589
633
|
if (!args.flags['no-epss'] && !isDeterministic() && !noNet) {
|
|
@@ -678,6 +722,10 @@ async function cmdScan(args) {
|
|
|
678
722
|
if (format === 'json') body = JSON.stringify(toJSON(scan, meta, { includeSuppressed }), null, 2);
|
|
679
723
|
else if (format === 'md' || format === 'markdown') body = toMarkdown(scan, meta);
|
|
680
724
|
else if (format === 'sarif') body = JSON.stringify(toSARIF(scan, meta), null, 2);
|
|
725
|
+
// OSCAL assessment-results. Observations and risks only, never findings — an
|
|
726
|
+
// OSCAL finding is a statement about a control, and a source scan reviews no
|
|
727
|
+
// control catalog. See src/report/oscal.js.
|
|
728
|
+
else if (format === 'oscal') body = JSON.stringify(toOSCAL(scan, meta), null, 2);
|
|
681
729
|
else if (format === 'stix') body = JSON.stringify(toSTIX(scan, meta), null, 2);
|
|
682
730
|
else if (format === 'junit') body = toJUnit(scan, meta);
|
|
683
731
|
else if (format === 'csv') body = toCSV(scan);
|
|
@@ -759,7 +807,7 @@ async function cmdScan(args) {
|
|
|
759
807
|
const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
|
|
760
808
|
persistedScan.mttr = computeMTTR(removed);
|
|
761
809
|
// Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
|
|
762
|
-
const isJson = format
|
|
810
|
+
const isJson = isMachineFormat(format);
|
|
763
811
|
if (!isJson) {
|
|
764
812
|
const sla = renderSlaSummary(persistedScan.findings || []);
|
|
765
813
|
if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
|
|
@@ -789,7 +837,7 @@ async function cmdScan(args) {
|
|
|
789
837
|
const fixMetrics = fixDurationReport(path.resolve(target));
|
|
790
838
|
if (fixMetrics.attempts > 0) {
|
|
791
839
|
persistedScan.fixMetrics = fixMetrics;
|
|
792
|
-
const isJsonFmt = format
|
|
840
|
+
const isJsonFmt = isMachineFormat(format);
|
|
793
841
|
const line = renderFixDurationSummary(fixMetrics);
|
|
794
842
|
if (!isJsonFmt && line) process.stderr.write(`🔧 agentic-security: ${line}\n`);
|
|
795
843
|
}
|
|
@@ -832,7 +880,7 @@ async function cmdScan(args) {
|
|
|
832
880
|
try {
|
|
833
881
|
const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
|
|
834
882
|
// Print celebration / streak line to stderr so it doesn't pollute --format json
|
|
835
|
-
if (streak && process.stderr.isTTY && format
|
|
883
|
+
if (streak && process.stderr.isTTY && !isMachineFormat(format)) {
|
|
836
884
|
const delta = formatGradeDelta(streak);
|
|
837
885
|
const line = formatStreakLine(streak);
|
|
838
886
|
if (delta) process.stderr.write('\n' + delta + '\n');
|
|
@@ -955,8 +1003,23 @@ async function cmdCi(args) {
|
|
|
955
1003
|
const findings = normalizeFindings(scan);
|
|
956
1004
|
const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
957
1005
|
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
1006
|
+
// FR-206: "reports show both finding count and scan-health status." A
|
|
1007
|
+
// 0-finding scan-exit is not the same claim as "the scan actually
|
|
1008
|
+
// finished cleanly" — an annotator error, a file timeout, or a skipped
|
|
1009
|
+
// analyzer already demotes scan.scanHealth.status to 'partial'
|
|
1010
|
+
// (scan-health.js), but until this line nothing in `ci`'s own printed
|
|
1011
|
+
// report ever surfaced it, so a broken analyzer could pass CI silently.
|
|
1012
|
+
// This is a REPORT fix, not a gate-policy change: whether an incomplete
|
|
1013
|
+
// scan should itself FAIL the build regardless of --fail-on is FR-204's
|
|
1014
|
+
// job (assurance modes advisory/standard/strict), not yet implemented —
|
|
1015
|
+
// deliberately not conflated with this fix.
|
|
1016
|
+
const _health = scan.scanHealth;
|
|
1017
|
+
const _healthLine = (_health && _health.status && _health.status !== 'complete')
|
|
1018
|
+
? `[ci] ⚠ scan-health=${_health.status} — ${(_health.conditions || [])[0] || 'analysis did not complete cleanly'}${(_health.conditions || []).length > 1 ? ` (+${_health.conditions.length - 1} more)` : ''}\n`
|
|
1019
|
+
: `[ci] scan-health=${_health && _health.status ? _health.status : 'unknown'}\n`;
|
|
958
1020
|
process.stderr.write(
|
|
959
1021
|
`[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
|
|
1022
|
+
_healthLine +
|
|
960
1023
|
(_canWriteCi
|
|
961
1024
|
? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
|
|
962
1025
|
: `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
|
|
@@ -980,6 +1043,23 @@ async function cmdCi(args) {
|
|
|
980
1043
|
}
|
|
981
1044
|
process.stderr.write(`[ci] policy gate PASSED (${r.runner}, 0 denials)\n`);
|
|
982
1045
|
}
|
|
1046
|
+
// FR-204: assurance modes. Runs ALONGSIDE --fail-on and --policy, same
|
|
1047
|
+
// precedent as FR-SDLC-9's policy gate above — any of the three can fail
|
|
1048
|
+
// the build independently. `standard` (the default) never fails here;
|
|
1049
|
+
// `strict` fails the build when the scan itself was not fully complete,
|
|
1050
|
+
// regardless of how few/no findings resulted from the incomplete run.
|
|
1051
|
+
const { evaluateAssuranceMode, ASSURANCE_MODES, DEFAULT_ASSURANCE_MODE } = await import('../src/pipeline/assurance-mode.js');
|
|
1052
|
+
const assuranceMode = args.flags.assurance || DEFAULT_ASSURANCE_MODE;
|
|
1053
|
+
if (!ASSURANCE_MODES.includes(assuranceMode)) {
|
|
1054
|
+
console.error(`[ci] --assurance must be one of: ${ASSURANCE_MODES.join('|')} (got '${assuranceMode}')`);
|
|
1055
|
+
return 1;
|
|
1056
|
+
}
|
|
1057
|
+
const assuranceVerdict = evaluateAssuranceMode(assuranceMode, scan.scanHealth);
|
|
1058
|
+
if (!assuranceVerdict.ok) {
|
|
1059
|
+
console.error(`[ci] assurance gate FAILED (mode=${assuranceMode}): ${assuranceVerdict.reason}`);
|
|
1060
|
+
return 1;
|
|
1061
|
+
}
|
|
1062
|
+
if (assuranceMode === 'strict') process.stderr.write(`[ci] assurance gate PASSED (mode=strict)\n`);
|
|
983
1063
|
// R24 (PRD §5): PR-native net-new gate. With --fail-on-new and a baseline
|
|
984
1064
|
// ref, block ONLY on findings this PR INTRODUCED (vs the base ref), never on
|
|
985
1065
|
// the pre-existing backlog — the posture teams actually leave enabled.
|
|
@@ -1122,6 +1202,17 @@ async function cmdTriage(args) {
|
|
|
1122
1202
|
console.log(` Open: critical=${t.openBySev.critical} high=${t.openBySev.high} medium=${t.openBySev.medium} low=${t.openBySev.low}`);
|
|
1123
1203
|
if (t.medianMttrDays != null) console.log(` MTTR median: ${t.medianMttrDays.toFixed(1)} days`);
|
|
1124
1204
|
console.log(` Total open: ${t.totalOpen}`);
|
|
1205
|
+
// FR-907: longitudinal production feedback — aggregates 5 already-
|
|
1206
|
+
// separate mechanisms (user suppression, accepted risk, invalid
|
|
1207
|
+
// finding, fixed finding, verification outcome) into one view, rather
|
|
1208
|
+
// than requiring an operator to check 5 different files by hand.
|
|
1209
|
+
const { productionFeedbackReport, renderProductionFeedbackSummary } = await import('../src/posture/production-feedback.js');
|
|
1210
|
+
const feedback = productionFeedbackReport(target, { sinceDays: days });
|
|
1211
|
+
const feedbackSummary = renderProductionFeedbackSummary(feedback);
|
|
1212
|
+
if (feedbackSummary) {
|
|
1213
|
+
console.log('');
|
|
1214
|
+
console.log(feedbackSummary);
|
|
1215
|
+
}
|
|
1125
1216
|
return 0;
|
|
1126
1217
|
}
|
|
1127
1218
|
console.error('triage list | assign <id> <assignee> | transition <id> <state> | trend [--since N]');
|
|
@@ -1566,57 +1657,263 @@ async function cmdReset(args) {
|
|
|
1566
1657
|
console.log(`No state to reset at ${stateDirPath}`);
|
|
1567
1658
|
return 0;
|
|
1568
1659
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
'hook-throttle.json',
|
|
1578
|
-
'tickets.json',
|
|
1579
|
-
'streak.json',
|
|
1580
|
-
'findings.json',
|
|
1581
|
-
'findings.sarif',
|
|
1582
|
-
'findings.csv',
|
|
1583
|
-
]);
|
|
1584
|
-
const WIPE_DIRS = new Set([
|
|
1585
|
-
'llm-cache',
|
|
1586
|
-
'fix-history',
|
|
1587
|
-
'fix-plans',
|
|
1588
|
-
]);
|
|
1660
|
+
// FR-703 (assurance-hardening PRD): registry-driven, not enumeration-
|
|
1661
|
+
// driven. The registry (src/posture/artifact-registry.js) is built from an
|
|
1662
|
+
// audit of every state-writing call site in src/ and bin/, not a
|
|
1663
|
+
// hand-maintained list that drifts as new artifacts are added — see that
|
|
1664
|
+
// module's header for the classification rules (generated vs
|
|
1665
|
+
// operator-config) and the corrections it made to this project's own PRD
|
|
1666
|
+
// evidence table along the way.
|
|
1667
|
+
const GENERATED = new Set(listGeneratedArtifacts().map(a => a.name));
|
|
1589
1668
|
const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1669
|
+
// FR-702: `--expired` narrows the reset to only artifacts that are past
|
|
1670
|
+
// their retention-class TTL (cache/scan/evidence/ticket/backup — see
|
|
1671
|
+
// posture/retention-policy.js), rather than every registered generated
|
|
1672
|
+
// artifact. Reuses this same command's existing confirm/keep/preserve
|
|
1673
|
+
// machinery — a second, parallel deletion pathway would be a second
|
|
1674
|
+
// place to get path-safety wrong.
|
|
1675
|
+
let targets;
|
|
1676
|
+
if (args.flags.expired) {
|
|
1677
|
+
const { findExpiredArtifacts } = await import('../src/posture/retention-policy.js');
|
|
1678
|
+
targets = findExpiredArtifacts(scanRoot)
|
|
1679
|
+
.filter(a => !keep.has(a.name))
|
|
1680
|
+
.map(a => ({ name: a.name, dir: a.isDir, ageDays: a.ageDays, ttlDays: a.ttlDays, retentionClass: a.retentionClass }));
|
|
1681
|
+
} else {
|
|
1682
|
+
targets = [];
|
|
1683
|
+
for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
|
|
1684
|
+
if (keep.has(entry.name)) continue;
|
|
1685
|
+
if (GENERATED.has(entry.name)) {
|
|
1686
|
+
targets.push({ name: entry.name, dir: entry.isDirectory() });
|
|
1687
|
+
}
|
|
1595
1688
|
}
|
|
1596
1689
|
}
|
|
1690
|
+
// FR-707: an artifact under an active legal hold is pulled OUT of the
|
|
1691
|
+
// deletion targets regardless of mode — a hold must protect against a
|
|
1692
|
+
// PLAIN `reset --yes` too (which otherwise deletes every registered
|
|
1693
|
+
// 'generated' artifact unconditionally), not just `--expired`, which
|
|
1694
|
+
// retention-policy.js's own findExpiredArtifacts already excludes on its
|
|
1695
|
+
// own as a second, defense-in-depth enforcement of the same guarantee.
|
|
1696
|
+
const { loadLegalHolds, isUnderHold } = await import('../src/posture/legal-hold.js');
|
|
1697
|
+
const holds = loadLegalHolds(scanRoot);
|
|
1698
|
+
const heldDetail = [];
|
|
1699
|
+
targets = targets.filter(t => {
|
|
1700
|
+
const hold = isUnderHold(t.name, holds);
|
|
1701
|
+
if (!hold) return true;
|
|
1702
|
+
heldDetail.push({ name: t.name, dir: !!t.dir, reason: `active legal hold (owner: ${hold.owner}, reason: ${hold.reason})` });
|
|
1703
|
+
return false;
|
|
1704
|
+
});
|
|
1597
1705
|
if (!targets.length) {
|
|
1598
|
-
console.log(`Nothing to reset under ${stateDirPath}.`);
|
|
1706
|
+
console.log(args.flags.expired ? `Nothing expired under ${stateDirPath}.` : `Nothing to reset under ${stateDirPath}.`);
|
|
1707
|
+
if (heldDetail.length) {
|
|
1708
|
+
console.log(`Preserving ${heldDetail.length} artifact(s) under active legal hold: ${heldDetail.map(h => h.name).sort().join(', ')}`);
|
|
1709
|
+
}
|
|
1599
1710
|
return 0;
|
|
1600
1711
|
}
|
|
1601
|
-
console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
|
|
1602
|
-
for (const t of targets)
|
|
1712
|
+
console.log(`agentic-security reset${args.flags.expired ? ' --expired' : ''} — will remove from ${stateDirPath}:`);
|
|
1713
|
+
for (const t of targets) {
|
|
1714
|
+
const ttlNote = args.flags.expired ? ` (${t.retentionClass}, ${t.ageDays.toFixed(1)}d old, ttl ${t.ttlDays}d)` : '';
|
|
1715
|
+
console.log(` ${t.name}${t.dir ? '/' : ''}${ttlNote}`);
|
|
1716
|
+
}
|
|
1603
1717
|
console.log('');
|
|
1604
|
-
|
|
1718
|
+
// FR-703: report what is ACTUALLY present and being left alone, rather
|
|
1719
|
+
// than a hardcoded 5-name string that drifted behind the real config
|
|
1720
|
+
// surface (risk-config.yml, profile.yml, suppressions.yml, and others were
|
|
1721
|
+
// silently omitted from the old message even though they were already
|
|
1722
|
+
// correctly never wiped).
|
|
1723
|
+
const targetNames = new Set(targets.map(t => t.name));
|
|
1724
|
+
const heldNames = new Set(heldDetail.map(h => h.name));
|
|
1725
|
+
const preserved = heldDetail.map(h => h.name + (h.dir ? '/' : ''));
|
|
1726
|
+
const preservedDetail = heldDetail.map(h => ({ name: h.name, reason: h.reason }));
|
|
1727
|
+
for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
|
|
1728
|
+
if (keep.has(entry.name) || targetNames.has(entry.name) || heldNames.has(entry.name)) continue;
|
|
1729
|
+
if (!args.flags.expired && GENERATED.has(entry.name)) continue;
|
|
1730
|
+
preserved.push(entry.name + (entry.isDirectory() ? '/' : ''));
|
|
1731
|
+
preservedDetail.push({
|
|
1732
|
+
name: entry.name,
|
|
1733
|
+
reason: args.flags.expired && GENERATED.has(entry.name)
|
|
1734
|
+
? 'generated artifact still within its retention TTL'
|
|
1735
|
+
: 'operator-authored configuration',
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
if (preserved.length) {
|
|
1739
|
+
console.log(args.flags.expired
|
|
1740
|
+
? `Preserving operator-authored config and generated artifacts still within their TTL: ${preserved.sort().join(', ')}`
|
|
1741
|
+
: `Preserving operator-authored config: ${preserved.sort().join(', ')}`);
|
|
1742
|
+
}
|
|
1743
|
+
// FR-706: every reset invocation — dry-run or applied — leaves a durable,
|
|
1744
|
+
// structured record of what it planned, deleted, preserved, or failed to
|
|
1745
|
+
// delete, not just console output that vanishes once the terminal
|
|
1746
|
+
// scrolls past it.
|
|
1747
|
+
const { buildDeletionReport, writeDeletionReport } = await import('../src/posture/state-lifecycle-report.js');
|
|
1748
|
+
const mode = args.flags.expired ? 'reset --expired' : 'reset';
|
|
1605
1749
|
if (!args.flags.yes) {
|
|
1606
1750
|
console.log('');
|
|
1607
1751
|
console.log('Pass --yes to proceed (or --keep <name,name> to spare specific items).');
|
|
1752
|
+
writeDeletionReport(scanRoot, buildDeletionReport({
|
|
1753
|
+
mode, dryRun: true, root: scanRoot,
|
|
1754
|
+
items: targets.map(t => ({
|
|
1755
|
+
name: t.name, dir: !!t.dir, status: 'planned',
|
|
1756
|
+
retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null,
|
|
1757
|
+
})),
|
|
1758
|
+
preserved: preservedDetail,
|
|
1759
|
+
}));
|
|
1608
1760
|
return 0;
|
|
1609
1761
|
}
|
|
1762
|
+
const outcomes = [];
|
|
1610
1763
|
for (const t of targets) {
|
|
1611
1764
|
const p = path.join(stateDirPath, t.name);
|
|
1765
|
+
const base = { name: t.name, dir: !!t.dir, retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null };
|
|
1612
1766
|
try {
|
|
1613
1767
|
if (t.dir) await fsp.rm(p, { recursive: true, force: true });
|
|
1614
1768
|
else await fsp.rm(p, { force: true });
|
|
1769
|
+
outcomes.push({ ...base, status: 'deleted' });
|
|
1615
1770
|
} catch (e) {
|
|
1616
1771
|
console.error(`reset: failed to remove ${p}: ${e.message}`);
|
|
1772
|
+
outcomes.push({ ...base, status: 'failed', error: e.message });
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
const failedCount = outcomes.filter(o => o.status === 'failed').length;
|
|
1776
|
+
console.log(`Reset ${outcomes.length - failedCount} item(s)${failedCount ? `, ${failedCount} failed` : ''}. Operator-authored config preserved.`);
|
|
1777
|
+
const reportPath = writeDeletionReport(scanRoot, buildDeletionReport({
|
|
1778
|
+
mode, dryRun: false, root: scanRoot, items: outcomes, preserved: preservedDetail,
|
|
1779
|
+
}));
|
|
1780
|
+
if (reportPath) console.log(`Deletion report: ${path.relative(scanRoot, reportPath)}`);
|
|
1781
|
+
return failedCount ? 1 : 0;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// `agentic-security export --out <dir> [--root <path>]`
|
|
1785
|
+
//
|
|
1786
|
+
// FR-706 (assurance-hardening PRD): the "exported" half of "operators can
|
|
1787
|
+
// prove what was exported, deleted, retained, or failed." Copies every
|
|
1788
|
+
// CURRENTLY-PRESENT registered artifact — generated AND operator-config —
|
|
1789
|
+
// from .agentic-security/ into an operator-chosen destination, alongside a
|
|
1790
|
+
// manifest naming exactly what was copied (with a content hash for files)
|
|
1791
|
+
// and what failed. Unlike `reset`, classification does not gate inclusion:
|
|
1792
|
+
// an export is a snapshot for the operator's own records, migration, or
|
|
1793
|
+
// legal-preservation purposes, not a deletion decision.
|
|
1794
|
+
async function cmdExport(args) {
|
|
1795
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1796
|
+
const stateDirPath = stateDir(scanRoot);
|
|
1797
|
+
if (!fs.existsSync(stateDirPath)) {
|
|
1798
|
+
console.log(`No state to export at ${stateDirPath}`);
|
|
1799
|
+
return 0;
|
|
1800
|
+
}
|
|
1801
|
+
if (!args.flags.out) {
|
|
1802
|
+
console.error('export: --out <dir> is required.');
|
|
1803
|
+
return 2;
|
|
1804
|
+
}
|
|
1805
|
+
const outDir = path.resolve(args.flags.out);
|
|
1806
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
1807
|
+
|
|
1808
|
+
const { ARTIFACT_REGISTRY } = await import('../src/posture/artifact-registry.js');
|
|
1809
|
+
const { buildExportReport, writeExportReport } = await import('../src/posture/state-lifecycle-report.js');
|
|
1810
|
+
const { createHash } = await import('node:crypto');
|
|
1811
|
+
const present = new Set((await fsp.readdir(stateDirPath, { withFileTypes: true })).map(e => e.name));
|
|
1812
|
+
|
|
1813
|
+
const items = [];
|
|
1814
|
+
for (const artifact of ARTIFACT_REGISTRY) {
|
|
1815
|
+
if (!present.has(artifact.name)) continue;
|
|
1816
|
+
const src = path.join(stateDirPath, artifact.name);
|
|
1817
|
+
const dest = path.join(outDir, artifact.name);
|
|
1818
|
+
try {
|
|
1819
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
1820
|
+
if (artifact.kind === 'dir') {
|
|
1821
|
+
await fsp.cp(src, dest, { recursive: true });
|
|
1822
|
+
items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256: null });
|
|
1823
|
+
} else {
|
|
1824
|
+
await fsp.copyFile(src, dest);
|
|
1825
|
+
const sha256 = createHash('sha256').update(fs.readFileSync(src)).digest('hex');
|
|
1826
|
+
items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256 });
|
|
1827
|
+
}
|
|
1828
|
+
} catch (e) {
|
|
1829
|
+
items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'failed', error: e.message });
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
const report = buildExportReport({ root: scanRoot, outDir, items });
|
|
1834
|
+
const manifestPath = path.join(outDir, 'export-manifest.json');
|
|
1835
|
+
await fsp.writeFile(manifestPath, JSON.stringify(report, null, 2) + '\n');
|
|
1836
|
+
writeExportReport(scanRoot, report); // last-action record kept under .agentic-security/ too, same as deletion-report.json
|
|
1837
|
+
|
|
1838
|
+
const failedCount = items.filter(i => i.status === 'failed').length;
|
|
1839
|
+
console.log(`Exported ${items.length - failedCount} artifact(s) to ${outDir}${failedCount ? `, ${failedCount} failed` : ''}.`);
|
|
1840
|
+
console.log(`Manifest: ${manifestPath}`);
|
|
1841
|
+
return failedCount ? 1 : 0;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// `agentic-security legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]`
|
|
1845
|
+
// `agentic-security legal-hold remove --artifact <name>`
|
|
1846
|
+
// `agentic-security legal-hold list [--all]`
|
|
1847
|
+
//
|
|
1848
|
+
// FR-707 (assurance-hardening PRD): identity-bound (--owner), reasoned
|
|
1849
|
+
// (--reason), time-bounded where applicable (--expires is optional — an
|
|
1850
|
+
// indefinite hold has no end date), and auditable (`list` reads back
|
|
1851
|
+
// exactly what was recorded). See posture/legal-hold.js for the full
|
|
1852
|
+
// design rationale and how this is enforced inside `cmdReset`.
|
|
1853
|
+
async function cmdLegalHold(args) {
|
|
1854
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1855
|
+
const sub = args._[1];
|
|
1856
|
+
const { addLegalHold, removeLegalHold, listLegalHolds } = await import('../src/posture/legal-hold.js');
|
|
1857
|
+
|
|
1858
|
+
if (sub === 'add') {
|
|
1859
|
+
const result = addLegalHold(scanRoot, {
|
|
1860
|
+
artifact: args.flags.artifact, owner: args.flags.owner, reason: args.flags.reason, expires_at: args.flags.expires,
|
|
1861
|
+
});
|
|
1862
|
+
if (!result.ok) { console.error(`legal-hold add: ${result.reason}`); return 2; }
|
|
1863
|
+
console.log(`Legal hold placed on "${result.hold.artifact}" (owner: ${result.hold.owner}${result.hold.expires_at ? `, expires ${result.hold.expires_at}` : ', indefinite'}).`);
|
|
1864
|
+
return 0;
|
|
1865
|
+
}
|
|
1866
|
+
if (sub === 'remove') {
|
|
1867
|
+
if (!args.flags.artifact) { console.error('legal-hold remove: --artifact <name> is required.'); return 2; }
|
|
1868
|
+
const removedCount = removeLegalHold(scanRoot, args.flags.artifact);
|
|
1869
|
+
console.log(removedCount ? `Removed ${removedCount} hold(s) on "${args.flags.artifact}".` : `No hold found on "${args.flags.artifact}".`);
|
|
1870
|
+
return 0;
|
|
1871
|
+
}
|
|
1872
|
+
if (sub === 'list' || !sub) {
|
|
1873
|
+
const holds = listLegalHolds(scanRoot, { includeExpired: !!args.flags.all });
|
|
1874
|
+
if (!holds.length) { console.log(args.flags.all ? 'No legal holds recorded (ever).' : 'No active legal holds.'); return 0; }
|
|
1875
|
+
for (const h of holds) {
|
|
1876
|
+
console.log(` ${h.artifact} owner=${h.owner} reason="${h.reason}" expires=${h.expires_at || 'never'} created=${h.created_at}`);
|
|
1617
1877
|
}
|
|
1878
|
+
return 0;
|
|
1618
1879
|
}
|
|
1619
|
-
console.
|
|
1880
|
+
console.error(`legal-hold: unknown subcommand "${sub}" (expected add|remove|list).`);
|
|
1881
|
+
return 2;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// `agentic-security calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]`
|
|
1885
|
+
// `agentic-security calibration-report`
|
|
1886
|
+
//
|
|
1887
|
+
// FR-806 (assurance-hardening PRD): genuinely opt-in — nothing here is ever
|
|
1888
|
+
// written by a scan. See posture/calibration-feedback.js for the full
|
|
1889
|
+
// scoping rationale (why "aggregated" means within-installation, why the
|
|
1890
|
+
// record snapshots only prediction signals and never file/line/vuln text).
|
|
1891
|
+
async function cmdCalibrationFeedback(args) {
|
|
1892
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1893
|
+
const sub = args._[1];
|
|
1894
|
+
const { recordCalibrationFeedback, OUTCOMES } = await import('../src/posture/calibration-feedback.js');
|
|
1895
|
+
|
|
1896
|
+
if (sub === 'record') {
|
|
1897
|
+
const result = recordCalibrationFeedback(scanRoot, {
|
|
1898
|
+
findingId: args.flags['finding-id'], outcome: args.flags.outcome, note: args.flags.note,
|
|
1899
|
+
});
|
|
1900
|
+
if (!result.ok) { console.error(`calibration-feedback record: ${result.reason}`); return 2; }
|
|
1901
|
+
// The persisted record.findingId is privacy-safe (a hash), not the
|
|
1902
|
+
// caller's own input -- echo back what the operator actually typed.
|
|
1903
|
+
console.log(`Recorded "${result.record.outcome}" for finding ${args.flags['finding-id']}.`);
|
|
1904
|
+
return 0;
|
|
1905
|
+
}
|
|
1906
|
+
console.error(`calibration-feedback: unknown subcommand "${sub}" (expected record --finding-id <id> --outcome ${OUTCOMES.join('|')} [--note <text>]).`);
|
|
1907
|
+
return 2;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
async function cmdCalibrationReport(args) {
|
|
1911
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1912
|
+
const { buildCalibrationReport, renderCalibrationReportSummary } = await import('../src/posture/calibration-feedback.js');
|
|
1913
|
+
const report = buildCalibrationReport(scanRoot);
|
|
1914
|
+
if (args.flags.format === 'json') { writeStdout(JSON.stringify(report, null, 2) + '\n'); return 0; }
|
|
1915
|
+
const summary = renderCalibrationReportSummary(report);
|
|
1916
|
+
console.log(summary || 'No calibration feedback recorded yet — this is opt-in; see `calibration-feedback record --help`.');
|
|
1620
1917
|
return 0;
|
|
1621
1918
|
}
|
|
1622
1919
|
|
|
@@ -1791,14 +2088,27 @@ async function cmdCompliance(args) {
|
|
|
1791
2088
|
// The engine records this two ways depending on the emit path; take either.
|
|
1792
2089
|
scan.filesScanned = scan._scanMeta?.filesScanned ?? scan.scanned?.files ?? 0;
|
|
1793
2090
|
|
|
1794
|
-
|
|
2091
|
+
// `--report <fw>` is accepted as a synonym for `--walkthrough <fw>`: the
|
|
2092
|
+
// slash-command surface has always spelled it that way, and it previously
|
|
2093
|
+
// reached the frameworks only through an inlined node call in commands/
|
|
2094
|
+
// rather than through this CLI. One code path, two spellings.
|
|
2095
|
+
const wt = args.flags.walkthrough || args.flags.report;
|
|
1795
2096
|
if (wt && wt !== true) {
|
|
1796
2097
|
const fw = loadFramework(scanRoot, String(wt));
|
|
1797
2098
|
if (!fw) {
|
|
1798
2099
|
console.error(`Unknown framework "${wt}". Try --list.`);
|
|
1799
2100
|
return 2;
|
|
1800
2101
|
}
|
|
1801
|
-
|
|
2102
|
+
const evaluation = evaluateFramework(scanRoot, fw, scan);
|
|
2103
|
+
if (fmt === 'oscal') {
|
|
2104
|
+
const { toOSCALCompliance, complianceRowsFromEvaluation } = await import('../src/report/oscal.js');
|
|
2105
|
+
writeStdout(JSON.stringify(
|
|
2106
|
+
toOSCALCompliance(fw, complianceRowsFromEvaluation(evaluation), { startedAt: scan._scanMeta?.startedAt }),
|
|
2107
|
+
null, 2) + '\n');
|
|
2108
|
+
return 0;
|
|
2109
|
+
}
|
|
2110
|
+
if (fmt === 'json') { writeStdout(JSON.stringify(evaluation, null, 2) + '\n'); return 0; }
|
|
2111
|
+
console.log(renderWalkthrough(fw, evaluation, {}));
|
|
1802
2112
|
return 0;
|
|
1803
2113
|
}
|
|
1804
2114
|
|
|
@@ -1808,6 +2118,19 @@ async function cmdCompliance(args) {
|
|
|
1808
2118
|
if (!r) { console.error(`Framework ${PRIVACY_FRAMEWORK_ID} could not be loaded.`); return 2; }
|
|
1809
2119
|
|
|
1810
2120
|
const gapsOnly = !!args.flags.gap;
|
|
2121
|
+
if (fmt === 'oscal') {
|
|
2122
|
+
// The privacy assessment's own bucket model, not evaluateFramework's — see
|
|
2123
|
+
// complianceRowsFromPrivacy for why `engine-gap` must not become a control
|
|
2124
|
+
// failure. `--gap` is deliberately NOT applied here: an OSCAL document that
|
|
2125
|
+
// silently omitted the satisfied controls would understate what was
|
|
2126
|
+
// reviewed, and reviewed-controls would then disagree with the findings.
|
|
2127
|
+
const { toOSCALCompliance, complianceRowsFromPrivacy } = await import('../src/report/oscal.js');
|
|
2128
|
+
const fwMeta = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID) || { id: PRIVACY_FRAMEWORK_ID, name: r.frameworkName };
|
|
2129
|
+
writeStdout(JSON.stringify(
|
|
2130
|
+
toOSCALCompliance(fwMeta, complianceRowsFromPrivacy(r), { startedAt: scan._scanMeta?.startedAt }),
|
|
2131
|
+
null, 2) + '\n');
|
|
2132
|
+
return args.flags['fail-on'] === 'gap' && r.summary.gap > 0 ? 1 : 0;
|
|
2133
|
+
}
|
|
1811
2134
|
if (fmt === 'json') {
|
|
1812
2135
|
writeStdout(JSON.stringify(gapsOnly ? { ...r, controls: r.controls.filter(c => c.bucket === 'gap') } : r, null, 2) + '\n');
|
|
1813
2136
|
} else if (fmt === 'md') {
|
|
@@ -1943,8 +2266,15 @@ async function cmdVerifyAttestation(args) {
|
|
|
1943
2266
|
if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
|
|
1944
2267
|
|
|
1945
2268
|
let bundle;
|
|
1946
|
-
try {
|
|
1947
|
-
|
|
2269
|
+
try {
|
|
2270
|
+
const raw = fs.readFileSync(path.resolve(file), 'utf8');
|
|
2271
|
+
// FR-705: transparently decrypt if this is an encrypted artifact (e.g.
|
|
2272
|
+
// an encrypted compliance-evidence.json) — a no-op for any plaintext
|
|
2273
|
+
// file, including every artifact from before encryption was ever
|
|
2274
|
+
// configured.
|
|
2275
|
+
const { maybeDecryptForRead } = await import('../src/posture/encryption-provider.js');
|
|
2276
|
+
bundle = JSON.parse(maybeDecryptForRead(raw));
|
|
2277
|
+
} catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
|
|
1948
2278
|
|
|
1949
2279
|
const runAttestation = _asRunAttestation(bundle);
|
|
1950
2280
|
if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
|
|
@@ -1954,6 +2284,24 @@ async function cmdVerifyAttestation(args) {
|
|
|
1954
2284
|
try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
|
|
1955
2285
|
catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
|
|
1956
2286
|
|
|
2287
|
+
// FR-505: a compliance evidence manifest (@type: ComplianceEvidence) is a
|
|
2288
|
+
// third distinct shape this same command can be handed — auto-detected
|
|
2289
|
+
// the same way run-attestation-vs-finding-bundle already is, rather than
|
|
2290
|
+
// adding a fourth CLI command for what is, from an operator's point of
|
|
2291
|
+
// view, the same question ("is this artifact exactly what was signed").
|
|
2292
|
+
if (bundle['@type'] === 'ComplianceEvidence') {
|
|
2293
|
+
const { verifyComplianceEvidence } = await import('../src/posture/compliance-evidence-signing.js');
|
|
2294
|
+
const cr = verifyComplianceEvidence(bundle, publicKeyPem);
|
|
2295
|
+
if (!cr.ok) { console.error(`✗ INVALID — ${cr.reason}`); return 1; }
|
|
2296
|
+
console.log('✓ VALID — the compliance evidence manifest is exactly what the signer produced.');
|
|
2297
|
+
console.log('');
|
|
2298
|
+
console.log(` framework: ${bundle.framework} version: ${bundle.version}`);
|
|
2299
|
+
console.log(` generated: ${bundle.generatedAt}`);
|
|
2300
|
+
if (bundle.evidenceDigest) console.log(` evidence digest: ${bundle.evidenceDigest}`);
|
|
2301
|
+
console.log(` compliant: ${bundle.summary?.compliant ?? 'n/a'} non-compliant: ${bundle.summary?.nonCompliant ?? 'n/a'} stale: ${bundle.summary?.stale ?? 0} gap: ${bundle.summary?.gap ?? 0}`);
|
|
2302
|
+
return 0;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
1957
2305
|
const r = verifyEvidenceBundle(bundle, publicKeyPem);
|
|
1958
2306
|
if (!r.ok) {
|
|
1959
2307
|
console.error(`✗ INVALID — ${r.reason}`);
|
|
@@ -1970,6 +2318,84 @@ async function cmdVerifyAttestation(args) {
|
|
|
1970
2318
|
return 0;
|
|
1971
2319
|
}
|
|
1972
2320
|
|
|
2321
|
+
// FR-1001 (assurance-hardening PRD): "effective policy is explainable."
|
|
2322
|
+
// Loads whichever organization/repository/environment policy bundles exist
|
|
2323
|
+
// under .agentic-security/policy-bundles/, verifies each against an
|
|
2324
|
+
// operator-supplied public key, merges the valid ones (most-specific-wins),
|
|
2325
|
+
// and prints BOTH the effective policy with per-key provenance AND every
|
|
2326
|
+
// rejected bundle's scope and reason — a rejection is reported, never
|
|
2327
|
+
// silently absent, so "tampered or expired policy is rejected" is visible
|
|
2328
|
+
// through this same real command, not just a library-level guarantee.
|
|
2329
|
+
async function cmdPolicyExplain(args) {
|
|
2330
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2331
|
+
const { loadPolicyBundles, loadPolicyPublicKey, resolveEffectivePolicy } = await import('../src/posture/policy-bundle.js');
|
|
2332
|
+
const entries = loadPolicyBundles(scanRoot);
|
|
2333
|
+
if (!entries.length) {
|
|
2334
|
+
console.log('No policy bundles found under .agentic-security/policy-bundles/ (organization.json, repository.json, environment.json).');
|
|
2335
|
+
return 0;
|
|
2336
|
+
}
|
|
2337
|
+
let publicKeyPem = null;
|
|
2338
|
+
const keyFile = args.flags['public-key'];
|
|
2339
|
+
if (keyFile) {
|
|
2340
|
+
try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
|
|
2341
|
+
catch (e) { console.error(`Could not read public key at ${keyFile}: ${e.message}`); return 2; }
|
|
2342
|
+
} else {
|
|
2343
|
+
publicKeyPem = loadPolicyPublicKey(scanRoot);
|
|
2344
|
+
}
|
|
2345
|
+
const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
|
|
2346
|
+
|
|
2347
|
+
console.log(`Accepted (${accepted.length}): ${accepted.join(', ') || 'none'}`);
|
|
2348
|
+
if (rejected.length) {
|
|
2349
|
+
console.log(`Rejected (${rejected.length}):`);
|
|
2350
|
+
for (const r of rejected) console.log(` ✗ ${r.scope}: ${r.reason}`);
|
|
2351
|
+
}
|
|
2352
|
+
console.log('');
|
|
2353
|
+
console.log('Effective policy:');
|
|
2354
|
+
const keys = Object.keys(effective).sort();
|
|
2355
|
+
if (!keys.length) {
|
|
2356
|
+
console.log(' (empty — no accepted bundle contributed any key)');
|
|
2357
|
+
} else {
|
|
2358
|
+
for (const k of keys) console.log(` ${k} = ${JSON.stringify(effective[k])} [from: ${provenance[k]}]`);
|
|
2359
|
+
}
|
|
2360
|
+
return 0;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// FR-1001: the operator-facing signing side, mirroring cmdAttest's own
|
|
2364
|
+
// generate-key-if-absent pattern. Genuinely optional — an org can sign
|
|
2365
|
+
// bundles with any Ed25519 tooling that produces the same canonical bytes
|
|
2366
|
+
// (canonicalPolicyBytes is exported for exactly that interop reason) — but
|
|
2367
|
+
// without SOME real caller for ensurePolicyKeyPair, this codebase's own
|
|
2368
|
+
// dead-module guard is right to flag it: a key-generation function nobody
|
|
2369
|
+
// calls is exactly the kind of code this project's premortems exist to
|
|
2370
|
+
// catch, per posture/CLAUDE.md's dead-module convention.
|
|
2371
|
+
async function cmdPolicySign(args) {
|
|
2372
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2373
|
+
const { ensurePolicyKeyPair, buildPolicyBundle, signPolicyBundle } = await import('../src/posture/policy-bundle.js');
|
|
2374
|
+
const scope = args.flags.scope;
|
|
2375
|
+
if (!['organization', 'repository', 'environment'].includes(scope)) {
|
|
2376
|
+
console.error('Usage: agentic-security policy-sign --scope <organization|repository|environment> --policy <json-file> [--expires <ISO-date>] [--out <path>]');
|
|
2377
|
+
return 2;
|
|
2378
|
+
}
|
|
2379
|
+
const policyFile = args.flags.policy;
|
|
2380
|
+
if (!policyFile) { console.error('--policy <json-file> is required (the policy object to sign).'); return 2; }
|
|
2381
|
+
let policy;
|
|
2382
|
+
try { policy = JSON.parse(fs.readFileSync(path.resolve(policyFile), 'utf8')); }
|
|
2383
|
+
catch (e) { console.error(`Could not read --policy file: ${e.message}`); return 2; }
|
|
2384
|
+
|
|
2385
|
+
const kp = ensurePolicyKeyPair();
|
|
2386
|
+
if (kp.created) console.error(`Generated a new policy-signing key at ${kp.privateKey} (public: ${kp.publicKey}). Distribute the PUBLIC key to every repository that must trust bundles you sign.`);
|
|
2387
|
+
|
|
2388
|
+
const bundle = buildPolicyBundle(scope, policy, { expiresAt: args.flags.expires || null });
|
|
2389
|
+
if (!bundle) { console.error('Could not build a bundle — check --scope and that --policy is a JSON object.'); return 2; }
|
|
2390
|
+
const signed = signPolicyBundle(bundle, kp.privateKeyPem);
|
|
2391
|
+
|
|
2392
|
+
const outFile = args.flags.out || `${scope}.json`;
|
|
2393
|
+
fs.writeFileSync(path.resolve(outFile), JSON.stringify(signed, null, 2) + '\n');
|
|
2394
|
+
console.log(`✓ signed ${scope} policy bundle written to ${outFile}`);
|
|
2395
|
+
console.log(` public key for verification: ${kp.publicKey}`);
|
|
2396
|
+
return 0;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
1973
2399
|
async function cmdRuleSynth(args) {
|
|
1974
2400
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1975
2401
|
const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
|
|
@@ -2053,11 +2479,15 @@ async function cmdFix(args) {
|
|
|
2053
2479
|
return 0;
|
|
2054
2480
|
}
|
|
2055
2481
|
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
|
|
2482
|
+
// FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
|
|
2483
|
+
// before the write — a traversal or symlink-planted f.file was previously
|
|
2484
|
+
// followed unquestioned for both the preview diff and the apply write (no
|
|
2485
|
+
// check existed on this path at all, unlike MCP's apply_fix). Applies to
|
|
2486
|
+
// preview too since both modes read from the same (until now, unconfined)
|
|
2487
|
+
// location.
|
|
2488
|
+
let absFile;
|
|
2489
|
+
try { absFile = confinePath(scanRoot, f.file, 'finding.file'); }
|
|
2490
|
+
catch (e) { console.error(`path-escape refused: ${e.message}`); return 4; }
|
|
2061
2491
|
if (!fs.existsSync(absFile)) { console.error(`File not found: ${absFile}`); return 4; }
|
|
2062
2492
|
const originalContent = await fsp.readFile(absFile, 'utf8');
|
|
2063
2493
|
let newContent = null;
|
|
@@ -2081,16 +2511,56 @@ async function cmdFix(args) {
|
|
|
2081
2511
|
return 0;
|
|
2082
2512
|
}
|
|
2083
2513
|
|
|
2084
|
-
// --apply.
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2514
|
+
// --apply. FR-301/FR-302/FR-304 (assurance-hardening PRD): this used to
|
|
2515
|
+
// WARN on failed integrity and apply anyway, and skip verification
|
|
2516
|
+
// entirely (no rescan, no lint) — the same write-time safety gate MCP's
|
|
2517
|
+
// apply_fix already enforces for its caller-patch branch is now required
|
|
2518
|
+
// here too, through the one shared service.
|
|
2519
|
+
if (sigVerified !== true) {
|
|
2520
|
+
console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
|
|
2521
|
+
return 4;
|
|
2522
|
+
}
|
|
2523
|
+
// FR-307/D-0024: before this, the CLI had no way to supply approval
|
|
2524
|
+
// evidence at all — a high-impact change (auth/authZ/crypto/PII/schema/
|
|
2525
|
+
// infra-privilege/public-API) was unconditionally refused via --apply
|
|
2526
|
+
// with no path to ever approve it, since fixMeta was never built here.
|
|
2527
|
+
// --approved-by/--approval-reason are optional and no-op for a candidate
|
|
2528
|
+
// that isn't high-impact — only apply-fix-service.js's own gate decides
|
|
2529
|
+
// whether they were needed.
|
|
2530
|
+
const approvedBy = args.flags['approved-by'] || null;
|
|
2531
|
+
const approvalReason = args.flags['approval-reason'] || null;
|
|
2532
|
+
// FR-1003: --author is optional and only has an effect when an operator
|
|
2533
|
+
// has opted into separation-of-duties in authorized-approvers.json — see
|
|
2534
|
+
// approver-registry.js's checkSeparationOfDuties.
|
|
2535
|
+
const patchAuthor = args.flags['author'] || null;
|
|
2536
|
+
const fixMeta = (approvedBy || approvalReason || patchAuthor)
|
|
2537
|
+
? { approval: { approvedBy: approvedBy || '', reason: approvalReason || '' }, ...(patchAuthor ? { author: patchAuthor } : {}) }
|
|
2538
|
+
: null;
|
|
2539
|
+
const result = await applyVerifiedFix({
|
|
2540
|
+
scanRoot,
|
|
2541
|
+
finding: { file: f.file, id: f.id, stableId: f.stableId || null, ruleId: f.cwe || f.title, vuln: f.vuln || f.title },
|
|
2542
|
+
files: { [f.file]: newContent },
|
|
2543
|
+
fixMeta,
|
|
2091
2544
|
});
|
|
2092
|
-
|
|
2545
|
+
if (!result.ok) {
|
|
2546
|
+
console.error(`Refusing to apply: ${result.reason}`);
|
|
2547
|
+
if (result.budgetExceeded) console.error(` (${result.attempts}/${result.maxAttempts} attempts already made for this finding)`);
|
|
2548
|
+
return 4;
|
|
2549
|
+
}
|
|
2550
|
+
const entry = result.written[0];
|
|
2551
|
+
console.log(`✓ applied fix ${entry.historyId} (file: ${entry.file})`);
|
|
2093
2552
|
console.log(` backup: ${entry.backupPath}`);
|
|
2553
|
+
// FR-305: this used to unconditionally print "lint" as part of the
|
|
2554
|
+
// verified line, even when the linter was skipped (not installed) or no
|
|
2555
|
+
// test runner existed — claiming a required leg ran when it did not is
|
|
2556
|
+
// exactly the mislabeling FR-305 exists to prevent. Say plainly when the
|
|
2557
|
+
// pass was degraded and name what was skipped.
|
|
2558
|
+
if (result.verifiedFull) {
|
|
2559
|
+
console.log(' verified: yes — fully verified (rescan clean, no new medium+ finding, lint, tests)');
|
|
2560
|
+
} else {
|
|
2561
|
+
const degraded = (result.verify?.degradedLegs || []).join('; ') || 'a required leg';
|
|
2562
|
+
console.log(` verified: yes, but NOT fully verified — rescan clean, no new medium+ finding; ${degraded}`);
|
|
2563
|
+
}
|
|
2094
2564
|
console.log(` revert with: agentic-security undo`);
|
|
2095
2565
|
return 0;
|
|
2096
2566
|
}
|
|
@@ -2262,10 +2732,16 @@ async function main() {
|
|
|
2262
2732
|
case 'validator-cache': process.exit(await cmdValidatorCache(args));
|
|
2263
2733
|
case 'verify': process.exit(await cmdVerify(args));
|
|
2264
2734
|
case 'reset': process.exit(await cmdReset(args));
|
|
2735
|
+
case 'export': process.exit(await cmdExport(args));
|
|
2736
|
+
case 'legal-hold': process.exit(await cmdLegalHold(args));
|
|
2737
|
+
case 'calibration-feedback': process.exit(await cmdCalibrationFeedback(args));
|
|
2738
|
+
case 'calibration-report': process.exit(await cmdCalibrationReport(args));
|
|
2265
2739
|
case 'hunt': process.exit(await cmdHunt(args));
|
|
2266
2740
|
case 'compliance': process.exit(await cmdCompliance(args));
|
|
2267
2741
|
case 'attest': process.exit(await cmdAttest(args));
|
|
2268
2742
|
case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
|
|
2743
|
+
case 'policy-explain': process.exit(await cmdPolicyExplain(args));
|
|
2744
|
+
case 'policy-sign': process.exit(await cmdPolicySign(args));
|
|
2269
2745
|
case 'rule-synth': process.exit(await cmdRuleSynth(args));
|
|
2270
2746
|
case 'digest': process.exit(await cmdDigest(args));
|
|
2271
2747
|
case 'setup': process.exit(await cmdSetup(args));
|