@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
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 +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
package/bin/agentic-security.js
CHANGED
|
@@ -14,7 +14,6 @@ import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
|
|
|
14
14
|
import { toPBOM } from '../src/sast/pipeline.js';
|
|
15
15
|
import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
|
|
16
16
|
import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
|
|
17
|
-
import { ingestAndMerge } from '../src/sca/sarif-ingest.js';
|
|
18
17
|
import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
|
|
19
18
|
import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
|
|
20
19
|
import { applyOverrides, validateOverrides, suppressionReport, renderSuppressionSummary } from '../src/posture/rule-overrides.js';
|
|
@@ -28,8 +27,8 @@ import { syncTickets } from '../src/integrations/tickets.js';
|
|
|
28
27
|
import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
|
|
29
28
|
import * as triage from '../src/posture/triage.js';
|
|
30
29
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
31
|
-
import { globFiles } from '../src/util/glob.js';
|
|
32
30
|
|
|
31
|
+
import { stateDir, statePath } from '../src/posture/state-dir.js';
|
|
33
32
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
34
33
|
// so the MCP server tools can share verification.
|
|
35
34
|
function _verifyLastScan(body, sigFile) {
|
|
@@ -65,6 +64,13 @@ Commands:
|
|
|
65
64
|
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
66
65
|
reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
|
|
67
66
|
rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
|
|
67
|
+
compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
|
|
68
|
+
--list show bundled + BYO frameworks
|
|
69
|
+
--walkthrough <id> auditor narrative for any framework
|
|
70
|
+
--gap only the failing controls
|
|
71
|
+
--format cli|json|md (default cli)
|
|
72
|
+
--fail-on gap exit 1 when a control is failing
|
|
73
|
+
Reads .agentic-security/last-scan.json — run a scan first.
|
|
68
74
|
version Print version
|
|
69
75
|
banner [--full] Print the Patch-the-frog mascot + brand lockup
|
|
70
76
|
harness [path] [--include-home] Multi-harness config audit: scans .claude/,
|
|
@@ -103,7 +109,6 @@ Options:
|
|
|
103
109
|
--show-threat-model Append the auto-derived STRIDE threat model summary
|
|
104
110
|
--show-drift Append calibration-drift alarms (overconfidence detection)
|
|
105
111
|
--sca-reachable-only Only SCA findings where the vulnerable function is reachable
|
|
106
|
-
--ingest-sarif <glob> Merge external SARIF into this scan
|
|
107
112
|
--scorecard Enrich components with OSSF Scorecard scores
|
|
108
113
|
--no-network Skip OSV/registry queries (offline mode)
|
|
109
114
|
--pr [ref] Diff-aware: scan only files changed since ref (auto-detects PR base)
|
|
@@ -289,21 +294,24 @@ function _bundleSha() {
|
|
|
289
294
|
return 'unavailable';
|
|
290
295
|
}
|
|
291
296
|
|
|
292
|
-
async function writeMachineOutput(targetAbs, scan, meta, profile) {
|
|
293
|
-
const
|
|
294
|
-
const { isSafeStateDir: _isSafe } = await import('../src/posture/state-dir.js');
|
|
295
|
-
|
|
296
|
-
|
|
297
|
+
async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
|
|
298
|
+
const stateDirPath = stateDir(targetAbs);
|
|
299
|
+
const { isSafeStateDir: _isSafe, stateWritesEnabled: _writesOn } = await import('../src/posture/state-dir.js');
|
|
300
|
+
// Read-only scan (NON_MUTATING_SCAN_PRD S1). These writes bypass
|
|
301
|
+
// safeWriteState because they are async, so the switch is checked here — the
|
|
302
|
+
// same refusal path the project-root check already uses.
|
|
303
|
+
if (!_writesOn() || !_isSafe(stateDirPath)) {
|
|
304
|
+
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write machine output at ${stateDirPath} — no project marker\n`);
|
|
297
305
|
return;
|
|
298
306
|
}
|
|
299
|
-
await fsp.mkdir(
|
|
307
|
+
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
300
308
|
// Always JSON (used by /security-fix and /security-report).
|
|
301
|
-
await fsp.writeFile(path.join(
|
|
309
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
|
|
302
310
|
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
303
|
-
if (profile.profile === 'pro' || profile.machineOutput) {
|
|
304
|
-
await fsp.writeFile(path.join(
|
|
311
|
+
if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
|
|
312
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
|
|
305
313
|
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
306
|
-
await fsp.writeFile(path.join(
|
|
314
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
317
|
|
|
@@ -325,6 +333,11 @@ function parseArgs(argv) {
|
|
|
325
333
|
}
|
|
326
334
|
|
|
327
335
|
async function cmdScan(args) {
|
|
336
|
+
// NON_MUTATING_SCAN_PRD S1 — a scan is an observation; --no-state makes it one.
|
|
337
|
+
if (args.flags['no-state']) {
|
|
338
|
+
const { setStateWritesEnabled } = await import('../src/posture/state-dir.js');
|
|
339
|
+
setStateWritesEnabled(false);
|
|
340
|
+
}
|
|
328
341
|
const target = args._[1] || '.';
|
|
329
342
|
const targetAbs = path.resolve(target);
|
|
330
343
|
// Load persona profile (R1). Persona-aware defaults flow from here.
|
|
@@ -424,13 +437,20 @@ async function cmdScan(args) {
|
|
|
424
437
|
|
|
425
438
|
const only = args.flags.only;
|
|
426
439
|
if (only) {
|
|
440
|
+
// Four channels exist (scanner/CLAUDE.md): findings (SAST), secrets,
|
|
441
|
+
// supplyChain (SCA), logicVulns (business-logic). --only sast keeps
|
|
442
|
+
// logicVulns (business-logic is source analysis, part of the SAST
|
|
443
|
+
// pillar); --only sca/secrets must also clear it — it's neither SCA
|
|
444
|
+
// nor secrets, and normalizeFindings()/exitCodeFor() both fold it in,
|
|
445
|
+
// so leaving it meant a business-logic finding leaked into a
|
|
446
|
+
// single-pillar scan's output AND its exit code.
|
|
427
447
|
if (only === 'sast') { scan.secrets = []; scan.supplyChain = []; }
|
|
428
|
-
if (only === 'sca') { scan.findings = []; scan.secrets = []; }
|
|
429
|
-
if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; }
|
|
448
|
+
if (only === 'sca') { scan.findings = []; scan.secrets = []; scan.logicVulns = []; }
|
|
449
|
+
if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; scan.logicVulns = []; }
|
|
430
450
|
}
|
|
431
451
|
|
|
432
452
|
// --set-baseline: save current findings as baseline for future --since-baseline filtering
|
|
433
|
-
const baselinePath =
|
|
453
|
+
const baselinePath = statePath(target || '.', 'baseline.json');
|
|
434
454
|
if (args.flags['set-baseline']) {
|
|
435
455
|
const { normalizeFindings } = await import('../src/report/index.js');
|
|
436
456
|
const baselineIds = new Set(normalizeFindings(scan).map(f => f.stableId || f.id));
|
|
@@ -478,18 +498,6 @@ async function cmdScan(args) {
|
|
|
478
498
|
// 0.9.0 Feat-18: --scorecard flag enables OSSF Scorecard enrichment
|
|
479
499
|
if (args.flags['scorecard']) process.env.AGENTIC_SECURITY_SCORECARD = '1';
|
|
480
500
|
|
|
481
|
-
// 0.7.0 Feat-7: --ingest-sarif <path-or-glob> merges SARIF from external tools (Semgrep,
|
|
482
|
-
// gitleaks, Bandit, Trivy, Checkov, etc.) into this scan's findings, deduping by
|
|
483
|
-
// fingerprint and tracking provenance via sources[].
|
|
484
|
-
if (args.flags['ingest-sarif']) {
|
|
485
|
-
const glob = args.flags['ingest-sarif'];
|
|
486
|
-
const paths = await globFiles(glob);
|
|
487
|
-
if (paths.length) {
|
|
488
|
-
const r = ingestAndMerge(scan, paths);
|
|
489
|
-
if (process.stderr.isTTY) process.stderr.write(`[ingest] merged ${r.merged} / added ${r.added} findings from ${paths.length} SARIF file(s)\n`);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
501
|
// 0.6.0 Feat-1: --sca-reachable-only filters to only SCA findings where the vulnerable
|
|
494
502
|
// function was confirmed reachable from a route handler.
|
|
495
503
|
if (args.flags['sca-reachable-only']) {
|
|
@@ -550,6 +558,31 @@ async function cmdScan(args) {
|
|
|
550
558
|
try { await enrichWithEPSS(scan); } catch {}
|
|
551
559
|
}
|
|
552
560
|
|
|
561
|
+
// Re-run risk-dollars now that both signals it needs are actually
|
|
562
|
+
// available: EPSS (just enriched above — engine.js's own call ran before
|
|
563
|
+
// this, so every finding's epssScore was still unset) and relevanceTier
|
|
564
|
+
// (posture/relevance.js runs last inside runScan/engine.js, so it's
|
|
565
|
+
// populated on `scan.findings` by the time we get here, but wasn't yet
|
|
566
|
+
// when engine.js's own annotateRiskDollars call ran mid-pipeline).
|
|
567
|
+
try {
|
|
568
|
+
const { annotateRiskDollars } = await import('../src/posture/risk-dollars.js');
|
|
569
|
+
annotateRiskDollars(targetAbs, scan.findings || []);
|
|
570
|
+
} catch {}
|
|
571
|
+
|
|
572
|
+
// Re-run composite risk for the same reason as annotateRiskDollars above:
|
|
573
|
+
// engine.js's mid-pipeline annotateCompositeRisk calls ran before
|
|
574
|
+
// enrichWithEPSS (just above) had a chance to set f.exploitedNow, so
|
|
575
|
+
// every finding's 'exploited-now-floor:75' boost was computed against a
|
|
576
|
+
// field that was always undefined at the time — an actively-exploited
|
|
577
|
+
// CVE (EPSS percentile >= 0.95) never got its compositeRisk score raised.
|
|
578
|
+
// annotateCompositeRisk is pure/idempotent (only sets compositeRisk*
|
|
579
|
+
// fields, reads everything else), so re-running it here is safe.
|
|
580
|
+
try {
|
|
581
|
+
const { annotateCompositeRisk } = await import('../src/posture/composite-risk.js');
|
|
582
|
+
annotateCompositeRisk(scan.findings || []);
|
|
583
|
+
annotateCompositeRisk(scan.supplyChain || []);
|
|
584
|
+
} catch {}
|
|
585
|
+
|
|
553
586
|
// Blast-radius narrative — purely local, always safe to run.
|
|
554
587
|
if (!args.flags['no-blast-radius']) {
|
|
555
588
|
try { enrichWithBlastRadius(scan, targetAbs); } catch {}
|
|
@@ -606,7 +639,7 @@ async function cmdScan(args) {
|
|
|
606
639
|
} catch { /* attestation is metadata; never fail a scan over it */ }
|
|
607
640
|
|
|
608
641
|
// R2: Always emit machine-readable artifacts to .agentic-security/.
|
|
609
|
-
await writeMachineOutput(targetAbs, scan, meta, profile);
|
|
642
|
+
await writeMachineOutput(targetAbs, scan, meta, profile, args);
|
|
610
643
|
|
|
611
644
|
const includeSuppressed = !!args.flags['include-suppressed'];
|
|
612
645
|
let body;
|
|
@@ -646,34 +679,72 @@ async function cmdScan(args) {
|
|
|
646
679
|
else process.stdout.write(body + '\n');
|
|
647
680
|
|
|
648
681
|
// Persist last scan for /security-fix and /security-report
|
|
649
|
-
const { isSafeStateDir: _isSafeStateDir } = await import('../src/posture/state-dir.js');
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
682
|
+
const { isSafeStateDir: _isSafeStateDir, stateWritesEnabled: _writesOnScan } = await import('../src/posture/state-dir.js');
|
|
683
|
+
const stateDirPath = stateDir(path.resolve(target));
|
|
684
|
+
// S7 (Stage-0 audit): declared here, not with `const` inside the `if`
|
|
685
|
+
// below — it used to be block-scoped there while recordScan() further
|
|
686
|
+
// down referenced it unconditionally, throwing a ReferenceError on every
|
|
687
|
+
// single scan (silently swallowed by that call's empty catch{}). streak.json
|
|
688
|
+
// — grades, streak days, achievements — never actually persisted through
|
|
689
|
+
// the real CLI as a result, reproduced live before this fix.
|
|
690
|
+
let persistedScan = null;
|
|
691
|
+
if (_writesOnScan() && _isSafeStateDir(stateDirPath)) {
|
|
692
|
+
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
693
|
+
persistedScan = toJSON(scan, meta);
|
|
654
694
|
// #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
|
|
655
695
|
// every finding carries an age, SLA breaches can be surfaced, and the fix
|
|
656
696
|
// loop can report time-to-clean. Best-effort; skipped under --deterministic
|
|
657
697
|
// so deterministic state stays byte-identical run-to-run.
|
|
658
698
|
if (!args.flags.deterministic) {
|
|
659
699
|
try {
|
|
660
|
-
const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary } = await import('../src/posture/mttr.js');
|
|
700
|
+
const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary, fingerprintFinding, computeMTTR } = await import('../src/posture/mttr.js');
|
|
661
701
|
let baselineMap = new Map();
|
|
702
|
+
let prevAll = [];
|
|
662
703
|
try {
|
|
663
|
-
const prev = JSON.parse(await fsp.readFile(path.join(
|
|
704
|
+
const prev = JSON.parse(await fsp.readFile(path.join(stateDirPath, 'last-scan.json'), 'utf8'));
|
|
664
705
|
baselineMap = buildBaselineMap(prev);
|
|
706
|
+
// The previous scan's own findings already carry firstSeenAt/lastSeenAt
|
|
707
|
+
// from ITS baseline lookup — exactly the shape computeMTTR needs for
|
|
708
|
+
// whichever of them are no longer present now (i.e. were fixed).
|
|
709
|
+
prevAll = [
|
|
710
|
+
...(prev?.findings || []), ...(prev?.secrets || []),
|
|
711
|
+
...(prev?.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
|
|
712
|
+
];
|
|
665
713
|
} catch { /* first run — empty baseline, everything is firstSeen now */ }
|
|
666
714
|
const now = Date.now();
|
|
667
715
|
stampFindingTimestamps(persistedScan.findings || [], baselineMap, now);
|
|
668
716
|
stampFindingTimestamps(persistedScan.secrets || [], baselineMap, now);
|
|
669
717
|
stampFindingTimestamps((persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'), baselineMap, now);
|
|
718
|
+
// #10 — MTTR: which of the previous scan's findings are no longer
|
|
719
|
+
// present now (fixed), and how long each took. computeMTTR itself was
|
|
720
|
+
// fully built and tested but had never had a real caller — the module's
|
|
721
|
+
// own comment calls it "true MTTR," distinct from the open-backlog
|
|
722
|
+
// median-age proxy renderSlaSummary already surfaces below.
|
|
723
|
+
const currentFps = new Set([
|
|
724
|
+
...(persistedScan.findings || []), ...(persistedScan.secrets || []),
|
|
725
|
+
...(persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
|
|
726
|
+
].map(fingerprintFinding));
|
|
727
|
+
const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
|
|
728
|
+
persistedScan.mttr = computeMTTR(removed);
|
|
670
729
|
// Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
|
|
671
730
|
const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
|
|
672
731
|
if (!isJson) {
|
|
673
732
|
const sla = renderSlaSummary(persistedScan.findings || []);
|
|
674
733
|
if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
|
|
734
|
+
if (persistedScan.mttr.count > 0) {
|
|
735
|
+
process.stderr.write(`✅ agentic-security: ${persistedScan.mttr.count} finding(s) fixed since last scan, median ${Math.round(persistedScan.mttr.medianDays)}d to remediate\n`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
} catch (e) {
|
|
739
|
+
// MTTR is best-effort — never block a scan write. But a silent catch
|
|
740
|
+
// here previously hid a real, deterministic CI-only failure (the
|
|
741
|
+
// mttr field went missing on every hosted-CI run, never locally, and
|
|
742
|
+
// nothing explained why) for long enough that it shipped several
|
|
743
|
+
// releases undiagnosed. Surface it — never fail the scan on it.
|
|
744
|
+
if (process.env.AGENTIC_SECURITY_MTTR_DEBUG === '1' || process.env.CI || process.env.GITHUB_ACTIONS) {
|
|
745
|
+
process.stderr.write(`agentic-security: MTTR computation failed (best-effort, scan unaffected): ${(e && e.stack) || e}\n`);
|
|
675
746
|
}
|
|
676
|
-
}
|
|
747
|
+
}
|
|
677
748
|
|
|
678
749
|
// R5 — report the observed time-to-validated-fix distribution from the
|
|
679
750
|
// fix attempts recorded by `verifyFix`. This is measurement, not
|
|
@@ -709,20 +780,27 @@ async function cmdScan(args) {
|
|
|
709
780
|
} catch { /* best-effort, offline-degrading — never block a scan */ }
|
|
710
781
|
}
|
|
711
782
|
const lastScanBody = JSON.stringify(persistedScan, null, 2);
|
|
712
|
-
await fsp.writeFile(path.join(
|
|
783
|
+
await fsp.writeFile(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
|
|
713
784
|
try {
|
|
714
|
-
await fsp.writeFile(path.join(
|
|
785
|
+
await fsp.writeFile(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
|
|
715
786
|
} catch { /* non-fatal — sig file is best-effort */ }
|
|
716
787
|
} else {
|
|
717
|
-
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write state at ${
|
|
788
|
+
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write state at ${stateDirPath} — no project marker in ${path.resolve(target)}\n`);
|
|
718
789
|
}
|
|
719
790
|
|
|
720
791
|
// 0.14.0 — update streak / achievements after every full scan. Suppress
|
|
721
792
|
// streak side effects when the user only wants raw JSON output (CI piping).
|
|
793
|
+
// Gated on `persistedScan !== null` (i.e. the same _writesOnScan() &&
|
|
794
|
+
// _isSafeStateDir() condition above) — recordScan()'s own isSafeStateDir
|
|
795
|
+
// check alone does not know about --no-state / AGENTIC_SECURITY_NO_STATE,
|
|
796
|
+
// so without this a "read-only, mutates nothing" scan would still write
|
|
797
|
+
// streak.json. This was accidentally masked before the persistedScan
|
|
798
|
+
// scoping fix (recordScan was never reached at all, for the wrong reason);
|
|
799
|
+
// fixing that reachability bug required adding this guard explicitly.
|
|
722
800
|
try {
|
|
723
|
-
const streak = recordScan(
|
|
801
|
+
const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
|
|
724
802
|
// Print celebration / streak line to stderr so it doesn't pollute --format json
|
|
725
|
-
if (process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
|
|
803
|
+
if (streak && process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
|
|
726
804
|
const delta = formatGradeDelta(streak);
|
|
727
805
|
const line = formatStreakLine(streak);
|
|
728
806
|
if (delta) process.stderr.write('\n' + delta + '\n');
|
|
@@ -798,20 +876,48 @@ async function cmdCi(args) {
|
|
|
798
876
|
const packNames = packArg ? (Array.isArray(packArg) ? packArg : String(packArg).split(',')) : [];
|
|
799
877
|
if (packNames.length) Object.assign(scan, applyPacks(scan, packNames));
|
|
800
878
|
|
|
801
|
-
//
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
879
|
+
// R4 — same run attestation cmdScan attaches, computed here too so
|
|
880
|
+
// findings.json (the actual CI evidence artifact) carries the same
|
|
881
|
+
// tamper-evidence digest 'agentic-security scan' does, rather than
|
|
882
|
+
// always shipping attestation: null. Runs after every filter above, so
|
|
883
|
+
// it attests what actually ships. Metadata only — never fails the scan.
|
|
884
|
+
try {
|
|
885
|
+
const { computeRunAttestation } = await import('../src/posture/attestation.js');
|
|
886
|
+
const { keyProvenance } = await import('../src/posture/integrity.js');
|
|
887
|
+
const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
|
|
888
|
+
scan.attestation = computeRunAttestation({
|
|
889
|
+
findings: normalizeFindings(scan),
|
|
890
|
+
engineVersion: PKG_VERSION,
|
|
891
|
+
rulesetVersion: effectiveVersion(targetAbs).version,
|
|
892
|
+
bundleSha: _bundleSha(),
|
|
893
|
+
root: targetAbs,
|
|
894
|
+
sign: true,
|
|
895
|
+
});
|
|
896
|
+
if (scan.attestation) scan.attestation.keyProvenance = keyProvenance();
|
|
897
|
+
} catch { /* attestation is metadata; never fail a scan over it */ }
|
|
898
|
+
|
|
899
|
+
// Persist the three CI artifacts — but a refusal to write must NEVER skip
|
|
900
|
+
// the --fail-on evaluation below. S1 (Stage-0 audit, 2026): this used to be
|
|
901
|
+
// a bare `return;` inside the write-guard, so `cmdCi` returned `undefined`
|
|
902
|
+
// and `process.exit(await cmdCi(args))` became `process.exit(undefined)`,
|
|
903
|
+
// which Node treats as exit 0 — a CI pipeline reading only $? would see a
|
|
904
|
+
// passing build whose findings were never evaluated against --fail-on at
|
|
905
|
+
// all. The write and the gate are orthogonal: "we couldn't persist
|
|
906
|
+
// artifacts" is not evidence about "the scan is clean."
|
|
907
|
+
const stateDirPath = stateDir(targetAbs);
|
|
908
|
+
const { isSafeStateDir: _isSafeCi, stateWritesEnabled: _writesOnCi } = await import('../src/posture/state-dir.js');
|
|
909
|
+
const _canWriteCi = _writesOnCi() && _isSafeCi(stateDirPath);
|
|
910
|
+
if (!_canWriteCi) {
|
|
911
|
+
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
|
|
912
|
+
} else {
|
|
913
|
+
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
914
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
|
|
915
|
+
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
916
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
|
|
917
|
+
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
918
|
+
await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
|
|
919
|
+
toJUnit(scan, meta));
|
|
807
920
|
}
|
|
808
|
-
await fsp.mkdir(stateDir, { recursive: true });
|
|
809
|
-
await fsp.writeFile(path.join(stateDir, 'findings.json'),
|
|
810
|
-
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
811
|
-
await fsp.writeFile(path.join(stateDir, 'findings.sarif'),
|
|
812
|
-
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
813
|
-
await fsp.writeFile(path.join(stateDir, 'findings.junit.xml'),
|
|
814
|
-
toJUnit(scan, meta));
|
|
815
921
|
|
|
816
922
|
const scanCode = exitCodeFor(scan);
|
|
817
923
|
const findings = normalizeFindings(scan);
|
|
@@ -819,7 +925,9 @@ async function cmdCi(args) {
|
|
|
819
925
|
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
820
926
|
process.stderr.write(
|
|
821
927
|
`[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
|
|
822
|
-
|
|
928
|
+
(_canWriteCi
|
|
929
|
+
? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
|
|
930
|
+
: `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
|
|
823
931
|
`[ci] fail-on=${failOn} scan-exit=${scanCode}\n`
|
|
824
932
|
);
|
|
825
933
|
// FR-SDLC-9: when --policy <file.rego> is supplied, evaluate against the
|
|
@@ -872,7 +980,7 @@ async function cmdAccept(args) {
|
|
|
872
980
|
const id = args.flags.finding;
|
|
873
981
|
if (!id) { console.error('--finding <id> required'); return 4; }
|
|
874
982
|
const reason = args.flags.reason || 'vibecoded for now';
|
|
875
|
-
const lastScanPath =
|
|
983
|
+
const lastScanPath = statePath(target, 'findings.json');
|
|
876
984
|
if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
|
|
877
985
|
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
878
986
|
const f = (last.findings || []).find(x => x.id === id);
|
|
@@ -930,7 +1038,7 @@ async function cmdTriage(args) {
|
|
|
930
1038
|
}
|
|
931
1039
|
const sub = args._[1];
|
|
932
1040
|
// Sync first so list reflects the latest scan.
|
|
933
|
-
const lastScanPath =
|
|
1041
|
+
const lastScanPath = statePath(target, 'findings.json');
|
|
934
1042
|
if (fs.existsSync(lastScanPath)) {
|
|
935
1043
|
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
936
1044
|
triage.syncWithScan(target, last.findings || []);
|
|
@@ -1256,7 +1364,7 @@ async function cmdRule(args) {
|
|
|
1256
1364
|
async function cmdValidatorCache(args) {
|
|
1257
1365
|
const sub = args._[1] || 'help';
|
|
1258
1366
|
const root = path.resolve(args._[2] || '.');
|
|
1259
|
-
const cacheDir =
|
|
1367
|
+
const cacheDir = statePath(root, 'llm-cache');
|
|
1260
1368
|
if (!fs.existsSync(cacheDir)) {
|
|
1261
1369
|
console.log(`No validator cache at ${cacheDir}`);
|
|
1262
1370
|
return 0;
|
|
@@ -1319,7 +1427,7 @@ async function cmdValidatorCache(args) {
|
|
|
1319
1427
|
// FR-VER-7 fail-closed: any error → cannot-verify, never silent drop.
|
|
1320
1428
|
async function cmdVerify(args) {
|
|
1321
1429
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1322
|
-
const lastScanPath =
|
|
1430
|
+
const lastScanPath = statePath(scanRoot, 'last-scan.json');
|
|
1323
1431
|
if (!fs.existsSync(lastScanPath)) {
|
|
1324
1432
|
console.error(`No prior scan found at ${lastScanPath}. Run \`agentic-security scan\` first.`);
|
|
1325
1433
|
return 4;
|
|
@@ -1421,9 +1529,9 @@ async function cmdVerify(args) {
|
|
|
1421
1529
|
// --yes to skip the confirmation prompt (for scripted use).
|
|
1422
1530
|
async function cmdReset(args) {
|
|
1423
1531
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1424
|
-
const
|
|
1425
|
-
if (!fs.existsSync(
|
|
1426
|
-
console.log(`No state to reset at ${
|
|
1532
|
+
const stateDirPath = stateDir(scanRoot);
|
|
1533
|
+
if (!fs.existsSync(stateDirPath)) {
|
|
1534
|
+
console.log(`No state to reset at ${stateDirPath}`);
|
|
1427
1535
|
return 0;
|
|
1428
1536
|
}
|
|
1429
1537
|
const WIPE = new Set([
|
|
@@ -1448,17 +1556,17 @@ async function cmdReset(args) {
|
|
|
1448
1556
|
]);
|
|
1449
1557
|
const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
|
|
1450
1558
|
const targets = [];
|
|
1451
|
-
for (const entry of await fsp.readdir(
|
|
1559
|
+
for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
|
|
1452
1560
|
if (keep.has(entry.name)) continue;
|
|
1453
1561
|
if (WIPE.has(entry.name) || WIPE_DIRS.has(entry.name)) {
|
|
1454
1562
|
targets.push({ name: entry.name, dir: entry.isDirectory() });
|
|
1455
1563
|
}
|
|
1456
1564
|
}
|
|
1457
1565
|
if (!targets.length) {
|
|
1458
|
-
console.log(`Nothing to reset under ${
|
|
1566
|
+
console.log(`Nothing to reset under ${stateDirPath}.`);
|
|
1459
1567
|
return 0;
|
|
1460
1568
|
}
|
|
1461
|
-
console.log(`agentic-security reset — will remove from ${
|
|
1569
|
+
console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
|
|
1462
1570
|
for (const t of targets) console.log(` ${t.name}${t.dir ? '/' : ''}`);
|
|
1463
1571
|
console.log('');
|
|
1464
1572
|
console.log('Preserving operator-authored config: rules.yml, rules/, license-policy.yml, trusted-keys.json, ruleset-version.json');
|
|
@@ -1468,7 +1576,7 @@ async function cmdReset(args) {
|
|
|
1468
1576
|
return 0;
|
|
1469
1577
|
}
|
|
1470
1578
|
for (const t of targets) {
|
|
1471
|
-
const p = path.join(
|
|
1579
|
+
const p = path.join(stateDirPath, t.name);
|
|
1472
1580
|
try {
|
|
1473
1581
|
if (t.dir) await fsp.rm(p, { recursive: true, force: true });
|
|
1474
1582
|
else await fsp.rm(p, { force: true });
|
|
@@ -1493,7 +1601,7 @@ const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendo
|
|
|
1493
1601
|
const HUNT_MAX_FILES = 2000;
|
|
1494
1602
|
|
|
1495
1603
|
async function cmdHunt(args) {
|
|
1496
|
-
const scanRoot = path.resolve(args.flags.root || args._[
|
|
1604
|
+
const scanRoot = path.resolve(args.flags.root || args._[1] || '.');
|
|
1497
1605
|
const { listFiles } = await import('../src/util/glob.js');
|
|
1498
1606
|
const { buildProjectIR } = await import('../src/ir/index.js');
|
|
1499
1607
|
const { runDiscovery } = await import('../src/discovery/index.js');
|
|
@@ -1517,19 +1625,43 @@ async function cmdHunt(args) {
|
|
|
1517
1625
|
// Prior scan and triage verdicts feed the judge so a hunt does not re-report
|
|
1518
1626
|
// what the rule engine already found or what a human already dismissed.
|
|
1519
1627
|
let priorScan = null, triageFeedback = null;
|
|
1520
|
-
try { priorScan = JSON.parse(fs.readFileSync(
|
|
1521
|
-
try { triageFeedback = JSON.parse(fs.readFileSync(
|
|
1628
|
+
try { priorScan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); } catch {}
|
|
1629
|
+
try { triageFeedback = JSON.parse(fs.readFileSync(statePath(scanRoot, 'triage-feedback.json'), 'utf8')); } catch {}
|
|
1522
1630
|
|
|
1523
1631
|
const lenses = args.flags.lens ? String(args.flags.lens).split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
1632
|
+
const intFlag = (name) => (args.flags[name] ? parseInt(args.flags[name], 10) : undefined);
|
|
1633
|
+
const numFlag = (name) => (args.flags[name] ? Number(args.flags[name]) : undefined);
|
|
1524
1634
|
const report = await runDiscovery(
|
|
1525
1635
|
{ perFileIR: perFile, callGraph, fileContents, priorScan, triageFeedback },
|
|
1526
|
-
{
|
|
1636
|
+
{
|
|
1637
|
+
lenses,
|
|
1638
|
+
maxAreas: intFlag('max-areas'),
|
|
1639
|
+
// PRD Phase 0 / C3. Defaults live in discovery/index.js so the library is
|
|
1640
|
+
// bounded even when driven by something other than this CLI.
|
|
1641
|
+
maxLlmCalls: intFlag('max-llm-calls'),
|
|
1642
|
+
maxWallMs: intFlag('max-wall-ms'),
|
|
1643
|
+
maxCandidates: intFlag('max-candidates'),
|
|
1644
|
+
maxCostUsd: numFlag('max-cost-usd'),
|
|
1645
|
+
// PRD C4 — persist and consult cross-run memory.
|
|
1646
|
+
scanRoot: args.flags['no-memory'] ? undefined : scanRoot,
|
|
1647
|
+
// PRD C2 — a comma-separated endpoint list turns on consensus.
|
|
1648
|
+
endpoints: args.flags.endpoints,
|
|
1649
|
+
costPerCallUsd: numFlag('cost-per-call-usd'),
|
|
1650
|
+
},
|
|
1527
1651
|
);
|
|
1528
1652
|
|
|
1529
1653
|
const c = report.coverage;
|
|
1530
1654
|
console.log('');
|
|
1531
1655
|
console.log(`Discovery — ${rels.length} file(s), ${c.areasPlanned} focus area(s), ${c.lensesPerArea} lens(es) each`);
|
|
1532
1656
|
console.log(` areas hunted: ${c.areasHunted}/${c.areasPlanned} (fully: ${c.areasFullyHunted}) degraded runs: ${c.degradedRuns}/${report.runs.length}`);
|
|
1657
|
+
console.log(` budget: ${c.llmCalls}/${c.maxLlmCalls} LLM calls${c.budgetExhausted ? ' ← EXHAUSTED, run is incomplete' : ''}` +
|
|
1658
|
+
`${c.candidatesCapped ? ` ${c.candidatesCapped} candidate(s) capped` : ''}` +
|
|
1659
|
+
`${c.callsPerFinding !== null && c.callsPerFinding !== undefined ? ` ${c.callsPerFinding} calls/finding` : ''}`);
|
|
1660
|
+
if (c.priorRuns !== null && c.priorRuns !== undefined) {
|
|
1661
|
+
console.log(` memory: ${c.priorRuns} prior run(s)` +
|
|
1662
|
+
`${c.rememberedRefutals ? `, ${c.rememberedRefutals} candidate(s) skipped as already refuted` : ''}`);
|
|
1663
|
+
if (c.nextWave?.summary) console.log(` next wave: ${c.nextWave.summary}`);
|
|
1664
|
+
}
|
|
1533
1665
|
console.log(` confirmation: ${JSON.stringify(c.confirmedByTier)} panels: ${c.panelsRun} (undecided ${c.undecidedPanels})`);
|
|
1534
1666
|
console.log('');
|
|
1535
1667
|
|
|
@@ -1548,6 +1680,23 @@ async function cmdHunt(args) {
|
|
|
1548
1680
|
`${report.refutedCandidates.length} refuted by the panel)`);
|
|
1549
1681
|
}
|
|
1550
1682
|
|
|
1683
|
+
// PRD C5 — variant analysis. A confirmed finding is rarely alone: the same
|
|
1684
|
+
// root cause is usually copy-pasted, and the siblings often do not trip a
|
|
1685
|
+
// rule. sweepRootCauses already does this with exact accounting for scan
|
|
1686
|
+
// findings; discovery findings simply never reached it until now.
|
|
1687
|
+
if (report.fresh.length && !args.flags['no-variants']) {
|
|
1688
|
+
try {
|
|
1689
|
+
const { sweepRootCauses, formatSweepLedger } = await import('../src/posture/root-cause-sweep.js');
|
|
1690
|
+
const sweep = sweepRootCauses(report.fresh, fileContents, { confirmedOnly: false });
|
|
1691
|
+
const ledger = formatSweepLedger(sweep);
|
|
1692
|
+
if (ledger) {
|
|
1693
|
+
console.log('');
|
|
1694
|
+
console.log('Variant sweep (siblings of the findings above):');
|
|
1695
|
+
console.log(` ${ledger}`);
|
|
1696
|
+
}
|
|
1697
|
+
} catch { /* the sweep is additive; never let it sink the report */ }
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1551
1700
|
// Degradation is part of the output. A half-failed hunt must never read as a
|
|
1552
1701
|
// clean one, so every reason is printed rather than summarised away.
|
|
1553
1702
|
if (c.reasons.length) {
|
|
@@ -1567,6 +1716,228 @@ async function cmdHunt(args) {
|
|
|
1567
1716
|
return 0;
|
|
1568
1717
|
}
|
|
1569
1718
|
|
|
1719
|
+
// PRD D2 — emit signed, per-finding evidence bundles, and verify them.
|
|
1720
|
+
//
|
|
1721
|
+
// `attest` needs our private key. `verify-attestation` needs ONLY a public key,
|
|
1722
|
+
// which is the entire point: a buyer or auditor checks the artefact without ever
|
|
1723
|
+
// having had access to anything of ours.
|
|
1724
|
+
/**
|
|
1725
|
+
* `compliance` — framework assessment from the CLI.
|
|
1726
|
+
*
|
|
1727
|
+
* Reads the LAST SCAN rather than running a new one. A compliance answer is a
|
|
1728
|
+
* statement about a scan that happened, and silently re-scanning here would
|
|
1729
|
+
* make the number depend on when you asked rather than on what was measured —
|
|
1730
|
+
* the same class of confusion as a benchmark that mutates its own corpus. If
|
|
1731
|
+
* there is no scan to read, this says so and exits non-zero instead of
|
|
1732
|
+
* assessing an empty project, which would report controls as satisfied on the
|
|
1733
|
+
* strength of having looked at nothing.
|
|
1734
|
+
*/
|
|
1735
|
+
async function cmdCompliance(args) {
|
|
1736
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1737
|
+
const fmt = String(args.flags.format || 'cli');
|
|
1738
|
+
const {
|
|
1739
|
+
assessPrivacyFramework, PRIVACY_FRAMEWORK_ID, BUCKETS,
|
|
1740
|
+
} = await import('../src/posture/privacy-framework.js');
|
|
1741
|
+
const { listFrameworks, loadFramework, evaluateFramework, renderWalkthrough } =
|
|
1742
|
+
await import('../src/posture/auditor-walkthrough.js');
|
|
1743
|
+
|
|
1744
|
+
if (args.flags.list) {
|
|
1745
|
+
const fws = listFrameworks(scanRoot);
|
|
1746
|
+
if (fmt === 'json') { console.log(JSON.stringify(fws, null, 2)); return 0; }
|
|
1747
|
+
for (const f of fws) console.log(` ${f.id.padEnd(20)} ${f.name} [${f.source}]`);
|
|
1748
|
+
return 0;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
let scan;
|
|
1752
|
+
try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
|
|
1753
|
+
catch {
|
|
1754
|
+
console.error('No .agentic-security/last-scan.json — run `agentic-security scan .` first.');
|
|
1755
|
+
console.error('Refusing to assess an unscanned project: every control would report as');
|
|
1756
|
+
console.error('unassessed, which is correct but useless, and one flag away from looking clean.');
|
|
1757
|
+
return 2;
|
|
1758
|
+
}
|
|
1759
|
+
// The engine records this two ways depending on the emit path; take either.
|
|
1760
|
+
scan.filesScanned = scan._scanMeta?.filesScanned ?? scan.scanned?.files ?? 0;
|
|
1761
|
+
|
|
1762
|
+
const wt = args.flags.walkthrough;
|
|
1763
|
+
if (wt && wt !== true) {
|
|
1764
|
+
const fw = loadFramework(scanRoot, String(wt));
|
|
1765
|
+
if (!fw) {
|
|
1766
|
+
console.error(`Unknown framework "${wt}". Try --list.`);
|
|
1767
|
+
return 2;
|
|
1768
|
+
}
|
|
1769
|
+
console.log(renderWalkthrough(fw, evaluateFramework(scanRoot, fw, scan), {}));
|
|
1770
|
+
return 0;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// Default mode is --privacy: it is the only framework with a remediation
|
|
1774
|
+
// layer, so it is the only one where a CLI exit code means anything.
|
|
1775
|
+
const r = assessPrivacyFramework(scanRoot, scan);
|
|
1776
|
+
if (!r) { console.error(`Framework ${PRIVACY_FRAMEWORK_ID} could not be loaded.`); return 2; }
|
|
1777
|
+
|
|
1778
|
+
const gapsOnly = !!args.flags.gap;
|
|
1779
|
+
if (fmt === 'json') {
|
|
1780
|
+
console.log(JSON.stringify(gapsOnly ? { ...r, controls: r.controls.filter(c => c.bucket === 'gap') } : r, null, 2));
|
|
1781
|
+
} else if (fmt === 'md') {
|
|
1782
|
+
console.log(fs.readFileSync(statePath(scanRoot, 'privacy-framework.md'), 'utf8'));
|
|
1783
|
+
} else {
|
|
1784
|
+
console.log(`\n${r.frameworkName}\n`);
|
|
1785
|
+
console.log(` ${r.interpretation}\n`);
|
|
1786
|
+
for (const b of BUCKETS) {
|
|
1787
|
+
const rows = r.controls.filter(c => c.bucket === b);
|
|
1788
|
+
if (!rows.length || (gapsOnly && b !== 'gap')) continue;
|
|
1789
|
+
console.log(` ${b} (${rows.length})`);
|
|
1790
|
+
for (const c of rows) {
|
|
1791
|
+
console.log(` ${c.id.padEnd(11)} ${c.summary.slice(0, 84)}`);
|
|
1792
|
+
const f = r.findings.find(x => x.id === `privacy-framework:${c.id}`);
|
|
1793
|
+
if (f) console.log(` → ${f.remediation}`);
|
|
1794
|
+
}
|
|
1795
|
+
console.log('');
|
|
1796
|
+
}
|
|
1797
|
+
console.log(' A control that is "manual" or "engine-gap" is NOT evidence of compliance.');
|
|
1798
|
+
console.log(' This organizes scanner evidence; a licensed assessor owns the attestation.\n');
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// --fail-on gap is opt-in. A compliance opinion becomes a build failure only
|
|
1802
|
+
// when someone asks for it; defaulting to non-zero would break every pipeline
|
|
1803
|
+
// that adds this command to see the report.
|
|
1804
|
+
if (args.flags['fail-on'] === 'gap' && r.summary.gap > 0) return 1;
|
|
1805
|
+
return 0;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
async function cmdAttest(args) {
|
|
1809
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1810
|
+
const {
|
|
1811
|
+
ensureKeyPair, buildEvidenceBundle, signEvidenceBundle,
|
|
1812
|
+
} = await import('../src/posture/evidence-bundle.js');
|
|
1813
|
+
|
|
1814
|
+
let scan;
|
|
1815
|
+
try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
|
|
1816
|
+
catch { console.error('No .agentic-security/last-scan.json — run a scan first.'); return 2; }
|
|
1817
|
+
|
|
1818
|
+
const findings = scan.findings || [];
|
|
1819
|
+
const wanted = args.flags.id;
|
|
1820
|
+
const subset = wanted ? findings.filter(f => f.id === wanted || f.stableId === wanted) : findings;
|
|
1821
|
+
if (!subset.length) {
|
|
1822
|
+
console.error(wanted ? `No finding matching "${wanted}".` : 'No findings to attest.');
|
|
1823
|
+
return 2;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const kp = ensureKeyPair();
|
|
1827
|
+
if (kp.created) console.error(`Generated a new signing key at ${kp.privateKey} (public: ${kp.publicKey}).`);
|
|
1828
|
+
|
|
1829
|
+
const outDir = statePath(scanRoot, 'attestations');
|
|
1830
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1831
|
+
const meta = {
|
|
1832
|
+
engineVersion: scan.engineVersion || null,
|
|
1833
|
+
rulesetVersion: scan.rulesetVersion || null,
|
|
1834
|
+
bundleSha: scan.bundleSha || null,
|
|
1835
|
+
commit: scan.commit || null,
|
|
1836
|
+
};
|
|
1837
|
+
|
|
1838
|
+
let n = 0;
|
|
1839
|
+
for (const f of subset) {
|
|
1840
|
+
const bundle = signEvidenceBundle(buildEvidenceBundle(f, meta), kp.privateKeyPem);
|
|
1841
|
+
const name = `${(f.stableId || f.id || `finding-${n}`)}.json`.replace(/[^\w.-]/g, '_');
|
|
1842
|
+
fs.writeFileSync(path.join(outDir, name), JSON.stringify(bundle, null, 2) + '\n');
|
|
1843
|
+
n++;
|
|
1844
|
+
}
|
|
1845
|
+
console.log(`Signed ${n} evidence bundle(s) → ${path.relative(scanRoot, outDir)}/`);
|
|
1846
|
+
console.log(`Public key (share this with whoever verifies): ${kp.publicKey}`);
|
|
1847
|
+
console.log('');
|
|
1848
|
+
console.log('A bundle proves its contents are unmodified since signing. It does NOT');
|
|
1849
|
+
console.log('prove the finding is real — read evidence.proofTier for that.');
|
|
1850
|
+
return 0;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// R4/D2 correctness follow-up: attestation.js ships TWO independent
|
|
1854
|
+
// verifiers — verifyEvidenceBundle (per-finding, Ed25519, self-contained —
|
|
1855
|
+
// only needs a public key) and verifyRunAttestation (whole-run, per-install
|
|
1856
|
+
// HMAC, NOT self-contained — needs the caller to also supply the finding
|
|
1857
|
+
// set to re-derive against). This CLI command only ever called the first;
|
|
1858
|
+
// the second had zero production callers anywhere in the repo (confirmed by
|
|
1859
|
+
// grep — only test/attestation.test.js called it). Auto-detect which
|
|
1860
|
+
// artifact was handed in and dispatch to the matching verifier instead of
|
|
1861
|
+
// silently misinterpreting a run-attestation as a (structurally different)
|
|
1862
|
+
// evidence bundle.
|
|
1863
|
+
function _asRunAttestation(obj) {
|
|
1864
|
+
if (obj && typeof obj.digest === 'string' && obj.canonicalisation) return obj;
|
|
1865
|
+
if (obj && obj.attestation && typeof obj.attestation.digest === 'string') return obj.attestation;
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Verifies a run attestation the ONLY way it can be verified: by re-deriving
|
|
1870
|
+
// the digest from a fresh scan and comparing. Unlike an evidence bundle
|
|
1871
|
+
// (self-contained, just needs a public key), a run attestation is a claim
|
|
1872
|
+
// about a FINDING SET, not a standalone signed blob — "does scanning this
|
|
1873
|
+
// codebase right now reproduce the exact digest an earlier run attested"
|
|
1874
|
+
// is the actual, meaningful question this command answers.
|
|
1875
|
+
async function cmdVerifyRunAttestation(attestation, args) {
|
|
1876
|
+
const projectPath = path.resolve(args.flags.against || '.');
|
|
1877
|
+
if (!fs.existsSync(projectPath)) {
|
|
1878
|
+
console.error(`--against path does not exist: ${projectPath}`);
|
|
1879
|
+
return 2;
|
|
1880
|
+
}
|
|
1881
|
+
console.log(`Re-scanning ${projectPath} to check it reproduces the attested digest...`);
|
|
1882
|
+
const { runScan } = await import('../src/runScan.js');
|
|
1883
|
+
const { normalizeFindings } = await import('../src/report/index.js');
|
|
1884
|
+
const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
|
|
1885
|
+
const { verifyRunAttestation } = await import('../src/posture/attestation.js');
|
|
1886
|
+
const { scan } = await runScan(projectPath);
|
|
1887
|
+
const r = verifyRunAttestation(attestation, {
|
|
1888
|
+
findings: normalizeFindings(scan),
|
|
1889
|
+
engineVersion: PKG_VERSION,
|
|
1890
|
+
rulesetVersion: effectiveVersion(projectPath).version,
|
|
1891
|
+
bundleSha: _bundleSha(),
|
|
1892
|
+
root: projectPath,
|
|
1893
|
+
});
|
|
1894
|
+
if (!r.ok) {
|
|
1895
|
+
console.error(`✗ INVALID — ${r.reason}`);
|
|
1896
|
+
return 1;
|
|
1897
|
+
}
|
|
1898
|
+
console.log('✓ VALID — a fresh scan of this project reproduces the attested digest exactly.');
|
|
1899
|
+
console.log('');
|
|
1900
|
+
console.log(` digest: ${attestation.digest}`);
|
|
1901
|
+
console.log(` findingCount: ${attestation.findingCount}`);
|
|
1902
|
+
console.log(` proves: ${attestation.proves}`);
|
|
1903
|
+
console.log(` does NOT prove: ${attestation.doesNotProve}`);
|
|
1904
|
+
return 0;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
async function cmdVerifyAttestation(args) {
|
|
1908
|
+
const { verifyEvidenceBundle, keyPaths } = await import('../src/posture/evidence-bundle.js');
|
|
1909
|
+
// `args._[0]` is the command name itself — the same convention cmdScan uses.
|
|
1910
|
+
const file = args.flags.bundle || args._[1];
|
|
1911
|
+
if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
|
|
1912
|
+
|
|
1913
|
+
let bundle;
|
|
1914
|
+
try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
|
|
1915
|
+
catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
|
|
1916
|
+
|
|
1917
|
+
const runAttestation = _asRunAttestation(bundle);
|
|
1918
|
+
if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
|
|
1919
|
+
|
|
1920
|
+
const keyFile = args.flags['public-key'] || keyPaths().publicKey;
|
|
1921
|
+
let publicKeyPem = null;
|
|
1922
|
+
try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
|
|
1923
|
+
catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
|
|
1924
|
+
|
|
1925
|
+
const r = verifyEvidenceBundle(bundle, publicKeyPem);
|
|
1926
|
+
if (!r.ok) {
|
|
1927
|
+
console.error(`✗ INVALID — ${r.reason}`);
|
|
1928
|
+
return 1;
|
|
1929
|
+
}
|
|
1930
|
+
const f = bundle.finding || {};
|
|
1931
|
+
console.log('✓ VALID — the bundle is exactly what the signer attested.');
|
|
1932
|
+
console.log('');
|
|
1933
|
+
console.log(` ${f.severity ? `[${f.severity}] ` : ''}${f.vuln || '(no title)'} ${f.file || '?'}:${f.line ?? '?'}`);
|
|
1934
|
+
console.log(` cwe ${f.cwe || 'n/a'} parser ${f.parser || 'n/a'} proof tier ${bundle.evidence?.proofTier || 'n/a'}`);
|
|
1935
|
+
console.log('');
|
|
1936
|
+
console.log(` proves: ${bundle.proves}`);
|
|
1937
|
+
console.log(` does NOT prove: ${bundle.doesNotProve}`);
|
|
1938
|
+
return 0;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1570
1941
|
async function cmdRuleSynth(args) {
|
|
1571
1942
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1572
1943
|
const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
|
|
@@ -1603,7 +1974,7 @@ async function cmdPacks(args) {
|
|
|
1603
1974
|
async function cmdDigest(args) {
|
|
1604
1975
|
const target = path.resolve(args._[1] || '.');
|
|
1605
1976
|
const profile = loadProfile(target);
|
|
1606
|
-
const lastScanPath =
|
|
1977
|
+
const lastScanPath = statePath(target, 'findings.json');
|
|
1607
1978
|
if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found.'); return 4; }
|
|
1608
1979
|
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
1609
1980
|
const findings = (last.findings || []).filter(f => f.severity === 'critical' || f.severity === 'high');
|
|
@@ -1631,7 +2002,7 @@ async function cmdFix(args) {
|
|
|
1631
2002
|
const isApply = !!args.flags.apply;
|
|
1632
2003
|
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1633
2004
|
if (!id) { console.error('--finding <id> required'); return 4; }
|
|
1634
|
-
const lastScanPath =
|
|
2005
|
+
const lastScanPath = statePath(scanRoot, 'last-scan.json');
|
|
1635
2006
|
if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
|
|
1636
2007
|
const lastScanBody = await fsp.readFile(lastScanPath, 'utf8');
|
|
1637
2008
|
const sigVerified = _verifyLastScan(lastScanBody, lastScanPath + '.sig');
|
|
@@ -1860,6 +2231,9 @@ async function main() {
|
|
|
1860
2231
|
case 'verify': process.exit(await cmdVerify(args));
|
|
1861
2232
|
case 'reset': process.exit(await cmdReset(args));
|
|
1862
2233
|
case 'hunt': process.exit(await cmdHunt(args));
|
|
2234
|
+
case 'compliance': process.exit(await cmdCompliance(args));
|
|
2235
|
+
case 'attest': process.exit(await cmdAttest(args));
|
|
2236
|
+
case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
|
|
1863
2237
|
case 'rule-synth': process.exit(await cmdRuleSynth(args));
|
|
1864
2238
|
case 'digest': process.exit(await cmdDigest(args));
|
|
1865
2239
|
case 'setup': process.exit(await cmdSetup(args));
|
|
@@ -1882,7 +2256,7 @@ async function main() {
|
|
|
1882
2256
|
const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
|
|
1883
2257
|
if (result.ok) {
|
|
1884
2258
|
try {
|
|
1885
|
-
const dir =
|
|
2259
|
+
const dir = stateDir(projectDir);
|
|
1886
2260
|
fs.mkdirSync(dir, { recursive: true });
|
|
1887
2261
|
fs.writeFileSync(path.join(dir, 'cache-telemetry.json'),
|
|
1888
2262
|
JSON.stringify({ updatedAt: new Date().toISOString(), metrics: result.metrics, leaks: result.leaks }, null, 2));
|