@clear-capabilities/agentic-security-scanner 0.143.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 +304 -0
- package/bin/agentic-security.js +477 -47
- 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 +51 -2
- package/src/report/oscal.js +7 -2
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
package/bin/agentic-security.js
CHANGED
|
@@ -61,14 +61,16 @@ import { listPacks, loadPack, applyPacks } from '../src/posture/rule-packs.js';
|
|
|
61
61
|
import { writeLockfile, verifyLockfile, makeDeterministic, isDeterministic } from '../src/posture/deterministic.js';
|
|
62
62
|
import { enrichWithEPSS } from '../src/posture/epss.js';
|
|
63
63
|
import { enrichWithBlastRadius } from '../src/posture/blast-radius.js';
|
|
64
|
-
import { applyCustomRules, runRuleTests, loadCustomRules } from '../src/posture/custom-rules.js';
|
|
65
|
-
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';
|
|
66
67
|
import { syncTickets } from '../src/integrations/tickets.js';
|
|
67
68
|
import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
|
|
68
69
|
import * as triage from '../src/posture/triage.js';
|
|
69
70
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
70
71
|
|
|
71
72
|
import { stateDir, statePath } from '../src/posture/state-dir.js';
|
|
73
|
+
import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
|
|
72
74
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
73
75
|
// so the MCP server tools can share verification.
|
|
74
76
|
function _verifyLastScan(body, sigFile) {
|
|
@@ -103,6 +105,17 @@ Commands:
|
|
|
103
105
|
validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
|
|
104
106
|
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
105
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
|
|
106
119
|
rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
|
|
107
120
|
compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
|
|
108
121
|
--list show bundled + BYO frameworks
|
|
@@ -136,6 +149,9 @@ Options:
|
|
|
136
149
|
--fail-on-new (ci) block ONLY on findings this PR introduced vs
|
|
137
150
|
the baseline ref — never the pre-existing backlog
|
|
138
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)
|
|
139
155
|
--columns standard|mitre|capec|owasp Pro-mode column set (default: standard)
|
|
140
156
|
--confidence <0..1> Override per-profile confidence threshold
|
|
141
157
|
--firehose Show ALL findings (ignore confidence threshold)
|
|
@@ -600,6 +616,18 @@ async function cmdScan(args) {
|
|
|
600
616
|
}
|
|
601
617
|
} catch {}
|
|
602
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
|
+
|
|
603
631
|
// EPSS exploit-prediction enrichment (skipped under --no-network / --deterministic).
|
|
604
632
|
// Bumps severity on actively-exploited CVEs so they sort to the top.
|
|
605
633
|
if (!args.flags['no-epss'] && !isDeterministic() && !noNet) {
|
|
@@ -975,8 +1003,23 @@ async function cmdCi(args) {
|
|
|
975
1003
|
const findings = normalizeFindings(scan);
|
|
976
1004
|
const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
977
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`;
|
|
978
1020
|
process.stderr.write(
|
|
979
1021
|
`[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
|
|
1022
|
+
_healthLine +
|
|
980
1023
|
(_canWriteCi
|
|
981
1024
|
? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
|
|
982
1025
|
: `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
|
|
@@ -1000,6 +1043,23 @@ async function cmdCi(args) {
|
|
|
1000
1043
|
}
|
|
1001
1044
|
process.stderr.write(`[ci] policy gate PASSED (${r.runner}, 0 denials)\n`);
|
|
1002
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`);
|
|
1003
1063
|
// R24 (PRD §5): PR-native net-new gate. With --fail-on-new and a baseline
|
|
1004
1064
|
// ref, block ONLY on findings this PR INTRODUCED (vs the base ref), never on
|
|
1005
1065
|
// the pre-existing backlog — the posture teams actually leave enabled.
|
|
@@ -1142,6 +1202,17 @@ async function cmdTriage(args) {
|
|
|
1142
1202
|
console.log(` Open: critical=${t.openBySev.critical} high=${t.openBySev.high} medium=${t.openBySev.medium} low=${t.openBySev.low}`);
|
|
1143
1203
|
if (t.medianMttrDays != null) console.log(` MTTR median: ${t.medianMttrDays.toFixed(1)} days`);
|
|
1144
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
|
+
}
|
|
1145
1216
|
return 0;
|
|
1146
1217
|
}
|
|
1147
1218
|
console.error('triage list | assign <id> <assignee> | transition <id> <state> | trend [--since N]');
|
|
@@ -1586,57 +1657,263 @@ async function cmdReset(args) {
|
|
|
1586
1657
|
console.log(`No state to reset at ${stateDirPath}`);
|
|
1587
1658
|
return 0;
|
|
1588
1659
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
'hook-throttle.json',
|
|
1598
|
-
'tickets.json',
|
|
1599
|
-
'streak.json',
|
|
1600
|
-
'findings.json',
|
|
1601
|
-
'findings.sarif',
|
|
1602
|
-
'findings.csv',
|
|
1603
|
-
]);
|
|
1604
|
-
const WIPE_DIRS = new Set([
|
|
1605
|
-
'llm-cache',
|
|
1606
|
-
'fix-history',
|
|
1607
|
-
'fix-plans',
|
|
1608
|
-
]);
|
|
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));
|
|
1609
1668
|
const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|
+
}
|
|
1615
1688
|
}
|
|
1616
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
|
+
});
|
|
1617
1705
|
if (!targets.length) {
|
|
1618
|
-
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
|
+
}
|
|
1619
1710
|
return 0;
|
|
1620
1711
|
}
|
|
1621
|
-
console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
|
|
1622
|
-
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
|
+
}
|
|
1623
1717
|
console.log('');
|
|
1624
|
-
|
|
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';
|
|
1625
1749
|
if (!args.flags.yes) {
|
|
1626
1750
|
console.log('');
|
|
1627
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
|
+
}));
|
|
1628
1760
|
return 0;
|
|
1629
1761
|
}
|
|
1762
|
+
const outcomes = [];
|
|
1630
1763
|
for (const t of targets) {
|
|
1631
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 };
|
|
1632
1766
|
try {
|
|
1633
1767
|
if (t.dir) await fsp.rm(p, { recursive: true, force: true });
|
|
1634
1768
|
else await fsp.rm(p, { force: true });
|
|
1769
|
+
outcomes.push({ ...base, status: 'deleted' });
|
|
1635
1770
|
} catch (e) {
|
|
1636
1771
|
console.error(`reset: failed to remove ${p}: ${e.message}`);
|
|
1772
|
+
outcomes.push({ ...base, status: 'failed', error: e.message });
|
|
1637
1773
|
}
|
|
1638
1774
|
}
|
|
1639
|
-
|
|
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}`);
|
|
1877
|
+
}
|
|
1878
|
+
return 0;
|
|
1879
|
+
}
|
|
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`.');
|
|
1640
1917
|
return 0;
|
|
1641
1918
|
}
|
|
1642
1919
|
|
|
@@ -1989,8 +2266,15 @@ async function cmdVerifyAttestation(args) {
|
|
|
1989
2266
|
if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
|
|
1990
2267
|
|
|
1991
2268
|
let bundle;
|
|
1992
|
-
try {
|
|
1993
|
-
|
|
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; }
|
|
1994
2278
|
|
|
1995
2279
|
const runAttestation = _asRunAttestation(bundle);
|
|
1996
2280
|
if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
|
|
@@ -2000,6 +2284,24 @@ async function cmdVerifyAttestation(args) {
|
|
|
2000
2284
|
try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
|
|
2001
2285
|
catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
|
|
2002
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
|
+
|
|
2003
2305
|
const r = verifyEvidenceBundle(bundle, publicKeyPem);
|
|
2004
2306
|
if (!r.ok) {
|
|
2005
2307
|
console.error(`✗ INVALID — ${r.reason}`);
|
|
@@ -2016,6 +2318,84 @@ async function cmdVerifyAttestation(args) {
|
|
|
2016
2318
|
return 0;
|
|
2017
2319
|
}
|
|
2018
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
|
+
|
|
2019
2399
|
async function cmdRuleSynth(args) {
|
|
2020
2400
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2021
2401
|
const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
|
|
@@ -2099,11 +2479,15 @@ async function cmdFix(args) {
|
|
|
2099
2479
|
return 0;
|
|
2100
2480
|
}
|
|
2101
2481
|
|
|
2102
|
-
//
|
|
2103
|
-
//
|
|
2104
|
-
//
|
|
2105
|
-
//
|
|
2106
|
-
|
|
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; }
|
|
2107
2491
|
if (!fs.existsSync(absFile)) { console.error(`File not found: ${absFile}`); return 4; }
|
|
2108
2492
|
const originalContent = await fsp.readFile(absFile, 'utf8');
|
|
2109
2493
|
let newContent = null;
|
|
@@ -2127,16 +2511,56 @@ async function cmdFix(args) {
|
|
|
2127
2511
|
return 0;
|
|
2128
2512
|
}
|
|
2129
2513
|
|
|
2130
|
-
// --apply.
|
|
2131
|
-
//
|
|
2132
|
-
//
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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,
|
|
2137
2544
|
});
|
|
2138
|
-
|
|
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})`);
|
|
2139
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
|
+
}
|
|
2140
2564
|
console.log(` revert with: agentic-security undo`);
|
|
2141
2565
|
return 0;
|
|
2142
2566
|
}
|
|
@@ -2308,10 +2732,16 @@ async function main() {
|
|
|
2308
2732
|
case 'validator-cache': process.exit(await cmdValidatorCache(args));
|
|
2309
2733
|
case 'verify': process.exit(await cmdVerify(args));
|
|
2310
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));
|
|
2311
2739
|
case 'hunt': process.exit(await cmdHunt(args));
|
|
2312
2740
|
case 'compliance': process.exit(await cmdCompliance(args));
|
|
2313
2741
|
case 'attest': process.exit(await cmdAttest(args));
|
|
2314
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));
|
|
2315
2745
|
case 'rule-synth': process.exit(await cmdRuleSynth(args));
|
|
2316
2746
|
case 'digest': process.exit(await cmdDigest(args));
|
|
2317
2747
|
case 'setup': process.exit(await cmdSetup(args));
|