@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +555 -0
- package/bin/agentic-security.js +770 -49
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +117 -18
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +24 -6
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +187 -52
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +117 -18
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +27 -5
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +173 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +23 -9
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +1048 -302
- package/src/fix/apply-fix-service.js +404 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +177 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +154 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +108 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +123 -0
- package/src/posture/accuracy-scorecard.js +156 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +241 -0
- package/src/posture/auditor-walkthrough.js +186 -21
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +324 -17
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +137 -20
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +65 -8
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/git-history.js +13 -5
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +111 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +47 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +64 -5
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +248 -5
- package/src/report/oscal.js +7 -2
- package/src/runScan.js +34 -5
- package/src/sast/cpp.js +3 -14
- package/src/sast/rate-limit.js +33 -3
- package/src/sca/llm-function-extract.js +6 -0
- package/src/util/git-hardening.js +128 -0
package/bin/agentic-security.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createRequire } from 'node:module';
|
|
|
8
8
|
const __require = createRequire(import.meta.url);
|
|
9
9
|
const PKG_VERSION = __require('../package.json').version;
|
|
10
10
|
import { signLastScan as _signLastScan, verifyLastScan as _verifyLastScanShared } from '../src/posture/integrity.js';
|
|
11
|
+
import { isProvenanceHealthy, sanitizeForTerminal } from '../src/posture/provenance/schema.js';
|
|
11
12
|
import { runScan } from '../src/runScan.js';
|
|
12
13
|
|
|
13
14
|
// Every command is dispatched as `process.exit(await cmdX(args))`, and
|
|
@@ -61,14 +62,16 @@ import { listPacks, loadPack, applyPacks } from '../src/posture/rule-packs.js';
|
|
|
61
62
|
import { writeLockfile, verifyLockfile, makeDeterministic, isDeterministic } from '../src/posture/deterministic.js';
|
|
62
63
|
import { enrichWithEPSS } from '../src/posture/epss.js';
|
|
63
64
|
import { enrichWithBlastRadius } from '../src/posture/blast-radius.js';
|
|
64
|
-
import { applyCustomRules, runRuleTests, loadCustomRules } from '../src/posture/custom-rules.js';
|
|
65
|
-
import {
|
|
65
|
+
import { applyCustomRules, runRuleTests, loadCustomRules, customRulesFreshness } from '../src/posture/custom-rules.js';
|
|
66
|
+
import { undoLast, undoAll, listHistory, preview as previewDiff, compactLog } from '../src/posture/fix-history.js';
|
|
67
|
+
import { applyVerifiedFix, confinePath } from '../src/fix/apply-fix-service.js';
|
|
66
68
|
import { syncTickets } from '../src/integrations/tickets.js';
|
|
67
69
|
import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
|
|
68
70
|
import * as triage from '../src/posture/triage.js';
|
|
69
71
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
70
72
|
|
|
71
73
|
import { stateDir, statePath } from '../src/posture/state-dir.js';
|
|
74
|
+
import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
|
|
72
75
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
73
76
|
// so the MCP server tools can share verification.
|
|
74
77
|
function _verifyLastScan(body, sigFile) {
|
|
@@ -103,6 +106,17 @@ Commands:
|
|
|
103
106
|
validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
|
|
104
107
|
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
105
108
|
reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
|
|
109
|
+
--expired only remove artifacts past their retention-class TTL
|
|
110
|
+
Every run writes a deletion-report.json proving what was planned/deleted/preserved/failed.
|
|
111
|
+
export --out <dir> Copy every present .agentic-security/ artifact to <dir> with a manifest (export-manifest.json,
|
|
112
|
+
unsigned) proving what was exported and what failed — for migration or legal-preservation purposes.
|
|
113
|
+
legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]
|
|
114
|
+
Exempt a registered artifact from retention TTL and reset (both --expired and plain)
|
|
115
|
+
legal-hold remove --artifact <name> Lift a hold
|
|
116
|
+
legal-hold list [--all] List active holds (--all also shows past holds whose expires_at has passed)
|
|
117
|
+
calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]
|
|
118
|
+
Opt-in: report a real-world outcome for a past finding, for calibration validation
|
|
119
|
+
calibration-report [--format cli|json] Aggregated, privacy-preserving calibration report from recorded feedback
|
|
106
120
|
rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
|
|
107
121
|
compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
|
|
108
122
|
--list show bundled + BYO frameworks
|
|
@@ -136,6 +150,9 @@ Options:
|
|
|
136
150
|
--fail-on-new (ci) block ONLY on findings this PR introduced vs
|
|
137
151
|
the baseline ref — never the pre-existing backlog
|
|
138
152
|
--policy <file.rego> ci-mode policy-as-code gate; deny[] rules fail the build (FR-SDLC-9)
|
|
153
|
+
--assurance advisory|standard|strict (ci) incomplete-analysis behavior (default: standard).
|
|
154
|
+
strict fails the build when any analyzer failed, timed out,
|
|
155
|
+
or was silently skipped by policy — independent of --fail-on (FR-204)
|
|
139
156
|
--columns standard|mitre|capec|owasp Pro-mode column set (default: standard)
|
|
140
157
|
--confidence <0..1> Override per-profile confidence threshold
|
|
141
158
|
--firehose Show ALL findings (ignore confidence threshold)
|
|
@@ -169,9 +186,20 @@ Options:
|
|
|
169
186
|
--no-epss Skip EPSS exploit-prediction enrichment (default: enabled)
|
|
170
187
|
--no-blast-radius Skip blast-radius / cost framing (default: enabled)
|
|
171
188
|
--verbose Include fix bodies + taxonomy in CLI output
|
|
189
|
+
(with --firehose, also prints each finding's git-origin provenance)
|
|
172
190
|
--output <file> Write report to file instead of stdout
|
|
173
191
|
--machine-output Always write .agentic-security/findings.{sarif,json,csv}
|
|
174
192
|
|
|
193
|
+
Finding provenance (which commit introduced each finding):
|
|
194
|
+
--provenance <standard|deep> Resolution depth (default: standard; deep explores non-linear ancestry — merges, reverts, cherry-picks)
|
|
195
|
+
--no-provenance Skip git-history provenance entirely (findings report not_available)
|
|
196
|
+
--provenance-since <ref> Do not walk history earlier than this git ref/commit
|
|
197
|
+
--provenance-timeout <ms> Whole-scan provenance budget in MILLISECONDS (default 60000)
|
|
198
|
+
--include-author-email Keep commit author emails in output (redacted by default)
|
|
199
|
+
--pseudonymize-authors Replace commit author names with a stable Contributor-XXXXXXXX id
|
|
200
|
+
--require-provenance Report unresolved provenance as a scan-health condition
|
|
201
|
+
(downgrades scanHealth.status to 'partial'; never changes the exit code)
|
|
202
|
+
|
|
175
203
|
Exit codes:
|
|
176
204
|
0 = clean 1 = low/medium 2 = high 3 = critical 4 = error`;
|
|
177
205
|
|
|
@@ -377,6 +405,112 @@ function parseArgs(argv) {
|
|
|
377
405
|
return args;
|
|
378
406
|
}
|
|
379
407
|
|
|
408
|
+
// The only values `--provenance` accepts. Used both to validate an inline
|
|
409
|
+
// `--provenance=<mode>` and to decide whether a following bare token is this
|
|
410
|
+
// flag's value at all — see the comment inside the parser.
|
|
411
|
+
const PROVENANCE_MODES = new Set(['standard', 'deep']);
|
|
412
|
+
|
|
413
|
+
// Finding Provenance (M0/M1) — CLI flags that set the env vars engine.js
|
|
414
|
+
// (Task 15) and report/index.js (Task 16) already read:
|
|
415
|
+
// AGENTIC_SECURITY_NO_PROVENANCE, AGENTIC_SECURITY_PROVENANCE_MODE,
|
|
416
|
+
// AGENTIC_SECURITY_PROVENANCE_SINCE, AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS,
|
|
417
|
+
// AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL,
|
|
418
|
+
// AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS. `requireProvenance` is consumed
|
|
419
|
+
// directly by cmdScan (post-scan scanHealth augmentation), not via an env var.
|
|
420
|
+
// Kept as a pure function (argv in, plain object out) so it's unit-testable
|
|
421
|
+
// without invoking the CLI dispatch or touching process.env.
|
|
422
|
+
export function parseProvenanceFlags(argv) {
|
|
423
|
+
// OPT-IN, not opt-out (0.145.0). Provenance was on by default through M0-M4,
|
|
424
|
+
// and the release gate caught what that costs: time-to-first-finding on a
|
|
425
|
+
// 207-file tree went 4.5s -> 45s, a 7.6x regression on the ONE metric
|
|
426
|
+
// bench/ttff/runner.mjs's own header calls the binding constraint for this
|
|
427
|
+
// product's vibecoder ICP ("how long until the FIRST useful result, not
|
|
428
|
+
// aggregate F1"). Resolving history for every finding is simply not what a
|
|
429
|
+
// first-time user is waiting for. It stays one flag away, and CI/compliance
|
|
430
|
+
// callers that DO want it pass `--provenance` explicitly.
|
|
431
|
+
//
|
|
432
|
+
// `disabled` therefore starts true, and any provenance-shaped flag flips it
|
|
433
|
+
// off — asking for `--provenance-since` or `--require-provenance` is asking
|
|
434
|
+
// for provenance, and making the user also pass a bare `--provenance`
|
|
435
|
+
// alongside it would be a papercut with no upside.
|
|
436
|
+
const result = { mode: 'standard', since: null, timeoutMs: undefined, includeEmail: false, pseudonymize: false, requireProvenance: false, disabled: true, warning: null };
|
|
437
|
+
const warnings = [];
|
|
438
|
+
for (let i = 0; i < argv.length; i++) {
|
|
439
|
+
const a = argv[i];
|
|
440
|
+
if (typeof a !== 'string' || !a.startsWith('--')) continue;
|
|
441
|
+
// BOTH `--flag value` and `--flag=value`, because parseArgs() above accepts
|
|
442
|
+
// both for every other flag in this CLI and an operator has no way to know
|
|
443
|
+
// this one parser is different. Exact-string matching silently ignored
|
|
444
|
+
// `--provenance=deep` / `--provenance-since=v1.0.0` /
|
|
445
|
+
// `--provenance-timeout=30000` — no warning, defaults quietly used.
|
|
446
|
+
//
|
|
447
|
+
// Split on the FIRST `=` and keep the whole remainder, rather than
|
|
448
|
+
// parseArgs's `split('=', 2)` which drops everything after a second `=`.
|
|
449
|
+
// A git ref (`--provenance-since=refs/tags/v1=rc1`) is a legal value and
|
|
450
|
+
// truncating it would be a worse failure than the one being fixed.
|
|
451
|
+
const eq = a.indexOf('=');
|
|
452
|
+
const key = eq === -1 ? a : a.slice(0, eq);
|
|
453
|
+
const inline = eq === -1 ? undefined : a.slice(eq + 1);
|
|
454
|
+
// `--flag value` consumes the NEXT argv entry only when there is no inline
|
|
455
|
+
// value; a value that itself starts with `--` is another flag, not this
|
|
456
|
+
// one's argument.
|
|
457
|
+
const takeValue = () => {
|
|
458
|
+
if (inline !== undefined) return inline;
|
|
459
|
+
const next = argv[i + 1];
|
|
460
|
+
if (next !== undefined && !String(next).startsWith('--')) { i++; return next; }
|
|
461
|
+
return undefined;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
if (key === '--no-provenance') result.disabled = true;
|
|
465
|
+
else if (key === '--provenance') {
|
|
466
|
+
result.disabled = false;
|
|
467
|
+
// The SPACE form only claims the next token when it actually names a
|
|
468
|
+
// mode. `--provenance` is legal on its own (provenance is on by default;
|
|
469
|
+
// the flag is how you say "standard, explicitly"), and this CLI's target
|
|
470
|
+
// is nearly always a positional — `scan --provenance ./src` must scan
|
|
471
|
+
// `./src`, not report `unrecognised mode './src'`. parseArgs() does its
|
|
472
|
+
// own positional collection over the same argv, so consuming here never
|
|
473
|
+
// steals the path from it, but the warning would still be a lie.
|
|
474
|
+
//
|
|
475
|
+
// The INLINE form has no such ambiguity: `--provenance=x` can only ever
|
|
476
|
+
// be a mode, so an unknown one is a typo worth naming.
|
|
477
|
+
const v = inline !== undefined
|
|
478
|
+
? inline
|
|
479
|
+
: (PROVENANCE_MODES.has(String(argv[i + 1])) ? argv[++i] : undefined);
|
|
480
|
+
if (v === 'deep' || v === 'standard' || v === undefined) {
|
|
481
|
+
result.mode = v || 'standard';
|
|
482
|
+
} else {
|
|
483
|
+
warnings.push(`unrecognised --provenance mode '${v}' (expected standard|deep), running standard`);
|
|
484
|
+
}
|
|
485
|
+
} else if (key === '--provenance-since') { result.since = takeValue() ?? null; result.disabled = false; }
|
|
486
|
+
else if (key === '--provenance-timeout') {
|
|
487
|
+
// MILLISECONDS, and validated as such. `parseInt` alone turned
|
|
488
|
+
// `--provenance-timeout 30s` into 30 — a 30-MILLISECOND budget that
|
|
489
|
+
// expires before the first `git blame` returns, so every finding came
|
|
490
|
+
// back `budget_exhausted` and nothing said why. A missing value produced
|
|
491
|
+
// NaN, which `if (_provFlags.timeoutMs)` then discarded silently. Both
|
|
492
|
+
// now warn and fall back to the engine default rather than inventing a
|
|
493
|
+
// budget the operator did not ask for. The unit is fixed by the env var
|
|
494
|
+
// this flag feeds, AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS.
|
|
495
|
+
const raw = takeValue();
|
|
496
|
+
if (raw === undefined) {
|
|
497
|
+
warnings.push('--provenance-timeout requires a value in milliseconds; using the default budget');
|
|
498
|
+
} else if (!/^\d+$/.test(String(raw).trim()) || parseInt(raw, 10) <= 0) {
|
|
499
|
+
warnings.push(`--provenance-timeout expects a positive integer number of MILLISECONDS, got '${raw}'; using the default budget`);
|
|
500
|
+
} else {
|
|
501
|
+
result.timeoutMs = parseInt(raw, 10);
|
|
502
|
+
result.disabled = false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else if (key === '--include-author-email') { result.includeEmail = true; result.disabled = false; }
|
|
506
|
+
else if (key === '--pseudonymize-authors') { result.pseudonymize = true; result.disabled = false; }
|
|
507
|
+
else if (key === '--require-provenance') { result.requireProvenance = true; result.disabled = false; }
|
|
508
|
+
}
|
|
509
|
+
// One field, so a caller that prints `warning` cannot drop the second one.
|
|
510
|
+
result.warning = warnings.length ? warnings.join('; ') : null;
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
|
|
380
514
|
async function cmdScan(args) {
|
|
381
515
|
// NON_MUTATING_SCAN_PRD S1 — a scan is an observation; --no-state makes it one.
|
|
382
516
|
if (args.flags['no-state']) {
|
|
@@ -472,12 +606,67 @@ async function cmdScan(args) {
|
|
|
472
606
|
process.stderr.write(`[pr-mode] scanning files changed since: ${changedSince}\n`);
|
|
473
607
|
}
|
|
474
608
|
|
|
609
|
+
// Finding Provenance (M0/M1) — translate --provenance/--no-provenance/etc.
|
|
610
|
+
// into the env vars engine.js already reads. Must run before runScan()
|
|
611
|
+
// below, same as the --deep/--no-deep wiring above.
|
|
612
|
+
const _provFlags = parseProvenanceFlags(process.argv.slice(2));
|
|
613
|
+
if (_provFlags.warning) console.error(`Warning: ${_provFlags.warning}`);
|
|
614
|
+
if (_provFlags.disabled) process.env.AGENTIC_SECURITY_NO_PROVENANCE = '1';
|
|
615
|
+
process.env.AGENTIC_SECURITY_PROVENANCE_MODE = _provFlags.mode;
|
|
616
|
+
if (_provFlags.since) process.env.AGENTIC_SECURITY_PROVENANCE_SINCE = _provFlags.since;
|
|
617
|
+
if (_provFlags.timeoutMs) process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS = String(_provFlags.timeoutMs);
|
|
618
|
+
if (_provFlags.includeEmail) process.env.AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL = '1';
|
|
619
|
+
if (_provFlags.pseudonymize) process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS = '1';
|
|
620
|
+
|
|
475
621
|
const { scan, meta } = await runScan(target, {
|
|
476
622
|
changedSince,
|
|
477
623
|
onProgress: (p) => {
|
|
478
624
|
if (process.stderr.isTTY) process.stderr.write(`\r[${p.phase}] ${p.current}/${p.total} ${p.file} `);
|
|
479
625
|
},
|
|
480
626
|
});
|
|
627
|
+
// --require-provenance: flag (never fail) any finding whose provenance
|
|
628
|
+
// isn't resolved, via scanHealth — deliberately independent of the
|
|
629
|
+
// severity-based exit code computed by exitCodeFor() at the end of this
|
|
630
|
+
// function. 'uncommitted' is a legitimate terminal status (the finding is
|
|
631
|
+
// in a file with no git history yet), not an incomplete one.
|
|
632
|
+
if (_provFlags.requireProvenance) {
|
|
633
|
+
// ALL FOUR channels (scanner/CLAUDE.md: findings / secrets / supplyChain /
|
|
634
|
+
// logicVulns). Checking only `scan.findings` made the flag report a clean
|
|
635
|
+
// bill of provenance health for a scan whose SCA and secrets findings had
|
|
636
|
+
// none — and report/index.js's normalizeFindings ships all four to the
|
|
637
|
+
// user as findings, so "every finding" has to mean all four here too.
|
|
638
|
+
const incomplete = [];
|
|
639
|
+
for (const [channel, bucket] of [
|
|
640
|
+
['findings', scan.findings], ['secrets', scan.secrets],
|
|
641
|
+
['supplyChain', scan.supplyChain], ['logicVulns', scan.logicVulns],
|
|
642
|
+
]) {
|
|
643
|
+
for (const f of (bucket || [])) {
|
|
644
|
+
if (!f || typeof f !== 'object') continue;
|
|
645
|
+
if (isProvenanceHealthy(f.findingProvenance)) continue;
|
|
646
|
+
incomplete.push(f.id || f.stableId || `${channel}:${f.name || f.file || f.type || 'entry'}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (incomplete.length > 0) {
|
|
650
|
+
// Written the way EVERY other scan-health signal is written — a sentence
|
|
651
|
+
// in `conditions[]` plus a `complete` -> `partial` status demotion (see
|
|
652
|
+
// pipeline/scan-health.js's applyFreshness, the same "patch it on from
|
|
653
|
+
// bin/ after the engine already built scanHealth" pattern). The previous
|
|
654
|
+
// version set a bespoke `scanHealth.provenanceIncomplete` key that NO
|
|
655
|
+
// consumer reads: pipeline/assurance-mode.js and
|
|
656
|
+
// posture/compliance-policy.js both read `status`/`conditions[]` only, so
|
|
657
|
+
// --require-provenance changed no behaviour anywhere. The array is still
|
|
658
|
+
// carried, for a consumer that wants the ids, but it is no longer the
|
|
659
|
+
// only trace.
|
|
660
|
+
const condition = `--require-provenance: ${incomplete.length} finding(s) have unresolved provenance`;
|
|
661
|
+
scan.scanHealth = {
|
|
662
|
+
...(scan.scanHealth || {}),
|
|
663
|
+
conditions: [...(Array.isArray(scan.scanHealth?.conditions) ? scan.scanHealth.conditions : []), condition],
|
|
664
|
+
status: (scan.scanHealth?.status ?? 'complete') === 'complete' ? 'partial' : scan.scanHealth.status,
|
|
665
|
+
provenanceIncomplete: incomplete,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
481
670
|
// The BOM/attestation emitters stamp the producing engine's version into
|
|
482
671
|
// their metadata; carry the real package version so it can never drift.
|
|
483
672
|
if (meta && meta.engineVersion == null) meta.engineVersion = PKG_VERSION;
|
|
@@ -600,6 +789,18 @@ async function cmdScan(args) {
|
|
|
600
789
|
}
|
|
601
790
|
} catch {}
|
|
602
791
|
|
|
792
|
+
// FR-207: custom rule pack freshness. This is the one freshness leg that
|
|
793
|
+
// cannot be computed inside engine.js -- the pattern-rule DSL only runs
|
|
794
|
+
// here, after scan.scanHealth already exists -- so it's patched on via
|
|
795
|
+
// the same applyFreshness() engine.js uses for the other four legs (see
|
|
796
|
+
// that function's header comment in pipeline/scan-health.js).
|
|
797
|
+
if (scan.scanHealth) {
|
|
798
|
+
try {
|
|
799
|
+
const { applyFreshness } = await import('../src/pipeline/scan-health.js');
|
|
800
|
+
scan.scanHealth = applyFreshness(scan.scanHealth, { customRules: customRulesFreshness(targetAbs) });
|
|
801
|
+
} catch {}
|
|
802
|
+
}
|
|
803
|
+
|
|
603
804
|
// EPSS exploit-prediction enrichment (skipped under --no-network / --deterministic).
|
|
604
805
|
// Bumps severity on actively-exploited CVEs so they sort to the top.
|
|
605
806
|
if (!args.flags['no-epss'] && !isDeterministic() && !noNet) {
|
|
@@ -717,7 +918,20 @@ async function cmdScan(args) {
|
|
|
717
918
|
// per-finding list (with inline why-it-matters / how-it-fires / fix depth) so
|
|
718
919
|
// "Show ALL findings" actually shows them. Add --verbose for full narration + code.
|
|
719
920
|
if (args.flags.firehose && (!format || format === 'ship' || format === 'summary')) {
|
|
720
|
-
|
|
921
|
+
// `provenance` reuses --verbose rather than adding a seventh provenance
|
|
922
|
+
// flag: --verbose already means "print the extra per-finding narration"
|
|
923
|
+
// (explainParts's why/how/fix bodies), and the provenance block is exactly
|
|
924
|
+
// that kind of detail. Before this, explainProvenance()/toCLI's
|
|
925
|
+
// `{provenance}` option had NO production caller at all — it was reachable
|
|
926
|
+
// only from its own unit test, so a feature that was built, reviewed and
|
|
927
|
+
// shipped could never be seen by a user.
|
|
928
|
+
//
|
|
929
|
+
// Gated on provenance actually having run, too: with --no-provenance (or
|
|
930
|
+
// AGENTIC_SECURITY_NO_PROVENANCE=1) every finding carries a `not_available`
|
|
931
|
+
// record, and printing five lines of "we did not look" per finding is noise
|
|
932
|
+
// the operator explicitly opted out of.
|
|
933
|
+
const provenanceOn = !_provFlags.disabled && process.env.AGENTIC_SECURITY_NO_PROVENANCE !== '1';
|
|
934
|
+
body += '\n\n' + toCLI(scan, { verbose, provenance: verbose && provenanceOn });
|
|
721
935
|
}
|
|
722
936
|
|
|
723
937
|
// v3 next-gen — supplementary blocks for human-readable formats. These
|
|
@@ -975,8 +1189,23 @@ async function cmdCi(args) {
|
|
|
975
1189
|
const findings = normalizeFindings(scan);
|
|
976
1190
|
const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
977
1191
|
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
1192
|
+
// FR-206: "reports show both finding count and scan-health status." A
|
|
1193
|
+
// 0-finding scan-exit is not the same claim as "the scan actually
|
|
1194
|
+
// finished cleanly" — an annotator error, a file timeout, or a skipped
|
|
1195
|
+
// analyzer already demotes scan.scanHealth.status to 'partial'
|
|
1196
|
+
// (scan-health.js), but until this line nothing in `ci`'s own printed
|
|
1197
|
+
// report ever surfaced it, so a broken analyzer could pass CI silently.
|
|
1198
|
+
// This is a REPORT fix, not a gate-policy change: whether an incomplete
|
|
1199
|
+
// scan should itself FAIL the build regardless of --fail-on is FR-204's
|
|
1200
|
+
// job (assurance modes advisory/standard/strict), not yet implemented —
|
|
1201
|
+
// deliberately not conflated with this fix.
|
|
1202
|
+
const _health = scan.scanHealth;
|
|
1203
|
+
const _healthLine = (_health && _health.status && _health.status !== 'complete')
|
|
1204
|
+
? `[ci] ⚠ scan-health=${_health.status} — ${(_health.conditions || [])[0] || 'analysis did not complete cleanly'}${(_health.conditions || []).length > 1 ? ` (+${_health.conditions.length - 1} more)` : ''}\n`
|
|
1205
|
+
: `[ci] scan-health=${_health && _health.status ? _health.status : 'unknown'}\n`;
|
|
978
1206
|
process.stderr.write(
|
|
979
1207
|
`[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
|
|
1208
|
+
_healthLine +
|
|
980
1209
|
(_canWriteCi
|
|
981
1210
|
? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
|
|
982
1211
|
: `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
|
|
@@ -1000,6 +1229,23 @@ async function cmdCi(args) {
|
|
|
1000
1229
|
}
|
|
1001
1230
|
process.stderr.write(`[ci] policy gate PASSED (${r.runner}, 0 denials)\n`);
|
|
1002
1231
|
}
|
|
1232
|
+
// FR-204: assurance modes. Runs ALONGSIDE --fail-on and --policy, same
|
|
1233
|
+
// precedent as FR-SDLC-9's policy gate above — any of the three can fail
|
|
1234
|
+
// the build independently. `standard` (the default) never fails here;
|
|
1235
|
+
// `strict` fails the build when the scan itself was not fully complete,
|
|
1236
|
+
// regardless of how few/no findings resulted from the incomplete run.
|
|
1237
|
+
const { evaluateAssuranceMode, ASSURANCE_MODES, DEFAULT_ASSURANCE_MODE } = await import('../src/pipeline/assurance-mode.js');
|
|
1238
|
+
const assuranceMode = args.flags.assurance || DEFAULT_ASSURANCE_MODE;
|
|
1239
|
+
if (!ASSURANCE_MODES.includes(assuranceMode)) {
|
|
1240
|
+
console.error(`[ci] --assurance must be one of: ${ASSURANCE_MODES.join('|')} (got '${assuranceMode}')`);
|
|
1241
|
+
return 1;
|
|
1242
|
+
}
|
|
1243
|
+
const assuranceVerdict = evaluateAssuranceMode(assuranceMode, scan.scanHealth, findings);
|
|
1244
|
+
if (!assuranceVerdict.ok) {
|
|
1245
|
+
console.error(`[ci] assurance gate FAILED (mode=${assuranceMode}): ${assuranceVerdict.reason}`);
|
|
1246
|
+
return 1;
|
|
1247
|
+
}
|
|
1248
|
+
if (assuranceMode === 'strict') process.stderr.write(`[ci] assurance gate PASSED (mode=strict)\n`);
|
|
1003
1249
|
// R24 (PRD §5): PR-native net-new gate. With --fail-on-new and a baseline
|
|
1004
1250
|
// ref, block ONLY on findings this PR INTRODUCED (vs the base ref), never on
|
|
1005
1251
|
// the pre-existing backlog — the posture teams actually leave enabled.
|
|
@@ -1142,6 +1388,17 @@ async function cmdTriage(args) {
|
|
|
1142
1388
|
console.log(` Open: critical=${t.openBySev.critical} high=${t.openBySev.high} medium=${t.openBySev.medium} low=${t.openBySev.low}`);
|
|
1143
1389
|
if (t.medianMttrDays != null) console.log(` MTTR median: ${t.medianMttrDays.toFixed(1)} days`);
|
|
1144
1390
|
console.log(` Total open: ${t.totalOpen}`);
|
|
1391
|
+
// FR-907: longitudinal production feedback — aggregates 5 already-
|
|
1392
|
+
// separate mechanisms (user suppression, accepted risk, invalid
|
|
1393
|
+
// finding, fixed finding, verification outcome) into one view, rather
|
|
1394
|
+
// than requiring an operator to check 5 different files by hand.
|
|
1395
|
+
const { productionFeedbackReport, renderProductionFeedbackSummary } = await import('../src/posture/production-feedback.js');
|
|
1396
|
+
const feedback = productionFeedbackReport(target, { sinceDays: days });
|
|
1397
|
+
const feedbackSummary = renderProductionFeedbackSummary(feedback);
|
|
1398
|
+
if (feedbackSummary) {
|
|
1399
|
+
console.log('');
|
|
1400
|
+
console.log(feedbackSummary);
|
|
1401
|
+
}
|
|
1145
1402
|
return 0;
|
|
1146
1403
|
}
|
|
1147
1404
|
console.error('triage list | assign <id> <assignee> | transition <id> <state> | trend [--since N]');
|
|
@@ -1586,57 +1843,263 @@ async function cmdReset(args) {
|
|
|
1586
1843
|
console.log(`No state to reset at ${stateDirPath}`);
|
|
1587
1844
|
return 0;
|
|
1588
1845
|
}
|
|
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
|
-
]);
|
|
1846
|
+
// FR-703 (assurance-hardening PRD): registry-driven, not enumeration-
|
|
1847
|
+
// driven. The registry (src/posture/artifact-registry.js) is built from an
|
|
1848
|
+
// audit of every state-writing call site in src/ and bin/, not a
|
|
1849
|
+
// hand-maintained list that drifts as new artifacts are added — see that
|
|
1850
|
+
// module's header for the classification rules (generated vs
|
|
1851
|
+
// operator-config) and the corrections it made to this project's own PRD
|
|
1852
|
+
// evidence table along the way.
|
|
1853
|
+
const GENERATED = new Set(listGeneratedArtifacts().map(a => a.name));
|
|
1609
1854
|
const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1855
|
+
// FR-702: `--expired` narrows the reset to only artifacts that are past
|
|
1856
|
+
// their retention-class TTL (cache/scan/evidence/ticket/backup — see
|
|
1857
|
+
// posture/retention-policy.js), rather than every registered generated
|
|
1858
|
+
// artifact. Reuses this same command's existing confirm/keep/preserve
|
|
1859
|
+
// machinery — a second, parallel deletion pathway would be a second
|
|
1860
|
+
// place to get path-safety wrong.
|
|
1861
|
+
let targets;
|
|
1862
|
+
if (args.flags.expired) {
|
|
1863
|
+
const { findExpiredArtifacts } = await import('../src/posture/retention-policy.js');
|
|
1864
|
+
targets = findExpiredArtifacts(scanRoot)
|
|
1865
|
+
.filter(a => !keep.has(a.name))
|
|
1866
|
+
.map(a => ({ name: a.name, dir: a.isDir, ageDays: a.ageDays, ttlDays: a.ttlDays, retentionClass: a.retentionClass }));
|
|
1867
|
+
} else {
|
|
1868
|
+
targets = [];
|
|
1869
|
+
for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
|
|
1870
|
+
if (keep.has(entry.name)) continue;
|
|
1871
|
+
if (GENERATED.has(entry.name)) {
|
|
1872
|
+
targets.push({ name: entry.name, dir: entry.isDirectory() });
|
|
1873
|
+
}
|
|
1615
1874
|
}
|
|
1616
1875
|
}
|
|
1876
|
+
// FR-707: an artifact under an active legal hold is pulled OUT of the
|
|
1877
|
+
// deletion targets regardless of mode — a hold must protect against a
|
|
1878
|
+
// PLAIN `reset --yes` too (which otherwise deletes every registered
|
|
1879
|
+
// 'generated' artifact unconditionally), not just `--expired`, which
|
|
1880
|
+
// retention-policy.js's own findExpiredArtifacts already excludes on its
|
|
1881
|
+
// own as a second, defense-in-depth enforcement of the same guarantee.
|
|
1882
|
+
const { loadLegalHolds, isUnderHold } = await import('../src/posture/legal-hold.js');
|
|
1883
|
+
const holds = loadLegalHolds(scanRoot);
|
|
1884
|
+
const heldDetail = [];
|
|
1885
|
+
targets = targets.filter(t => {
|
|
1886
|
+
const hold = isUnderHold(t.name, holds);
|
|
1887
|
+
if (!hold) return true;
|
|
1888
|
+
heldDetail.push({ name: t.name, dir: !!t.dir, reason: `active legal hold (owner: ${hold.owner}, reason: ${hold.reason})` });
|
|
1889
|
+
return false;
|
|
1890
|
+
});
|
|
1617
1891
|
if (!targets.length) {
|
|
1618
|
-
console.log(`Nothing to reset under ${stateDirPath}.`);
|
|
1892
|
+
console.log(args.flags.expired ? `Nothing expired under ${stateDirPath}.` : `Nothing to reset under ${stateDirPath}.`);
|
|
1893
|
+
if (heldDetail.length) {
|
|
1894
|
+
console.log(`Preserving ${heldDetail.length} artifact(s) under active legal hold: ${heldDetail.map(h => h.name).sort().join(', ')}`);
|
|
1895
|
+
}
|
|
1619
1896
|
return 0;
|
|
1620
1897
|
}
|
|
1621
|
-
console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
|
|
1622
|
-
for (const t of targets)
|
|
1898
|
+
console.log(`agentic-security reset${args.flags.expired ? ' --expired' : ''} — will remove from ${stateDirPath}:`);
|
|
1899
|
+
for (const t of targets) {
|
|
1900
|
+
const ttlNote = args.flags.expired ? ` (${t.retentionClass}, ${t.ageDays.toFixed(1)}d old, ttl ${t.ttlDays}d)` : '';
|
|
1901
|
+
console.log(` ${t.name}${t.dir ? '/' : ''}${ttlNote}`);
|
|
1902
|
+
}
|
|
1623
1903
|
console.log('');
|
|
1624
|
-
|
|
1904
|
+
// FR-703: report what is ACTUALLY present and being left alone, rather
|
|
1905
|
+
// than a hardcoded 5-name string that drifted behind the real config
|
|
1906
|
+
// surface (risk-config.yml, profile.yml, suppressions.yml, and others were
|
|
1907
|
+
// silently omitted from the old message even though they were already
|
|
1908
|
+
// correctly never wiped).
|
|
1909
|
+
const targetNames = new Set(targets.map(t => t.name));
|
|
1910
|
+
const heldNames = new Set(heldDetail.map(h => h.name));
|
|
1911
|
+
const preserved = heldDetail.map(h => h.name + (h.dir ? '/' : ''));
|
|
1912
|
+
const preservedDetail = heldDetail.map(h => ({ name: h.name, reason: h.reason }));
|
|
1913
|
+
for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
|
|
1914
|
+
if (keep.has(entry.name) || targetNames.has(entry.name) || heldNames.has(entry.name)) continue;
|
|
1915
|
+
if (!args.flags.expired && GENERATED.has(entry.name)) continue;
|
|
1916
|
+
preserved.push(entry.name + (entry.isDirectory() ? '/' : ''));
|
|
1917
|
+
preservedDetail.push({
|
|
1918
|
+
name: entry.name,
|
|
1919
|
+
reason: args.flags.expired && GENERATED.has(entry.name)
|
|
1920
|
+
? 'generated artifact still within its retention TTL'
|
|
1921
|
+
: 'operator-authored configuration',
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
if (preserved.length) {
|
|
1925
|
+
console.log(args.flags.expired
|
|
1926
|
+
? `Preserving operator-authored config and generated artifacts still within their TTL: ${preserved.sort().join(', ')}`
|
|
1927
|
+
: `Preserving operator-authored config: ${preserved.sort().join(', ')}`);
|
|
1928
|
+
}
|
|
1929
|
+
// FR-706: every reset invocation — dry-run or applied — leaves a durable,
|
|
1930
|
+
// structured record of what it planned, deleted, preserved, or failed to
|
|
1931
|
+
// delete, not just console output that vanishes once the terminal
|
|
1932
|
+
// scrolls past it.
|
|
1933
|
+
const { buildDeletionReport, writeDeletionReport } = await import('../src/posture/state-lifecycle-report.js');
|
|
1934
|
+
const mode = args.flags.expired ? 'reset --expired' : 'reset';
|
|
1625
1935
|
if (!args.flags.yes) {
|
|
1626
1936
|
console.log('');
|
|
1627
1937
|
console.log('Pass --yes to proceed (or --keep <name,name> to spare specific items).');
|
|
1938
|
+
writeDeletionReport(scanRoot, buildDeletionReport({
|
|
1939
|
+
mode, dryRun: true, root: scanRoot,
|
|
1940
|
+
items: targets.map(t => ({
|
|
1941
|
+
name: t.name, dir: !!t.dir, status: 'planned',
|
|
1942
|
+
retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null,
|
|
1943
|
+
})),
|
|
1944
|
+
preserved: preservedDetail,
|
|
1945
|
+
}));
|
|
1628
1946
|
return 0;
|
|
1629
1947
|
}
|
|
1948
|
+
const outcomes = [];
|
|
1630
1949
|
for (const t of targets) {
|
|
1631
1950
|
const p = path.join(stateDirPath, t.name);
|
|
1951
|
+
const base = { name: t.name, dir: !!t.dir, retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null };
|
|
1632
1952
|
try {
|
|
1633
1953
|
if (t.dir) await fsp.rm(p, { recursive: true, force: true });
|
|
1634
1954
|
else await fsp.rm(p, { force: true });
|
|
1955
|
+
outcomes.push({ ...base, status: 'deleted' });
|
|
1635
1956
|
} catch (e) {
|
|
1636
1957
|
console.error(`reset: failed to remove ${p}: ${e.message}`);
|
|
1958
|
+
outcomes.push({ ...base, status: 'failed', error: e.message });
|
|
1637
1959
|
}
|
|
1638
1960
|
}
|
|
1639
|
-
|
|
1961
|
+
const failedCount = outcomes.filter(o => o.status === 'failed').length;
|
|
1962
|
+
console.log(`Reset ${outcomes.length - failedCount} item(s)${failedCount ? `, ${failedCount} failed` : ''}. Operator-authored config preserved.`);
|
|
1963
|
+
const reportPath = writeDeletionReport(scanRoot, buildDeletionReport({
|
|
1964
|
+
mode, dryRun: false, root: scanRoot, items: outcomes, preserved: preservedDetail,
|
|
1965
|
+
}));
|
|
1966
|
+
if (reportPath) console.log(`Deletion report: ${path.relative(scanRoot, reportPath)}`);
|
|
1967
|
+
return failedCount ? 1 : 0;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// `agentic-security export --out <dir> [--root <path>]`
|
|
1971
|
+
//
|
|
1972
|
+
// FR-706 (assurance-hardening PRD): the "exported" half of "operators can
|
|
1973
|
+
// prove what was exported, deleted, retained, or failed." Copies every
|
|
1974
|
+
// CURRENTLY-PRESENT registered artifact — generated AND operator-config —
|
|
1975
|
+
// from .agentic-security/ into an operator-chosen destination, alongside a
|
|
1976
|
+
// manifest naming exactly what was copied (with a content hash for files)
|
|
1977
|
+
// and what failed. Unlike `reset`, classification does not gate inclusion:
|
|
1978
|
+
// an export is a snapshot for the operator's own records, migration, or
|
|
1979
|
+
// legal-preservation purposes, not a deletion decision.
|
|
1980
|
+
async function cmdExport(args) {
|
|
1981
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1982
|
+
const stateDirPath = stateDir(scanRoot);
|
|
1983
|
+
if (!fs.existsSync(stateDirPath)) {
|
|
1984
|
+
console.log(`No state to export at ${stateDirPath}`);
|
|
1985
|
+
return 0;
|
|
1986
|
+
}
|
|
1987
|
+
if (!args.flags.out) {
|
|
1988
|
+
console.error('export: --out <dir> is required.');
|
|
1989
|
+
return 2;
|
|
1990
|
+
}
|
|
1991
|
+
const outDir = path.resolve(args.flags.out);
|
|
1992
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
1993
|
+
|
|
1994
|
+
const { ARTIFACT_REGISTRY } = await import('../src/posture/artifact-registry.js');
|
|
1995
|
+
const { buildExportReport, writeExportReport } = await import('../src/posture/state-lifecycle-report.js');
|
|
1996
|
+
const { createHash } = await import('node:crypto');
|
|
1997
|
+
const present = new Set((await fsp.readdir(stateDirPath, { withFileTypes: true })).map(e => e.name));
|
|
1998
|
+
|
|
1999
|
+
const items = [];
|
|
2000
|
+
for (const artifact of ARTIFACT_REGISTRY) {
|
|
2001
|
+
if (!present.has(artifact.name)) continue;
|
|
2002
|
+
const src = path.join(stateDirPath, artifact.name);
|
|
2003
|
+
const dest = path.join(outDir, artifact.name);
|
|
2004
|
+
try {
|
|
2005
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
2006
|
+
if (artifact.kind === 'dir') {
|
|
2007
|
+
await fsp.cp(src, dest, { recursive: true });
|
|
2008
|
+
items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256: null });
|
|
2009
|
+
} else {
|
|
2010
|
+
await fsp.copyFile(src, dest);
|
|
2011
|
+
const sha256 = createHash('sha256').update(fs.readFileSync(src)).digest('hex');
|
|
2012
|
+
items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256 });
|
|
2013
|
+
}
|
|
2014
|
+
} catch (e) {
|
|
2015
|
+
items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'failed', error: e.message });
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
const report = buildExportReport({ root: scanRoot, outDir, items });
|
|
2020
|
+
const manifestPath = path.join(outDir, 'export-manifest.json');
|
|
2021
|
+
await fsp.writeFile(manifestPath, JSON.stringify(report, null, 2) + '\n');
|
|
2022
|
+
writeExportReport(scanRoot, report); // last-action record kept under .agentic-security/ too, same as deletion-report.json
|
|
2023
|
+
|
|
2024
|
+
const failedCount = items.filter(i => i.status === 'failed').length;
|
|
2025
|
+
console.log(`Exported ${items.length - failedCount} artifact(s) to ${outDir}${failedCount ? `, ${failedCount} failed` : ''}.`);
|
|
2026
|
+
console.log(`Manifest: ${manifestPath}`);
|
|
2027
|
+
return failedCount ? 1 : 0;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// `agentic-security legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]`
|
|
2031
|
+
// `agentic-security legal-hold remove --artifact <name>`
|
|
2032
|
+
// `agentic-security legal-hold list [--all]`
|
|
2033
|
+
//
|
|
2034
|
+
// FR-707 (assurance-hardening PRD): identity-bound (--owner), reasoned
|
|
2035
|
+
// (--reason), time-bounded where applicable (--expires is optional — an
|
|
2036
|
+
// indefinite hold has no end date), and auditable (`list` reads back
|
|
2037
|
+
// exactly what was recorded). See posture/legal-hold.js for the full
|
|
2038
|
+
// design rationale and how this is enforced inside `cmdReset`.
|
|
2039
|
+
async function cmdLegalHold(args) {
|
|
2040
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2041
|
+
const sub = args._[1];
|
|
2042
|
+
const { addLegalHold, removeLegalHold, listLegalHolds } = await import('../src/posture/legal-hold.js');
|
|
2043
|
+
|
|
2044
|
+
if (sub === 'add') {
|
|
2045
|
+
const result = addLegalHold(scanRoot, {
|
|
2046
|
+
artifact: args.flags.artifact, owner: args.flags.owner, reason: args.flags.reason, expires_at: args.flags.expires,
|
|
2047
|
+
});
|
|
2048
|
+
if (!result.ok) { console.error(`legal-hold add: ${result.reason}`); return 2; }
|
|
2049
|
+
console.log(`Legal hold placed on "${result.hold.artifact}" (owner: ${result.hold.owner}${result.hold.expires_at ? `, expires ${result.hold.expires_at}` : ', indefinite'}).`);
|
|
2050
|
+
return 0;
|
|
2051
|
+
}
|
|
2052
|
+
if (sub === 'remove') {
|
|
2053
|
+
if (!args.flags.artifact) { console.error('legal-hold remove: --artifact <name> is required.'); return 2; }
|
|
2054
|
+
const removedCount = removeLegalHold(scanRoot, args.flags.artifact);
|
|
2055
|
+
console.log(removedCount ? `Removed ${removedCount} hold(s) on "${args.flags.artifact}".` : `No hold found on "${args.flags.artifact}".`);
|
|
2056
|
+
return 0;
|
|
2057
|
+
}
|
|
2058
|
+
if (sub === 'list' || !sub) {
|
|
2059
|
+
const holds = listLegalHolds(scanRoot, { includeExpired: !!args.flags.all });
|
|
2060
|
+
if (!holds.length) { console.log(args.flags.all ? 'No legal holds recorded (ever).' : 'No active legal holds.'); return 0; }
|
|
2061
|
+
for (const h of holds) {
|
|
2062
|
+
console.log(` ${h.artifact} owner=${h.owner} reason="${h.reason}" expires=${h.expires_at || 'never'} created=${h.created_at}`);
|
|
2063
|
+
}
|
|
2064
|
+
return 0;
|
|
2065
|
+
}
|
|
2066
|
+
console.error(`legal-hold: unknown subcommand "${sub}" (expected add|remove|list).`);
|
|
2067
|
+
return 2;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// `agentic-security calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]`
|
|
2071
|
+
// `agentic-security calibration-report`
|
|
2072
|
+
//
|
|
2073
|
+
// FR-806 (assurance-hardening PRD): genuinely opt-in — nothing here is ever
|
|
2074
|
+
// written by a scan. See posture/calibration-feedback.js for the full
|
|
2075
|
+
// scoping rationale (why "aggregated" means within-installation, why the
|
|
2076
|
+
// record snapshots only prediction signals and never file/line/vuln text).
|
|
2077
|
+
async function cmdCalibrationFeedback(args) {
|
|
2078
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2079
|
+
const sub = args._[1];
|
|
2080
|
+
const { recordCalibrationFeedback, OUTCOMES } = await import('../src/posture/calibration-feedback.js');
|
|
2081
|
+
|
|
2082
|
+
if (sub === 'record') {
|
|
2083
|
+
const result = recordCalibrationFeedback(scanRoot, {
|
|
2084
|
+
findingId: args.flags['finding-id'], outcome: args.flags.outcome, note: args.flags.note,
|
|
2085
|
+
});
|
|
2086
|
+
if (!result.ok) { console.error(`calibration-feedback record: ${result.reason}`); return 2; }
|
|
2087
|
+
// The persisted record.findingId is privacy-safe (a hash), not the
|
|
2088
|
+
// caller's own input -- echo back what the operator actually typed.
|
|
2089
|
+
console.log(`Recorded "${result.record.outcome}" for finding ${args.flags['finding-id']}.`);
|
|
2090
|
+
return 0;
|
|
2091
|
+
}
|
|
2092
|
+
console.error(`calibration-feedback: unknown subcommand "${sub}" (expected record --finding-id <id> --outcome ${OUTCOMES.join('|')} [--note <text>]).`);
|
|
2093
|
+
return 2;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
async function cmdCalibrationReport(args) {
|
|
2097
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2098
|
+
const { buildCalibrationReport, renderCalibrationReportSummary } = await import('../src/posture/calibration-feedback.js');
|
|
2099
|
+
const report = buildCalibrationReport(scanRoot);
|
|
2100
|
+
if (args.flags.format === 'json') { writeStdout(JSON.stringify(report, null, 2) + '\n'); return 0; }
|
|
2101
|
+
const summary = renderCalibrationReportSummary(report);
|
|
2102
|
+
console.log(summary || 'No calibration feedback recorded yet — this is opt-in; see `calibration-feedback record --help`.');
|
|
1640
2103
|
return 0;
|
|
1641
2104
|
}
|
|
1642
2105
|
|
|
@@ -1885,6 +2348,53 @@ async function cmdCompliance(args) {
|
|
|
1885
2348
|
|
|
1886
2349
|
async function cmdAttest(args) {
|
|
1887
2350
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2351
|
+
|
|
2352
|
+
if (args.flags.provenance) {
|
|
2353
|
+
const {
|
|
2354
|
+
buildProvenanceEvidenceBundle, signProvenanceEvidenceBundle, ensureKeyPair,
|
|
2355
|
+
} = await import('../src/posture/provenance-evidence-bundle.js');
|
|
2356
|
+
|
|
2357
|
+
let scan;
|
|
2358
|
+
try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
|
|
2359
|
+
catch { console.error('No .agentic-security/last-scan.json — run a scan first.'); return 2; }
|
|
2360
|
+
|
|
2361
|
+
const findings = scan.findings || [];
|
|
2362
|
+
const wanted = args.flags.provenance === true ? undefined : args.flags.provenance;
|
|
2363
|
+
// `--provenance` alone (boolean flag) attests every finding WITH
|
|
2364
|
+
// findingProvenance present; `--provenance <id>` scopes to one.
|
|
2365
|
+
const subset = (wanted ? findings.filter((f) => f.id === wanted || f.stableId === wanted) : findings)
|
|
2366
|
+
.filter((f) => f.findingProvenance);
|
|
2367
|
+
if (!subset.length) {
|
|
2368
|
+
console.error(wanted ? `No finding matching "${wanted}" with findingProvenance.` : 'No findings with findingProvenance to attest.');
|
|
2369
|
+
return 2;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
const kp = ensureKeyPair();
|
|
2373
|
+
if (kp.created) console.error(`Generated a new signing key at ${kp.privateKey} (public: ${kp.publicKey}).`);
|
|
2374
|
+
|
|
2375
|
+
const outDir = statePath(scanRoot, 'attestations');
|
|
2376
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
2377
|
+
// repoIdentity: best-effort, from the same `git remote` lookup other
|
|
2378
|
+
// provenance modules avoid (no such lookup exists yet) — keep it simple,
|
|
2379
|
+
// pass null when unavailable rather than inventing a git-remote reader
|
|
2380
|
+
// here. A future task can enrich this; the field degrades honestly.
|
|
2381
|
+
const meta = { engineVersion: scan.engineVersion || null, repoIdentity: null, head: scan.commit || null };
|
|
2382
|
+
|
|
2383
|
+
let n = 0;
|
|
2384
|
+
for (const f of subset) {
|
|
2385
|
+
const bundle = signProvenanceEvidenceBundle(buildProvenanceEvidenceBundle(f, meta), kp.privateKeyPem);
|
|
2386
|
+
const name = `provenance-${(f.stableId || f.id || `finding-${n}`)}.json`.replace(/[^\w.-]/g, '_');
|
|
2387
|
+
fs.writeFileSync(path.join(outDir, name), JSON.stringify(bundle, null, 2) + '\n');
|
|
2388
|
+
n++;
|
|
2389
|
+
}
|
|
2390
|
+
console.log(`Signed ${n} provenance evidence bundle(s) → ${path.relative(scanRoot, outDir)}/`);
|
|
2391
|
+
console.log(`Public key (share this with whoever verifies): ${kp.publicKey}`);
|
|
2392
|
+
console.log('');
|
|
2393
|
+
console.log('A bundle proves its contents are unmodified since signing. It does NOT');
|
|
2394
|
+
console.log('prove the origin commit is correctly identified — read confidence.level.');
|
|
2395
|
+
return 0;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
1888
2398
|
const {
|
|
1889
2399
|
ensureKeyPair, buildEvidenceBundle, signEvidenceBundle,
|
|
1890
2400
|
} = await import('../src/posture/evidence-bundle.js');
|
|
@@ -1989,8 +2499,15 @@ async function cmdVerifyAttestation(args) {
|
|
|
1989
2499
|
if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
|
|
1990
2500
|
|
|
1991
2501
|
let bundle;
|
|
1992
|
-
try {
|
|
1993
|
-
|
|
2502
|
+
try {
|
|
2503
|
+
const raw = fs.readFileSync(path.resolve(file), 'utf8');
|
|
2504
|
+
// FR-705: transparently decrypt if this is an encrypted artifact (e.g.
|
|
2505
|
+
// an encrypted compliance-evidence.json) — a no-op for any plaintext
|
|
2506
|
+
// file, including every artifact from before encryption was ever
|
|
2507
|
+
// configured.
|
|
2508
|
+
const { maybeDecryptForRead } = await import('../src/posture/encryption-provider.js');
|
|
2509
|
+
bundle = JSON.parse(maybeDecryptForRead(raw));
|
|
2510
|
+
} catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
|
|
1994
2511
|
|
|
1995
2512
|
const runAttestation = _asRunAttestation(bundle);
|
|
1996
2513
|
if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
|
|
@@ -2000,6 +2517,55 @@ async function cmdVerifyAttestation(args) {
|
|
|
2000
2517
|
try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
|
|
2001
2518
|
catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
|
|
2002
2519
|
|
|
2520
|
+
// FR-505: a compliance evidence manifest (@type: ComplianceEvidence) is a
|
|
2521
|
+
// third distinct shape this same command can be handed — auto-detected
|
|
2522
|
+
// the same way run-attestation-vs-finding-bundle already is, rather than
|
|
2523
|
+
// adding a fourth CLI command for what is, from an operator's point of
|
|
2524
|
+
// view, the same question ("is this artifact exactly what was signed").
|
|
2525
|
+
if (bundle['@type'] === 'ComplianceEvidence') {
|
|
2526
|
+
const { verifyComplianceEvidence } = await import('../src/posture/compliance-evidence-signing.js');
|
|
2527
|
+
const cr = verifyComplianceEvidence(bundle, publicKeyPem);
|
|
2528
|
+
if (!cr.ok) { console.error(`✗ INVALID — ${cr.reason}`); return 1; }
|
|
2529
|
+
console.log('✓ VALID — the compliance evidence manifest is exactly what the signer produced.');
|
|
2530
|
+
console.log('');
|
|
2531
|
+
console.log(` framework: ${bundle.framework} version: ${bundle.version}`);
|
|
2532
|
+
console.log(` generated: ${bundle.generatedAt}`);
|
|
2533
|
+
if (bundle.evidenceDigest) console.log(` evidence digest: ${bundle.evidenceDigest}`);
|
|
2534
|
+
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}`);
|
|
2535
|
+
return 0;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// Finding Provenance PRD M4 §4.1: a provenance evidence bundle
|
|
2539
|
+
// (schema: agentic-security/provenance-evidence@1) is a fourth distinct
|
|
2540
|
+
// shape this same command can be handed — same auto-detection chain as
|
|
2541
|
+
// the ComplianceEvidence branch above, dispatched by schema marker
|
|
2542
|
+
// rather than a new CLI verb. Must be checked BEFORE the fallback
|
|
2543
|
+
// verifyEvidenceBundle() call below, which assumes evidence-bundle.js's
|
|
2544
|
+
// own shape (`.evidence`, `.finding` with severity/vuln/file/line) and
|
|
2545
|
+
// would misinterpret a provenance bundle.
|
|
2546
|
+
const { verifyProvenanceEvidenceBundle, PROVENANCE_BUNDLE_SCHEMA } = await import('../src/posture/provenance-evidence-bundle.js');
|
|
2547
|
+
if (bundle.schema === PROVENANCE_BUNDLE_SCHEMA) {
|
|
2548
|
+
const pr = verifyProvenanceEvidenceBundle(bundle, publicKeyPem);
|
|
2549
|
+
if (!pr.ok) { console.error(`✗ INVALID — ${pr.reason}`); return 1; }
|
|
2550
|
+
const p = bundle.provenance || {};
|
|
2551
|
+
console.log('✓ VALID — the provenance record is exactly what the signer attested.');
|
|
2552
|
+
console.log('');
|
|
2553
|
+
console.log(` finding: ${bundle.finding?.stableId || bundle.finding?.id || '?'}`);
|
|
2554
|
+
console.log(` status: ${p.status || 'n/a'} method: ${p.method || 'n/a'}`);
|
|
2555
|
+
// FR-PROV-026: findingOrigin.authorName is untrusted git commit metadata
|
|
2556
|
+
// (this bundle's signature only proves the BUNDLE wasn't tampered with —
|
|
2557
|
+
// it says nothing about what the original commit author put in their
|
|
2558
|
+
// name — see the "Signed, portable evidence" section of the root
|
|
2559
|
+
// CLAUDE.md), printed straight to the terminal by `verify-attestation`.
|
|
2560
|
+
if (p.findingOrigin) console.log(` origin: ${p.findingOrigin.commit || '?'} by ${sanitizeForTerminal(p.findingOrigin.authorName) || '?'} on ${p.findingOrigin.authorDate || '?'}`);
|
|
2561
|
+
console.log(` confidence: ${p.confidence?.level || 'n/a'} (${p.confidence?.score ?? 'n/a'})`);
|
|
2562
|
+
if ((p.limitations || []).length) console.log(` limitations: ${p.limitations.join('; ')}`);
|
|
2563
|
+
console.log('');
|
|
2564
|
+
console.log(` proves: ${bundle.proves}`);
|
|
2565
|
+
console.log(` does NOT prove: ${bundle.doesNotProve}`);
|
|
2566
|
+
return 0;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2003
2569
|
const r = verifyEvidenceBundle(bundle, publicKeyPem);
|
|
2004
2570
|
if (!r.ok) {
|
|
2005
2571
|
console.error(`✗ INVALID — ${r.reason}`);
|
|
@@ -2016,6 +2582,84 @@ async function cmdVerifyAttestation(args) {
|
|
|
2016
2582
|
return 0;
|
|
2017
2583
|
}
|
|
2018
2584
|
|
|
2585
|
+
// FR-1001 (assurance-hardening PRD): "effective policy is explainable."
|
|
2586
|
+
// Loads whichever organization/repository/environment policy bundles exist
|
|
2587
|
+
// under .agentic-security/policy-bundles/, verifies each against an
|
|
2588
|
+
// operator-supplied public key, merges the valid ones (most-specific-wins),
|
|
2589
|
+
// and prints BOTH the effective policy with per-key provenance AND every
|
|
2590
|
+
// rejected bundle's scope and reason — a rejection is reported, never
|
|
2591
|
+
// silently absent, so "tampered or expired policy is rejected" is visible
|
|
2592
|
+
// through this same real command, not just a library-level guarantee.
|
|
2593
|
+
async function cmdPolicyExplain(args) {
|
|
2594
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2595
|
+
const { loadPolicyBundles, loadPolicyPublicKey, resolveEffectivePolicy } = await import('../src/posture/policy-bundle.js');
|
|
2596
|
+
const entries = loadPolicyBundles(scanRoot);
|
|
2597
|
+
if (!entries.length) {
|
|
2598
|
+
console.log('No policy bundles found under .agentic-security/policy-bundles/ (organization.json, repository.json, environment.json).');
|
|
2599
|
+
return 0;
|
|
2600
|
+
}
|
|
2601
|
+
let publicKeyPem = null;
|
|
2602
|
+
const keyFile = args.flags['public-key'];
|
|
2603
|
+
if (keyFile) {
|
|
2604
|
+
try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
|
|
2605
|
+
catch (e) { console.error(`Could not read public key at ${keyFile}: ${e.message}`); return 2; }
|
|
2606
|
+
} else {
|
|
2607
|
+
publicKeyPem = loadPolicyPublicKey(scanRoot);
|
|
2608
|
+
}
|
|
2609
|
+
const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
|
|
2610
|
+
|
|
2611
|
+
console.log(`Accepted (${accepted.length}): ${accepted.join(', ') || 'none'}`);
|
|
2612
|
+
if (rejected.length) {
|
|
2613
|
+
console.log(`Rejected (${rejected.length}):`);
|
|
2614
|
+
for (const r of rejected) console.log(` ✗ ${r.scope}: ${r.reason}`);
|
|
2615
|
+
}
|
|
2616
|
+
console.log('');
|
|
2617
|
+
console.log('Effective policy:');
|
|
2618
|
+
const keys = Object.keys(effective).sort();
|
|
2619
|
+
if (!keys.length) {
|
|
2620
|
+
console.log(' (empty — no accepted bundle contributed any key)');
|
|
2621
|
+
} else {
|
|
2622
|
+
for (const k of keys) console.log(` ${k} = ${JSON.stringify(effective[k])} [from: ${provenance[k]}]`);
|
|
2623
|
+
}
|
|
2624
|
+
return 0;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// FR-1001: the operator-facing signing side, mirroring cmdAttest's own
|
|
2628
|
+
// generate-key-if-absent pattern. Genuinely optional — an org can sign
|
|
2629
|
+
// bundles with any Ed25519 tooling that produces the same canonical bytes
|
|
2630
|
+
// (canonicalPolicyBytes is exported for exactly that interop reason) — but
|
|
2631
|
+
// without SOME real caller for ensurePolicyKeyPair, this codebase's own
|
|
2632
|
+
// dead-module guard is right to flag it: a key-generation function nobody
|
|
2633
|
+
// calls is exactly the kind of code this project's premortems exist to
|
|
2634
|
+
// catch, per posture/CLAUDE.md's dead-module convention.
|
|
2635
|
+
async function cmdPolicySign(args) {
|
|
2636
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2637
|
+
const { ensurePolicyKeyPair, buildPolicyBundle, signPolicyBundle } = await import('../src/posture/policy-bundle.js');
|
|
2638
|
+
const scope = args.flags.scope;
|
|
2639
|
+
if (!['organization', 'repository', 'environment'].includes(scope)) {
|
|
2640
|
+
console.error('Usage: agentic-security policy-sign --scope <organization|repository|environment> --policy <json-file> [--expires <ISO-date>] [--out <path>]');
|
|
2641
|
+
return 2;
|
|
2642
|
+
}
|
|
2643
|
+
const policyFile = args.flags.policy;
|
|
2644
|
+
if (!policyFile) { console.error('--policy <json-file> is required (the policy object to sign).'); return 2; }
|
|
2645
|
+
let policy;
|
|
2646
|
+
try { policy = JSON.parse(fs.readFileSync(path.resolve(policyFile), 'utf8')); }
|
|
2647
|
+
catch (e) { console.error(`Could not read --policy file: ${e.message}`); return 2; }
|
|
2648
|
+
|
|
2649
|
+
const kp = ensurePolicyKeyPair();
|
|
2650
|
+
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.`);
|
|
2651
|
+
|
|
2652
|
+
const bundle = buildPolicyBundle(scope, policy, { expiresAt: args.flags.expires || null });
|
|
2653
|
+
if (!bundle) { console.error('Could not build a bundle — check --scope and that --policy is a JSON object.'); return 2; }
|
|
2654
|
+
const signed = signPolicyBundle(bundle, kp.privateKeyPem);
|
|
2655
|
+
|
|
2656
|
+
const outFile = args.flags.out || `${scope}.json`;
|
|
2657
|
+
fs.writeFileSync(path.resolve(outFile), JSON.stringify(signed, null, 2) + '\n');
|
|
2658
|
+
console.log(`✓ signed ${scope} policy bundle written to ${outFile}`);
|
|
2659
|
+
console.log(` public key for verification: ${kp.publicKey}`);
|
|
2660
|
+
return 0;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2019
2663
|
async function cmdRuleSynth(args) {
|
|
2020
2664
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
2021
2665
|
const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
|
|
@@ -2099,11 +2743,15 @@ async function cmdFix(args) {
|
|
|
2099
2743
|
return 0;
|
|
2100
2744
|
}
|
|
2101
2745
|
|
|
2102
|
-
//
|
|
2103
|
-
//
|
|
2104
|
-
//
|
|
2105
|
-
//
|
|
2106
|
-
|
|
2746
|
+
// FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
|
|
2747
|
+
// before the write — a traversal or symlink-planted f.file was previously
|
|
2748
|
+
// followed unquestioned for both the preview diff and the apply write (no
|
|
2749
|
+
// check existed on this path at all, unlike MCP's apply_fix). Applies to
|
|
2750
|
+
// preview too since both modes read from the same (until now, unconfined)
|
|
2751
|
+
// location.
|
|
2752
|
+
let absFile;
|
|
2753
|
+
try { absFile = confinePath(scanRoot, f.file, 'finding.file'); }
|
|
2754
|
+
catch (e) { console.error(`path-escape refused: ${e.message}`); return 4; }
|
|
2107
2755
|
if (!fs.existsSync(absFile)) { console.error(`File not found: ${absFile}`); return 4; }
|
|
2108
2756
|
const originalContent = await fsp.readFile(absFile, 'utf8');
|
|
2109
2757
|
let newContent = null;
|
|
@@ -2127,16 +2775,56 @@ async function cmdFix(args) {
|
|
|
2127
2775
|
return 0;
|
|
2128
2776
|
}
|
|
2129
2777
|
|
|
2130
|
-
// --apply.
|
|
2131
|
-
//
|
|
2132
|
-
//
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2778
|
+
// --apply. FR-301/FR-302/FR-304 (assurance-hardening PRD): this used to
|
|
2779
|
+
// WARN on failed integrity and apply anyway, and skip verification
|
|
2780
|
+
// entirely (no rescan, no lint) — the same write-time safety gate MCP's
|
|
2781
|
+
// apply_fix already enforces for its caller-patch branch is now required
|
|
2782
|
+
// here too, through the one shared service.
|
|
2783
|
+
if (sigVerified !== true) {
|
|
2784
|
+
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.`);
|
|
2785
|
+
return 4;
|
|
2786
|
+
}
|
|
2787
|
+
// FR-307/D-0024: before this, the CLI had no way to supply approval
|
|
2788
|
+
// evidence at all — a high-impact change (auth/authZ/crypto/PII/schema/
|
|
2789
|
+
// infra-privilege/public-API) was unconditionally refused via --apply
|
|
2790
|
+
// with no path to ever approve it, since fixMeta was never built here.
|
|
2791
|
+
// --approved-by/--approval-reason are optional and no-op for a candidate
|
|
2792
|
+
// that isn't high-impact — only apply-fix-service.js's own gate decides
|
|
2793
|
+
// whether they were needed.
|
|
2794
|
+
const approvedBy = args.flags['approved-by'] || null;
|
|
2795
|
+
const approvalReason = args.flags['approval-reason'] || null;
|
|
2796
|
+
// FR-1003: --author is optional and only has an effect when an operator
|
|
2797
|
+
// has opted into separation-of-duties in authorized-approvers.json — see
|
|
2798
|
+
// approver-registry.js's checkSeparationOfDuties.
|
|
2799
|
+
const patchAuthor = args.flags['author'] || null;
|
|
2800
|
+
const fixMeta = (approvedBy || approvalReason || patchAuthor)
|
|
2801
|
+
? { approval: { approvedBy: approvedBy || '', reason: approvalReason || '' }, ...(patchAuthor ? { author: patchAuthor } : {}) }
|
|
2802
|
+
: null;
|
|
2803
|
+
const result = await applyVerifiedFix({
|
|
2804
|
+
scanRoot,
|
|
2805
|
+
finding: { file: f.file, id: f.id, stableId: f.stableId || null, ruleId: f.cwe || f.title, vuln: f.vuln || f.title },
|
|
2806
|
+
files: { [f.file]: newContent },
|
|
2807
|
+
fixMeta,
|
|
2137
2808
|
});
|
|
2138
|
-
|
|
2809
|
+
if (!result.ok) {
|
|
2810
|
+
console.error(`Refusing to apply: ${result.reason}`);
|
|
2811
|
+
if (result.budgetExceeded) console.error(` (${result.attempts}/${result.maxAttempts} attempts already made for this finding)`);
|
|
2812
|
+
return 4;
|
|
2813
|
+
}
|
|
2814
|
+
const entry = result.written[0];
|
|
2815
|
+
console.log(`✓ applied fix ${entry.historyId} (file: ${entry.file})`);
|
|
2139
2816
|
console.log(` backup: ${entry.backupPath}`);
|
|
2817
|
+
// FR-305: this used to unconditionally print "lint" as part of the
|
|
2818
|
+
// verified line, even when the linter was skipped (not installed) or no
|
|
2819
|
+
// test runner existed — claiming a required leg ran when it did not is
|
|
2820
|
+
// exactly the mislabeling FR-305 exists to prevent. Say plainly when the
|
|
2821
|
+
// pass was degraded and name what was skipped.
|
|
2822
|
+
if (result.verifiedFull) {
|
|
2823
|
+
console.log(' verified: yes — fully verified (rescan clean, no new medium+ finding, lint, tests)');
|
|
2824
|
+
} else {
|
|
2825
|
+
const degraded = (result.verify?.degradedLegs || []).join('; ') || 'a required leg';
|
|
2826
|
+
console.log(` verified: yes, but NOT fully verified — rescan clean, no new medium+ finding; ${degraded}`);
|
|
2827
|
+
}
|
|
2140
2828
|
console.log(` revert with: agentic-security undo`);
|
|
2141
2829
|
return 0;
|
|
2142
2830
|
}
|
|
@@ -2308,10 +2996,16 @@ async function main() {
|
|
|
2308
2996
|
case 'validator-cache': process.exit(await cmdValidatorCache(args));
|
|
2309
2997
|
case 'verify': process.exit(await cmdVerify(args));
|
|
2310
2998
|
case 'reset': process.exit(await cmdReset(args));
|
|
2999
|
+
case 'export': process.exit(await cmdExport(args));
|
|
3000
|
+
case 'legal-hold': process.exit(await cmdLegalHold(args));
|
|
3001
|
+
case 'calibration-feedback': process.exit(await cmdCalibrationFeedback(args));
|
|
3002
|
+
case 'calibration-report': process.exit(await cmdCalibrationReport(args));
|
|
2311
3003
|
case 'hunt': process.exit(await cmdHunt(args));
|
|
2312
3004
|
case 'compliance': process.exit(await cmdCompliance(args));
|
|
2313
3005
|
case 'attest': process.exit(await cmdAttest(args));
|
|
2314
3006
|
case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
|
|
3007
|
+
case 'policy-explain': process.exit(await cmdPolicyExplain(args));
|
|
3008
|
+
case 'policy-sign': process.exit(await cmdPolicySign(args));
|
|
2315
3009
|
case 'rule-synth': process.exit(await cmdRuleSynth(args));
|
|
2316
3010
|
case 'digest': process.exit(await cmdDigest(args));
|
|
2317
3011
|
case 'setup': process.exit(await cmdSetup(args));
|
|
@@ -2517,4 +3211,31 @@ async function main() {
|
|
|
2517
3211
|
}
|
|
2518
3212
|
}
|
|
2519
3213
|
|
|
2520
|
-
|
|
3214
|
+
// Guard against running as the CLI entry point vs. being `import`ed (e.g. by
|
|
3215
|
+
// scanner/test/cli/provenance-flags.test.js, which imports parseProvenanceFlags
|
|
3216
|
+
// for a unit test). Without this, any import of this module — for a single
|
|
3217
|
+
// named export — re-runs the entire CLI dispatch and calls process.exit(),
|
|
3218
|
+
// killing whatever process did the importing.
|
|
3219
|
+
//
|
|
3220
|
+
// `import.meta.url === file://${process.argv[1]}` looks equivalent but is
|
|
3221
|
+
// NOT: when this script is invoked through a symlink (exactly what
|
|
3222
|
+
// `npm install -g`, `npx`, and `node_modules/.bin/<name>` all do for a
|
|
3223
|
+
// package's `bin` entries — which is the documented `npx
|
|
3224
|
+
// @clear-capabilities/agentic-security-scanner` install path), Node
|
|
3225
|
+
// resolves `import.meta.url` to the symlink's realpath while
|
|
3226
|
+
// `process.argv[1]` stays the symlink path as invoked, so the two never
|
|
3227
|
+
// match, the guard is always false, and the CLI silently exits with no
|
|
3228
|
+
// output. `import.meta.main` is resolved correctly through a symlink —
|
|
3229
|
+
// verified live through an actual symlink, not just read about — see the
|
|
3230
|
+
// Task 17 fix report. It was added in Node v24.2.0 (backported to
|
|
3231
|
+
// v22.18.0) and is currently Stability 1.0 (early development) per Node's
|
|
3232
|
+
// own docs — NOT stable, and NOT available on v20.11 as an earlier
|
|
3233
|
+
// version of this comment incorrectly claimed. Concretely: it is
|
|
3234
|
+
// `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
|
|
3235
|
+
// `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
|
|
3236
|
+
// reproduce this exact bug (main() silently never runs) on a plain
|
|
3237
|
+
// non-symlinked invocation under those two point releases. The `??`
|
|
3238
|
+
// fallback below covers that gap without bumping the engines floor.
|
|
3239
|
+
if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
|
|
3240
|
+
main();
|
|
3241
|
+
}
|