@clear-capabilities/agentic-security-scanner 0.74.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 +1580 -0
- package/bin/.agentic-security/findings.json +1577 -0
- package/bin/.agentic-security/last-scan.json +1577 -0
- package/bin/.agentic-security/last-scan.json.sig +1 -0
- package/bin/.agentic-security/scan-history.json +465 -0
- package/bin/.agentic-security/streak.json +25 -0
- package/bin/agentic-security-audit.js +198 -0
- package/bin/agentic-security-consistency.js +80 -0
- package/bin/agentic-security-diff.js +136 -0
- package/bin/agentic-security-lsp.js +12 -0
- package/bin/agentic-security-mcp.js +40 -0
- package/bin/agentic-security-rule.js +153 -0
- package/bin/agentic-security.js +1683 -0
- package/dist/117.index.js +207 -0
- package/dist/178.index.js +250 -0
- package/dist/218.index.js +793 -0
- package/dist/227.index.js +192 -0
- package/dist/301.index.js +167 -0
- package/dist/384.index.js +18 -0
- package/dist/476.index.js +126 -0
- package/dist/513.index.js +373 -0
- package/dist/520.index.js +13 -0
- package/dist/601.index.js +1038 -0
- package/dist/634.index.js +1892 -0
- package/dist/637.index.js +216 -0
- package/dist/660.index.js +131 -0
- package/dist/675.index.js +451 -0
- package/dist/826.index.js +188 -0
- package/dist/830.index.js +133 -0
- package/dist/agentic-security.mjs +272 -0
- package/dist/agentic-security.mjs.sha256 +1 -0
- package/dist/calibration-seed.json +27 -0
- package/package.json +77 -0
- package/src/.agentic-security/findings.json +80844 -0
- package/src/.agentic-security/last-scan.json +80844 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +8408 -0
- package/src/.agentic-security/streak.json +26 -0
- package/src/badge.js +188 -0
- package/src/compare.js +203 -0
- package/src/dataflow/.agentic-security/findings.json +3487 -0
- package/src/dataflow/.agentic-security/last-scan.json +3487 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +735 -0
- package/src/dataflow/.agentic-security/streak.json +24 -0
- package/src/dataflow/CLAUDE.md +38 -0
- package/src/dataflow/access-paths.js +172 -0
- package/src/dataflow/async-sequencing.js +177 -0
- package/src/dataflow/backward.js +201 -0
- package/src/dataflow/catalog-expanded.js +485 -0
- package/src/dataflow/catalog.js +659 -0
- package/src/dataflow/cross-repo.js +219 -0
- package/src/dataflow/engine.js +588 -0
- package/src/dataflow/exception-flow.js +116 -0
- package/src/dataflow/exploit-prover.js +187 -0
- package/src/dataflow/higher-order.js +221 -0
- package/src/dataflow/ifds.js +347 -0
- package/src/dataflow/implicit-flow.js +129 -0
- package/src/dataflow/incremental.js +229 -0
- package/src/dataflow/index.js +181 -0
- package/src/dataflow/numeric-domain.js +192 -0
- package/src/dataflow/path-feasibility.js +114 -0
- package/src/dataflow/points-to.js +337 -0
- package/src/dataflow/polyglot.js +190 -0
- package/src/dataflow/proven-clean.js +159 -0
- package/src/dataflow/receiver-context.js +76 -0
- package/src/dataflow/sanitizer-proof.js +154 -0
- package/src/dataflow/soft-taint.js +140 -0
- package/src/dataflow/string-domain.js +234 -0
- package/src/dataflow/stub-aware-filter.js +100 -0
- package/src/dataflow/summaries.js +132 -0
- package/src/dataflow/symbolic-exec.js +238 -0
- package/src/dataflow/tabulation.js +135 -0
- package/src/engine.js +7763 -0
- package/src/history-scan.js +229 -0
- package/src/index.js +3 -0
- package/src/integrations/.agentic-security/findings.json +1504 -0
- package/src/integrations/.agentic-security/last-scan.json +1504 -0
- package/src/integrations/.agentic-security/scan-history.json +40 -0
- package/src/integrations/.agentic-security/streak.json +21 -0
- package/src/integrations/index.js +321 -0
- package/src/integrations/tickets.js +200 -0
- package/src/ir/.agentic-security/findings.json +3036 -0
- package/src/ir/.agentic-security/last-scan.json +3036 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +364 -0
- package/src/ir/.agentic-security/streak.json +23 -0
- package/src/ir/CLAUDE.md +172 -0
- package/src/ir/callgraph.js +73 -0
- package/src/ir/class-hierarchy.js +195 -0
- package/src/ir/index.js +152 -0
- package/src/ir/parser-cs.js +260 -0
- package/src/ir/parser-java.js +286 -0
- package/src/ir/parser-js.js +413 -0
- package/src/ir/parser-kt.js +258 -0
- package/src/ir/parser-py-cst.js +136 -0
- package/src/ir/parser-py.helper.py +501 -0
- package/src/ir/parser-py.js +312 -0
- package/src/ir/ssa.js +315 -0
- package/src/ir/type-stubs.js +288 -0
- package/src/leaderboard.js +152 -0
- package/src/llm-validator/.agentic-security/findings.json +1891 -0
- package/src/llm-validator/.agentic-security/last-scan.json +1891 -0
- package/src/llm-validator/.agentic-security/last-scan.json.sig +1 -0
- package/src/llm-validator/.agentic-security/scan-history.json +168 -0
- package/src/llm-validator/.agentic-security/streak.json +20 -0
- package/src/llm-validator/consistency.js +141 -0
- package/src/llm-validator/index.js +437 -0
- package/src/lsp/.agentic-security/findings.json +28 -0
- package/src/lsp/.agentic-security/last-scan.json +28 -0
- package/src/lsp/.agentic-security/scan-history.json +79 -0
- package/src/lsp/.agentic-security/streak.json +22 -0
- package/src/lsp/server.js +275 -0
- package/src/mcp/.agentic-security/findings.json +8358 -0
- package/src/mcp/.agentic-security/last-scan.json +8358 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +1125 -0
- package/src/mcp/.agentic-security/streak.json +22 -0
- package/src/mcp/CLAUDE.md +54 -0
- package/src/mcp/audit.js +136 -0
- package/src/mcp/redact.js +75 -0
- package/src/mcp/server.js +158 -0
- package/src/mcp/stdio.js +83 -0
- package/src/mcp/tools.js +940 -0
- package/src/mcp/validate.js +49 -0
- package/src/personality.js +164 -0
- package/src/poc-video.js +239 -0
- package/src/posture/.agentic-security/findings.json +51239 -0
- package/src/posture/.agentic-security/last-scan.json +51239 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +5557 -0
- package/src/posture/.agentic-security/streak.json +24 -0
- package/src/posture/CLAUDE.md +42 -0
- package/src/posture/adversarial-self-test.js +114 -0
- package/src/posture/adversary-agent.js +204 -0
- package/src/posture/agents-memory.js +135 -0
- package/src/posture/ai-code-fingerprint.js +171 -0
- package/src/posture/aibom.js +284 -0
- package/src/posture/api-inventory.js +96 -0
- package/src/posture/attack-playbooks.js +305 -0
- package/src/posture/auditor-agent.js +115 -0
- package/src/posture/auth-posture-import.js +135 -0
- package/src/posture/baseline-compare.js +114 -0
- package/src/posture/blast-radius.js +836 -0
- package/src/posture/bounty-prediction.js +141 -0
- package/src/posture/business-logic.js +239 -0
- package/src/posture/calibration-drift.js +93 -0
- package/src/posture/calibration-seed.json +27 -0
- package/src/posture/calibration.js +204 -0
- package/src/posture/clustering.js +75 -0
- package/src/posture/concurrency-checker.js +265 -0
- package/src/posture/confidence.js +65 -0
- package/src/posture/container-runtime.js +149 -0
- package/src/posture/counterfactual.js +109 -0
- package/src/posture/cross-lang-graphql.js +165 -0
- package/src/posture/cross-lang-grpc.js +166 -0
- package/src/posture/cross-lang-meta.js +101 -0
- package/src/posture/cross-lang-openapi.js +187 -0
- package/src/posture/cross-lang-orm.js +153 -0
- package/src/posture/cross-lang-queues.js +210 -0
- package/src/posture/crown-jewels.js +110 -0
- package/src/posture/custom-rules.js +361 -0
- package/src/posture/cve-alert-daemon.js +433 -0
- package/src/posture/cve-lookup.js +129 -0
- package/src/posture/dead-code.js +430 -0
- package/src/posture/defender-agent.js +158 -0
- package/src/posture/deploy-platform.js +204 -0
- package/src/posture/detector-fuzz.js +61 -0
- package/src/posture/deterministic.js +99 -0
- package/src/posture/drift.js +165 -0
- package/src/posture/epss.js +156 -0
- package/src/posture/exploitability-probability.js +212 -0
- package/src/posture/exploitability.js +121 -0
- package/src/posture/feature-flags.js +110 -0
- package/src/posture/finding-defaults.js +132 -0
- package/src/posture/fix-history.js +411 -0
- package/src/posture/fix-plan.js +121 -0
- package/src/posture/fix-verify-loop.js +157 -0
- package/src/posture/fix-verify.js +130 -0
- package/src/posture/flow-narration.js +105 -0
- package/src/posture/grader-calibration.js +156 -0
- package/src/posture/harness-discovery.js +113 -0
- package/src/posture/holdout-eval.js +144 -0
- package/src/posture/iac-reachability.js +163 -0
- package/src/posture/iam-policy.js +128 -0
- package/src/posture/integrity.js +97 -0
- package/src/posture/learning.js +166 -0
- package/src/posture/license-policy.js +109 -0
- package/src/posture/llm-redteam-prompts.js +418 -0
- package/src/posture/llm-redteam.js +303 -0
- package/src/posture/material-change.js +163 -0
- package/src/posture/mitigation-composite.js +55 -0
- package/src/posture/mttr.js +91 -0
- package/src/posture/network-policy-import.js +126 -0
- package/src/posture/path-predicates.js +99 -0
- package/src/posture/persona-prioritization.js +153 -0
- package/src/posture/poc-cwe-map.js +51 -0
- package/src/posture/poc-generator.js +500 -0
- package/src/posture/policy-gate.js +174 -0
- package/src/posture/pre-incident-archaeology.js +110 -0
- package/src/posture/profile.js +93 -0
- package/src/posture/reachability-filter.js +42 -0
- package/src/posture/regression-test-gen.js +200 -0
- package/src/posture/reverse-blast-radius.js +110 -0
- package/src/posture/router.js +109 -0
- package/src/posture/rule-overrides.js +198 -0
- package/src/posture/rule-pack-signing.js +209 -0
- package/src/posture/rule-packs.js +143 -0
- package/src/posture/rule-synthesis.js +108 -0
- package/src/posture/ruleset-version.js +71 -0
- package/src/posture/sbom.js +129 -0
- package/src/posture/schema-aware-bridge.js +207 -0
- package/src/posture/security-trend.js +87 -0
- package/src/posture/semantic-clone.js +114 -0
- package/src/posture/specification-mining.js +170 -0
- package/src/posture/stable-id.js +75 -0
- package/src/posture/stack-playbook.js +229 -0
- package/src/posture/streak.js +249 -0
- package/src/posture/suppressions.js +135 -0
- package/src/posture/telemetry-ingest.js +112 -0
- package/src/posture/threat-model.js +145 -0
- package/src/posture/three-agent-pipeline.js +74 -0
- package/src/posture/triage.js +146 -0
- package/src/posture/trust-boundary-diagram.js +115 -0
- package/src/posture/type-narrowing.js +129 -0
- package/src/posture/validator-metrics.js +179 -0
- package/src/posture/verifier-ephemeral.js +118 -0
- package/src/posture/verifier-target.js +147 -0
- package/src/posture/verifier.js +257 -0
- package/src/posture/version.js +75 -0
- package/src/posture/waf-ingest.js +200 -0
- package/src/posture/why-fired.js +141 -0
- package/src/pr-comment.js +172 -0
- package/src/pr-delta.js +198 -0
- package/src/report/.agentic-security/findings.json +79 -0
- package/src/report/.agentic-security/last-scan.json +79 -0
- package/src/report/.agentic-security/last-scan.json.sig +1 -0
- package/src/report/.agentic-security/scan-history.json +332 -0
- package/src/report/.agentic-security/streak.json +23 -0
- package/src/report/index.js +1136 -0
- package/src/report/mascot.js +42 -0
- package/src/runScan.js +141 -0
- package/src/sast/.agentic-security/findings.json +5051 -0
- package/src/sast/.agentic-security/last-scan.json +5051 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +788 -0
- package/src/sast/.agentic-security/streak.json +23 -0
- package/src/sast/CLAUDE.md +39 -0
- package/src/sast/_comment-strip.js +46 -0
- package/src/sast/agent-tool-escalation.js +131 -0
- package/src/sast/auth-provider.js +171 -0
- package/src/sast/authz.js +236 -0
- package/src/sast/bench-shape/.agentic-security/findings.json +28 -0
- package/src/sast/bench-shape/.agentic-security/last-scan.json +28 -0
- package/src/sast/bench-shape/.agentic-security/scan-history.json +24 -0
- package/src/sast/bench-shape/.agentic-security/streak.json +22 -0
- package/src/sast/bench-shape/index.js +62 -0
- package/src/sast/claude-hook-injection.js +199 -0
- package/src/sast/claude-md-prompt-injection.js +170 -0
- package/src/sast/claude-settings.js +165 -0
- package/src/sast/client-side.js +149 -0
- package/src/sast/cpp-bench-extras.js +122 -0
- package/src/sast/cpp-dataflow.js +430 -0
- package/src/sast/cpp.js +248 -0
- package/src/sast/csharp.js +152 -0
- package/src/sast/csrf.js +82 -0
- package/src/sast/dart-flutter.js +173 -0
- package/src/sast/db-rls.js +147 -0
- package/src/sast/db-taint.js +215 -0
- package/src/sast/defi-deep.js +242 -0
- package/src/sast/deserialization-gadgets.js +113 -0
- package/src/sast/django-hardening.js +230 -0
- package/src/sast/env-hygiene.js +125 -0
- package/src/sast/fastapi-hardening.js +145 -0
- package/src/sast/go-extended.js +84 -0
- package/src/sast/host-header.js +106 -0
- package/src/sast/index.js +17 -0
- package/src/sast/java-ast-folding.js +561 -0
- package/src/sast/java-bench-extras.js +708 -0
- package/src/sast/java-collection-passthrough.js +178 -0
- package/src/sast/java-constant-fold.js +244 -0
- package/src/sast/java-deserialization.js +125 -0
- package/src/sast/jndi.js +104 -0
- package/src/sast/juliet-shape.js +324 -0
- package/src/sast/jwt-exp.js +104 -0
- package/src/sast/kotlin.js +82 -0
- package/src/sast/laravel-hardening.js +198 -0
- package/src/sast/ldap-injection.js +100 -0
- package/src/sast/llm-owasp.js +465 -0
- package/src/sast/llm-stored-prompt.js +103 -0
- package/src/sast/llm-trading-agent.js +161 -0
- package/src/sast/llm.js +308 -0
- package/src/sast/logic.js +140 -0
- package/src/sast/mass-assignment.js +101 -0
- package/src/sast/mcp-audit.js +242 -0
- package/src/sast/mobile-manifest.js +195 -0
- package/src/sast/model-load.js +164 -0
- package/src/sast/mutation-xss.js +87 -0
- package/src/sast/nosql-injection.js +82 -0
- package/src/sast/open-redirect.js +119 -0
- package/src/sast/php.js +91 -0
- package/src/sast/pipeline.js +122 -0
- package/src/sast/primary-cwe-java.js +155 -0
- package/src/sast/prompt-firewall.js +151 -0
- package/src/sast/prompt-template.js +157 -0
- package/src/sast/prototype-pollution.js +112 -0
- package/src/sast/python-sinks.js +195 -0
- package/src/sast/quarkus-hardening.js +102 -0
- package/src/sast/rag-poisoning.js +118 -0
- package/src/sast/rate-limit.js +128 -0
- package/src/sast/response-splitting.js +138 -0
- package/src/sast/ruby.js +108 -0
- package/src/sast/rust.js +105 -0
- package/src/sast/solidity.js +167 -0
- package/src/sast/springboot-hardening.js +186 -0
- package/src/sast/ssrf-cloud-metadata.js +80 -0
- package/src/sast/ssti.js +116 -0
- package/src/sast/swift.js +162 -0
- package/src/sast/toctou.js +95 -0
- package/src/sast/webhook.js +101 -0
- package/src/sast/xpath-injection.js +51 -0
- package/src/sast/xxe.js +140 -0
- package/src/sast/zip-slip.js +200 -0
- package/src/sca/base-images.json +45 -0
- package/src/sca/container.js +107 -0
- package/src/sca/dep-confusion.js +134 -0
- package/src/sca/index.js +6 -0
- package/src/sca/popular-packages.json +41 -0
- package/src/sca/sarif-ingest.js +187 -0
- package/src/sca/vuln-function-hints.json +89 -0
- package/src/secrets/index.js +4 -0
|
@@ -0,0 +1,1683 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agentic-security CLI — scan, fix, setup, version.
|
|
3
|
+
// Created by ClearCapabilities.Com — https://clearcapabilities.com
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as fsp from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { signLastScan as _signLastScan, verifyLastScan as _verifyLastScanShared } from '../src/posture/integrity.js';
|
|
8
|
+
import { runScan } from '../src/runScan.js';
|
|
9
|
+
import { toJSON, toMarkdown, toSARIF, toSTIX, toCSV, toJUnit, toCLI, toCLIByProfile, toShipVerdict, toProTable, toHTML, toSummary, exitCodeFor, normalizeFindings } from '../src/report/index.js';
|
|
10
|
+
import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
|
|
11
|
+
import { toPBOM } from '../src/sast/pipeline.js';
|
|
12
|
+
import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
|
|
13
|
+
import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
|
|
14
|
+
import { ingestAndMerge } from '../src/sca/sarif-ingest.js';
|
|
15
|
+
import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
|
|
16
|
+
import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
|
|
17
|
+
import { applyOverrides, validateOverrides } from '../src/posture/rule-overrides.js';
|
|
18
|
+
import { listPacks, loadPack, applyPacks } from '../src/posture/rule-packs.js';
|
|
19
|
+
import { writeLockfile, verifyLockfile, makeDeterministic, isDeterministic } from '../src/posture/deterministic.js';
|
|
20
|
+
import { enrichWithEPSS } from '../src/posture/epss.js';
|
|
21
|
+
import { enrichWithBlastRadius } from '../src/posture/blast-radius.js';
|
|
22
|
+
import { applyCustomRules, runRuleTests, loadCustomRules } from '../src/posture/custom-rules.js';
|
|
23
|
+
import { applyFix, undoLast, undoAll, listHistory, preview as previewDiff, compactLog } from '../src/posture/fix-history.js';
|
|
24
|
+
import { syncTickets } from '../src/integrations/tickets.js';
|
|
25
|
+
import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
|
|
26
|
+
import * as triage from '../src/posture/triage.js';
|
|
27
|
+
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
28
|
+
import fg from 'fast-glob';
|
|
29
|
+
|
|
30
|
+
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
31
|
+
// so the MCP server tools can share verification.
|
|
32
|
+
function _verifyLastScan(body, sigFile) {
|
|
33
|
+
const v = _verifyLastScanShared(body, sigFile);
|
|
34
|
+
return v;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const USAGE = `agentic-security <command> [options]
|
|
38
|
+
|
|
39
|
+
🛡 Created by ClearCapabilities.Com · https://clearcapabilities.com
|
|
40
|
+
|
|
41
|
+
Commands:
|
|
42
|
+
secure [path] [--launch] Smart router: tells you the single best next action
|
|
43
|
+
scan [path] Full SAST + SCA + Secrets sweep (default: cwd)
|
|
44
|
+
ship (internal) Vibecoder verdict — invoked by /scan-all
|
|
45
|
+
ci [path] Baseline-aware CI scan: auto-detects PR base ref,
|
|
46
|
+
writes SARIF + JUnit + JSON, applies --fail-on policy
|
|
47
|
+
fix --finding <id> [--preview|--apply] Show diff or apply fix for a single finding
|
|
48
|
+
undo [--all|--list|--compact] Revert the most recent applied fix; --compact archives terminal entries (--retain-days N --prune-backups)
|
|
49
|
+
accept --finding <id> Soft-suppress a finding for 30 days (vibecoder)
|
|
50
|
+
setup [project-dir] Install /security-* shortcut commands into a project
|
|
51
|
+
profile set <vibecoder|pro> Set or change the persona profile
|
|
52
|
+
profile show Print current profile
|
|
53
|
+
org-scan --repos <list> Pro: scan multiple repos and produce roll-up
|
|
54
|
+
triage list|assign|trend Pro: per-finding state, MTTR, assignment
|
|
55
|
+
rules validate Pro: lint .agentic-security/rules.yml
|
|
56
|
+
packs list List available curated rule packs
|
|
57
|
+
rule list | test <glob> List/test custom YAML rules in .agentic-security/rules/
|
|
58
|
+
tickets sync --provider <p> Two-way sync findings ↔ GitHub Issues / Linear / Jira
|
|
59
|
+
digest --slack <webhook> Vibecoder: send daily digest to Slack
|
|
60
|
+
mcp Start the MCP stdio server (scan_diff, query_taint, explain_finding, apply_fix)
|
|
61
|
+
validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
|
|
62
|
+
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
63
|
+
reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
|
|
64
|
+
rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
|
|
65
|
+
version Print version
|
|
66
|
+
banner [--full] Print the Patch-the-frog mascot + brand lockup
|
|
67
|
+
harness [path] [--include-home] Multi-harness config audit: scans .claude/,
|
|
68
|
+
.cursor/, .codex/, .gemini/, .kiro/, .opencode/,
|
|
69
|
+
.trae/, .qwen/, .zed/, .continue/, .aider/ at the
|
|
70
|
+
project root. --include-home also sweeps ~/.
|
|
71
|
+
scan-baseline --current <f> --previous <f>
|
|
72
|
+
Finding-level diff between two scan JSON outputs.
|
|
73
|
+
Reports added / removed / changed findings.
|
|
74
|
+
|
|
75
|
+
Options:
|
|
76
|
+
--profile vibecoder|pro Override profile for this run
|
|
77
|
+
--only sast|sca|secrets Limit scan to one pillar
|
|
78
|
+
--format <fmt> cli | json | md | sarif | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
|
|
79
|
+
--pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
|
|
80
|
+
--baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
|
|
81
|
+
--fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
|
|
82
|
+
--policy <file.rego> ci-mode policy-as-code gate; deny[] rules fail the build (FR-SDLC-9)
|
|
83
|
+
--columns standard|mitre|capec|owasp Pro-mode column set (default: standard)
|
|
84
|
+
--confidence <0..1> Override per-profile confidence threshold
|
|
85
|
+
--firehose Show ALL findings (ignore confidence threshold)
|
|
86
|
+
--honest Show only high-confidence (≥0.9) findings
|
|
87
|
+
--exposed-only Filter to findings the production stack does NOT mitigate
|
|
88
|
+
--mitigated-only Filter to findings already mitigated by WAF/auth/network/flag
|
|
89
|
+
--unreachable-only Filter to findings on unreachable code paths
|
|
90
|
+
--persona <name> Filter to findings whose top-2 personas include <name>
|
|
91
|
+
(script-kiddie|opportunistic-criminal|apt-nation-state|
|
|
92
|
+
supply-chain-attacker|malicious-insider)
|
|
93
|
+
--show-personas Append per-persona top-picks block
|
|
94
|
+
--show-bounty Append predicted bug-bounty payout block
|
|
95
|
+
--show-playbook Append attack-playbook block for high+ findings
|
|
96
|
+
--show-spof Append single-point-of-failure-controls block
|
|
97
|
+
--show-trust-boundary Append the auto-generated trust-boundary Mermaid diagram
|
|
98
|
+
--show-threat-model Append the auto-derived STRIDE threat model summary
|
|
99
|
+
--show-drift Append calibration-drift alarms (overconfidence detection)
|
|
100
|
+
--sca-reachable-only Only SCA findings where the vulnerable function is reachable
|
|
101
|
+
--ingest-sarif <glob> Merge external SARIF into this scan
|
|
102
|
+
--scorecard Enrich components with OSSF Scorecard scores
|
|
103
|
+
--no-network Skip OSV/registry queries (offline mode)
|
|
104
|
+
--pr [ref] Diff-aware: scan only files changed since ref (auto-detects PR base)
|
|
105
|
+
--deterministic Reproducible scan: stable sort, no-network, lockfile-checked
|
|
106
|
+
--no-epss Skip EPSS exploit-prediction enrichment (default: enabled)
|
|
107
|
+
--no-blast-radius Skip blast-radius / cost framing (default: enabled)
|
|
108
|
+
--verbose Include fix bodies + taxonomy in CLI output
|
|
109
|
+
--output <file> Write report to file instead of stdout
|
|
110
|
+
--machine-output Always write .agentic-security/findings.{sarif,json,csv}
|
|
111
|
+
|
|
112
|
+
Exit codes:
|
|
113
|
+
0 = clean 1 = low/medium 2 = high 3 = critical 4 = error`;
|
|
114
|
+
|
|
115
|
+
// Load profile, allowing CLI flags to override. CLI flag takes precedence.
|
|
116
|
+
function loadPersonaProfile(scanRoot, args) {
|
|
117
|
+
const flagProfile = args.flags.profile;
|
|
118
|
+
const base = loadProfile(scanRoot);
|
|
119
|
+
if (flagProfile === 'pro' || flagProfile === 'vibecoder') {
|
|
120
|
+
return { ...base, profile: flagProfile };
|
|
121
|
+
}
|
|
122
|
+
return base;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Compute confidence threshold from profile + flags.
|
|
126
|
+
// `agentic-security banner [--full|--compact]` — Patch the frog mascot +
|
|
127
|
+
// brand line. `--compact` (default) prints a single coloured frog face beside
|
|
128
|
+
// the wordmark. `--full` prints the seven-line lockup mirroring the SVG.
|
|
129
|
+
// Colour is suppressed under NO_COLOR or non-TTY stderr.
|
|
130
|
+
function printBanner(args) {
|
|
131
|
+
const useColor = !!process.stderr.isTTY && !process.env.NO_COLOR;
|
|
132
|
+
const C = useColor ? {
|
|
133
|
+
FROG: '\x1b[38;2;255;107;44m',
|
|
134
|
+
DEEP: '\x1b[38;2;201;52;20m',
|
|
135
|
+
CREAM: '\x1b[38;2;244;239;230m',
|
|
136
|
+
DIM: '\x1b[2m',
|
|
137
|
+
BOLD: '\x1b[1m',
|
|
138
|
+
RESET: '\x1b[0m',
|
|
139
|
+
} : { FROG:'', DEEP:'', CREAM:'', DIM:'', BOLD:'', RESET:'' };
|
|
140
|
+
const v = '0.74.0';
|
|
141
|
+
const compact = !args.flags.full;
|
|
142
|
+
if (compact) {
|
|
143
|
+
const lines = [
|
|
144
|
+
'',
|
|
145
|
+
` ${C.FROG}╭─╮╭─╮${C.RESET} ${C.BOLD}agentic-security${C.RESET} ${C.DIM}·${C.RESET} ${C.CREAM}by Clear Capabilities${C.RESET} ${C.DIM}· v${v}${C.RESET}`,
|
|
146
|
+
` ${C.FROG}│${C.BOLD}◉${C.RESET}${C.FROG}││${C.BOLD}◉${C.RESET}${C.FROG}│${C.RESET} ${C.DIM}Tiny.${C.RESET} ${C.FROG}${C.BOLD}Bright.${C.RESET} ${C.DIM}Watching.${C.RESET}`,
|
|
147
|
+
` ${C.FROG}╰─╯╰─╯${C.RESET}`,
|
|
148
|
+
'',
|
|
149
|
+
];
|
|
150
|
+
process.stdout.write(lines.join('\n'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Full lockup — mirrors hooks/mascot.js lockup() for first-run / banner output.
|
|
154
|
+
const lines = [
|
|
155
|
+
'',
|
|
156
|
+
` ${C.FROG}╭───╮ ╭───╮${C.RESET}`,
|
|
157
|
+
` ${C.FROG}│ ${C.BOLD}◉${C.RESET}${C.FROG} │ │ ${C.BOLD}◉${C.RESET}${C.FROG} │${C.RESET} ${C.BOLD}agentic-security${C.RESET}`,
|
|
158
|
+
` ${C.FROG}╰─┬─╯ ╰─┬─╯${C.RESET} ${C.DIM}─────────────────${C.RESET}`,
|
|
159
|
+
` ${C.FROG}╭──┴─────┴──╮${C.RESET} ${C.CREAM}Tiny. ${C.FROG}${C.BOLD}Bright.${C.RESET}${C.CREAM} Watching.${C.RESET}`,
|
|
160
|
+
` ${C.FROG}│ ${C.DEEP}·${C.FROG} ${C.BOLD}⌣${C.RESET}${C.FROG} ${C.DEEP}·${C.FROG} │${C.RESET} ${C.CREAM}by Clear Capabilities Inc.${C.RESET} ${C.DIM}· v${v}${C.RESET}`,
|
|
161
|
+
` ${C.FROG}╰───────────╯${C.RESET} ${C.DIM}https://clearcapabilities.com${C.RESET}`,
|
|
162
|
+
'',
|
|
163
|
+
];
|
|
164
|
+
process.stdout.write(lines.join('\n'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function effectiveConfidence(profile, args) {
|
|
168
|
+
if (args.flags['firehose']) return 0.0;
|
|
169
|
+
if (args.flags['honest']) return 0.9;
|
|
170
|
+
if (args.flags['confidence'] != null) return parseFloat(args.flags['confidence']);
|
|
171
|
+
return profile.confidenceMin ?? (profile.profile === 'pro' ? 0.3 : 0.9);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// v3 next-gen — render supplementary blocks on top of the normal CLI body.
|
|
175
|
+
// Each block is opt-in via a flag; renderV3Blocks returns '' when no flags
|
|
176
|
+
// are set, so the default output is unchanged.
|
|
177
|
+
function renderV3Blocks(scan, flags) {
|
|
178
|
+
const out = [];
|
|
179
|
+
const findings = scan.findings || [];
|
|
180
|
+
if (flags['show-personas']) {
|
|
181
|
+
out.push('\n── Per-attacker-persona top picks ───────────────────────────────');
|
|
182
|
+
const byPersona = new Map();
|
|
183
|
+
for (const f of findings) {
|
|
184
|
+
if (!Array.isArray(f.personaTopTwo)) continue;
|
|
185
|
+
for (const p of f.personaTopTwo) {
|
|
186
|
+
if (!byPersona.has(p)) byPersona.set(p, []);
|
|
187
|
+
byPersona.get(p).push(f);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!byPersona.size) out.push(' (no findings carry persona scores yet — rerun /scan)');
|
|
191
|
+
for (const [persona, items] of byPersona) {
|
|
192
|
+
items.sort((a, b) => (b.personaMaxScore || 0) - (a.personaMaxScore || 0));
|
|
193
|
+
out.push(`\n ${persona} (${items.length} relevant)`);
|
|
194
|
+
for (const f of items.slice(0, 3)) {
|
|
195
|
+
const sev = (f.severity || '').toUpperCase();
|
|
196
|
+
out.push(` [${sev}] ${(f.vuln || '').slice(0, 60)} — ${f.file}:${f.line}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (flags['show-bounty']) {
|
|
201
|
+
out.push('\n── Predicted bug-bounty payouts ─────────────────────────────────');
|
|
202
|
+
const withBounty = findings.filter(f => f.predictedBountyUsd);
|
|
203
|
+
if (!withBounty.length) out.push(' (no findings carry bounty predictions — rerun /scan)');
|
|
204
|
+
const sorted = withBounty.slice().sort((a, b) => (b.predictedBountyUsd.likely || 0) - (a.predictedBountyUsd.likely || 0));
|
|
205
|
+
for (const f of sorted.slice(0, 15)) {
|
|
206
|
+
const b = f.predictedBountyUsd;
|
|
207
|
+
out.push(` $${b.low}-$${b.high} (likely $${b.likely}, ${b.program}) — ${(f.vuln || '').slice(0, 50)} ${f.file}:${f.line}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (flags['show-playbook']) {
|
|
211
|
+
out.push('\n── Attack playbooks (high+ findings only) ───────────────────────');
|
|
212
|
+
const withPb = findings.filter(f => f.attackPlaybook);
|
|
213
|
+
if (!withPb.length) out.push(' (no high+/critical findings to show playbooks for)');
|
|
214
|
+
for (const f of withPb.slice(0, 5)) {
|
|
215
|
+
const pb = f.attackPlaybook;
|
|
216
|
+
out.push(`\n ${pb.cwe} — ${pb.title} (${f.file}:${f.line})`);
|
|
217
|
+
out.push(' ────────────────────────────────────');
|
|
218
|
+
out.push(pb.script.split('\n').map(l => ' ' + l).join('\n'));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (flags['show-spof']) {
|
|
222
|
+
out.push('\n── Single-point-of-failure controls (counterfactual) ────────────');
|
|
223
|
+
const spof = scan._v3?.counterfactual?.spofControls || [];
|
|
224
|
+
if (!spof.length) out.push(' (no SPOF controls detected — either no controls or no clusters of high+ findings depend on one)');
|
|
225
|
+
for (const c of spof.slice(0, 10)) {
|
|
226
|
+
out.push(` ${c.control} @ ${c.location} — would expose ${c.wouldExpose} high+ findings if removed`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (flags['show-trust-boundary']) {
|
|
230
|
+
out.push('\n── Trust-boundary diagram (Mermaid) ─────────────────────────────');
|
|
231
|
+
const d = scan._v3?.trustBoundaryDiagram;
|
|
232
|
+
if (!d) out.push(' (no diagram — rerun /scan)');
|
|
233
|
+
else {
|
|
234
|
+
out.push(' ```mermaid');
|
|
235
|
+
out.push(d.mermaid.split('\n').map(l => ' ' + l).join('\n'));
|
|
236
|
+
out.push(' ```');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (flags['show-threat-model']) {
|
|
240
|
+
out.push('\n── Auto-generated STRIDE threat model ───────────────────────────');
|
|
241
|
+
const tm = scan._v3?.threatModel;
|
|
242
|
+
if (!tm) out.push(' (no threat model — rerun /scan)');
|
|
243
|
+
else {
|
|
244
|
+
out.push(` Assets: ${tm.summary.assetCount} Trust boundaries: ${tm.summary.boundaryCount}`);
|
|
245
|
+
for (const [cat, count] of Object.entries(tm.summary.strideCounts)) {
|
|
246
|
+
out.push(` ${cat.padEnd(22)} ${count}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (flags['show-drift']) {
|
|
251
|
+
out.push('\n── Calibration-drift alarms ─────────────────────────────────────');
|
|
252
|
+
const dr = scan._v3?.calibrationDrift;
|
|
253
|
+
const alarms = dr?.alarms || [];
|
|
254
|
+
if (!alarms.length) out.push(' (no drift detected — confidence matches realized accuracy within threshold)');
|
|
255
|
+
for (const a of alarms) {
|
|
256
|
+
out.push(` ${a.family}: reported ${(a.reportedAccuracy * 100).toFixed(0)}% vs realized ${(a.realizedAccuracy * 100).toFixed(0)}% (N=${a.sampleSize})`);
|
|
257
|
+
out.push(` ${a.recommendation}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return out.join('\n');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Always-on machine output (R2). Vibecoder gets JSON only; pro gets JSON+SARIF+CSV.
|
|
264
|
+
async function writeMachineOutput(targetAbs, scan, meta, profile) {
|
|
265
|
+
const stateDir = path.join(targetAbs, '.agentic-security');
|
|
266
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
267
|
+
// Always JSON (used by /security-fix and /security-report).
|
|
268
|
+
await fsp.writeFile(path.join(stateDir, 'findings.json'),
|
|
269
|
+
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
270
|
+
if (profile.profile === 'pro' || profile.machineOutput) {
|
|
271
|
+
await fsp.writeFile(path.join(stateDir, 'findings.sarif'),
|
|
272
|
+
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
273
|
+
await fsp.writeFile(path.join(stateDir, 'findings.csv'), toCSV(scan));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseArgs(argv) {
|
|
278
|
+
const args = { _: [], flags: {} };
|
|
279
|
+
for (let i = 0; i < argv.length; i++) {
|
|
280
|
+
const a = argv[i];
|
|
281
|
+
if (a.startsWith('--')) {
|
|
282
|
+
const [k, v] = a.slice(2).split('=', 2);
|
|
283
|
+
if (v !== undefined) { args.flags[k] = v; continue; }
|
|
284
|
+
const next = argv[i + 1];
|
|
285
|
+
if (next && !next.startsWith('--')) { args.flags[k] = next; i++; }
|
|
286
|
+
else args.flags[k] = true;
|
|
287
|
+
} else {
|
|
288
|
+
args._.push(a);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return args;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function cmdScan(args) {
|
|
295
|
+
const target = args._[1] || '.';
|
|
296
|
+
const targetAbs = path.resolve(target);
|
|
297
|
+
// Load persona profile (R1). Persona-aware defaults flow from here.
|
|
298
|
+
const profile = loadPersonaProfile(targetAbs, args);
|
|
299
|
+
const format = args.flags.format || (profile.profile === 'pro' ? 'cli' : 'ship');
|
|
300
|
+
const verbose = !!args.flags.verbose;
|
|
301
|
+
const output = args.flags.output;
|
|
302
|
+
const noNet = !!args.flags['no-network'];
|
|
303
|
+
if (noNet) process.env.AGENTIC_SECURITY_OFFLINE = '1';
|
|
304
|
+
|
|
305
|
+
// Deterministic mode: stable output, no-network, lockfile verification.
|
|
306
|
+
if (args.flags['deterministic']) {
|
|
307
|
+
process.env.AGENTIC_SECURITY_DETERMINISTIC = '1';
|
|
308
|
+
process.env.AGENTIC_SECURITY_OFFLINE = '1';
|
|
309
|
+
const v = verifyLockfile(targetAbs);
|
|
310
|
+
if (!v.ok) {
|
|
311
|
+
process.stderr.write(`[deterministic] lockfile mismatch:\n - ${v.mismatches.join('\n - ')}\n`);
|
|
312
|
+
process.stderr.write(`[deterministic] run \`agentic-security rules lock\` to refresh.\n`);
|
|
313
|
+
return 4;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --pr [ref] : friendlier alias for --changed-since that auto-detects the PR
|
|
318
|
+
// base ref (GitHub/GitLab/Buildkite/Bitbucket env vars) when no value is given.
|
|
319
|
+
let changedSince = args.flags['changed-since'] || null;
|
|
320
|
+
if (args.flags['pr']) {
|
|
321
|
+
const pr = args.flags['pr'];
|
|
322
|
+
changedSince = (typeof pr === 'string' && pr !== 'true') ? pr : (detectBaseline() || 'origin/main');
|
|
323
|
+
process.stderr.write(`[pr-mode] scanning files changed since: ${changedSince}\n`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const { scan, meta } = await runScan(target, {
|
|
327
|
+
changedSince,
|
|
328
|
+
onProgress: (p) => {
|
|
329
|
+
if (process.stderr.isTTY) process.stderr.write(`\r[${p.phase}] ${p.current}/${p.total} ${p.file} `);
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
if (process.stderr.isTTY) process.stderr.write('\r' + ' '.repeat(80) + '\r');
|
|
333
|
+
|
|
334
|
+
const only = args.flags.only;
|
|
335
|
+
if (only) {
|
|
336
|
+
if (only === 'sast') { scan.secrets = []; scan.supplyChain = []; }
|
|
337
|
+
if (only === 'sca') { scan.findings = []; scan.secrets = []; }
|
|
338
|
+
if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 0.9.0 Feat-18: --scorecard flag enables OSSF Scorecard enrichment
|
|
342
|
+
if (args.flags['scorecard']) process.env.AGENTIC_SECURITY_SCORECARD = '1';
|
|
343
|
+
|
|
344
|
+
// 0.7.0 Feat-7: --ingest-sarif <path-or-glob> merges SARIF from external tools (Semgrep,
|
|
345
|
+
// gitleaks, Bandit, Trivy, Checkov, etc.) into this scan's findings, deduping by
|
|
346
|
+
// fingerprint and tracking provenance via sources[].
|
|
347
|
+
if (args.flags['ingest-sarif']) {
|
|
348
|
+
const glob = args.flags['ingest-sarif'];
|
|
349
|
+
const paths = await fg(glob, { dot: false, onlyFiles: true });
|
|
350
|
+
if (paths.length) {
|
|
351
|
+
const r = ingestAndMerge(scan, paths);
|
|
352
|
+
if (process.stderr.isTTY) process.stderr.write(`[ingest] merged ${r.merged} / added ${r.added} findings from ${paths.length} SARIF file(s)\n`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 0.6.0 Feat-1: --sca-reachable-only filters to only SCA findings where the vulnerable
|
|
357
|
+
// function was confirmed reachable from a route handler.
|
|
358
|
+
if (args.flags['sca-reachable-only']) {
|
|
359
|
+
scan.supplyChain = (scan.supplyChain || []).filter(sc =>
|
|
360
|
+
sc.functionReachable === 'reachable' || sc.functionReachable !== 'unreachable'
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// R4: Apply persona-appropriate suppressions BEFORE rendering.
|
|
365
|
+
// R9: Apply rule overrides (severity remap, disable list).
|
|
366
|
+
// R3: Compute effective confidence threshold for renderers.
|
|
367
|
+
const confidenceMin = effectiveConfidence(profile, args);
|
|
368
|
+
const effProfile = { ...profile, confidenceMin };
|
|
369
|
+
// Apply suppressions to each findings bucket (findings/secrets/logicVulns/supplyChain).
|
|
370
|
+
scan.findings = applySuppressions(scan.findings || [], targetAbs, profile);
|
|
371
|
+
scan.secrets = applySuppressions(scan.secrets || [], targetAbs, profile);
|
|
372
|
+
scan.logicVulns = applySuppressions(scan.logicVulns || [], targetAbs, profile);
|
|
373
|
+
scan.supplyChain = applySuppressions(scan.supplyChain || [], targetAbs, profile);
|
|
374
|
+
// Apply rule overrides (severity remaps + disable list).
|
|
375
|
+
scan.findings = applyOverrides(scan.findings || [], targetAbs);
|
|
376
|
+
scan.secrets = applyOverrides(scan.secrets || [], targetAbs);
|
|
377
|
+
scan.logicVulns = applyOverrides(scan.logicVulns || [], targetAbs);
|
|
378
|
+
|
|
379
|
+
// Curated rule packs: --pack <name> (repeatable). Narrows findings to the
|
|
380
|
+
// CWEs covered by the requested pack(s).
|
|
381
|
+
const packArg = args.flags.pack;
|
|
382
|
+
const packNames = packArg ? (Array.isArray(packArg) ? packArg : String(packArg).split(',')) : [];
|
|
383
|
+
if (packNames.length) Object.assign(scan, applyPacks(scan, packNames));
|
|
384
|
+
|
|
385
|
+
// Custom pattern-rule DSL — load .agentic-security/rules/*.yml and append findings.
|
|
386
|
+
try {
|
|
387
|
+
const { fileContents } = await import('../src/runScan.js').then(m => m.readTree(targetAbs));
|
|
388
|
+
const customFindings = applyCustomRules(targetAbs, fileContents);
|
|
389
|
+
if (customFindings.length) {
|
|
390
|
+
scan.findings = [...(scan.findings || []), ...customFindings];
|
|
391
|
+
if (process.stderr.isTTY) {
|
|
392
|
+
process.stderr.write(`[custom-rules] +${customFindings.length} finding(s) from ${loadCustomRules(targetAbs).length} rule(s)\n`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} catch {}
|
|
396
|
+
|
|
397
|
+
// EPSS exploit-prediction enrichment (skipped under --no-network / --deterministic).
|
|
398
|
+
// Bumps severity on actively-exploited CVEs so they sort to the top.
|
|
399
|
+
if (!args.flags['no-epss'] && !isDeterministic() && !noNet) {
|
|
400
|
+
try { await enrichWithEPSS(scan); } catch {}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Blast-radius narrative — purely local, always safe to run.
|
|
404
|
+
if (!args.flags['no-blast-radius']) {
|
|
405
|
+
try { enrichWithBlastRadius(scan, targetAbs); } catch {}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// v3 next-gen filter flags — operate on the production-aware composite
|
|
409
|
+
// verdict. These run after every annotator so the verdict is final.
|
|
410
|
+
if (args.flags['exposed-only']) {
|
|
411
|
+
scan.findings = (scan.findings || []).filter(f => f.mitigationVerdict === 'exposed-in-prod' || !f.mitigationVerdict);
|
|
412
|
+
scan.supplyChain = (scan.supplyChain || []).filter(f => f.mitigationVerdict === 'exposed-in-prod' || !f.mitigationVerdict);
|
|
413
|
+
}
|
|
414
|
+
if (args.flags['mitigated-only']) {
|
|
415
|
+
scan.findings = (scan.findings || []).filter(f => f.mitigationVerdict === 'mitigated-in-prod');
|
|
416
|
+
scan.supplyChain = (scan.supplyChain || []).filter(f => f.mitigationVerdict === 'mitigated-in-prod');
|
|
417
|
+
}
|
|
418
|
+
if (args.flags['unreachable-only']) {
|
|
419
|
+
scan.findings = (scan.findings || []).filter(f => f.mitigationVerdict === 'unreachable-in-prod');
|
|
420
|
+
scan.supplyChain = (scan.supplyChain || []).filter(f => f.mitigationVerdict === 'unreachable-in-prod');
|
|
421
|
+
}
|
|
422
|
+
// --persona <name> filter — keep only findings where the named persona
|
|
423
|
+
// appears in the top-2 ranked personas for the finding.
|
|
424
|
+
if (args.flags['persona']) {
|
|
425
|
+
const want = String(args.flags['persona']);
|
|
426
|
+
scan.findings = (scan.findings || []).filter(f =>
|
|
427
|
+
Array.isArray(f.personaTopTwo) && f.personaTopTwo.includes(want)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Deterministic post-process: stable-sort findings + zero out timing.
|
|
432
|
+
if (isDeterministic()) makeDeterministic(scan, meta);
|
|
433
|
+
|
|
434
|
+
// R2: Always emit machine-readable artifacts to .agentic-security/.
|
|
435
|
+
await writeMachineOutput(targetAbs, scan, meta, profile);
|
|
436
|
+
|
|
437
|
+
const includeSuppressed = !!args.flags['include-suppressed'];
|
|
438
|
+
let body;
|
|
439
|
+
if (format === 'json') body = JSON.stringify(toJSON(scan, meta, { includeSuppressed }), null, 2);
|
|
440
|
+
else if (format === 'md' || format === 'markdown') body = toMarkdown(scan, meta);
|
|
441
|
+
else if (format === 'sarif') body = JSON.stringify(toSARIF(scan, meta), null, 2);
|
|
442
|
+
else if (format === 'stix') body = JSON.stringify(toSTIX(scan, meta), null, 2);
|
|
443
|
+
else if (format === 'junit') body = toJUnit(scan, meta);
|
|
444
|
+
else if (format === 'csv') body = toCSV(scan);
|
|
445
|
+
else if (format === 'html') body = toHTML(scan, meta);
|
|
446
|
+
else if (format === 'cyclonedx' || format === 'sbom') body = JSON.stringify(toCycloneDX(scan, meta), null, 2);
|
|
447
|
+
else if (format === 'spdx') body = JSON.stringify(toSPDX(scan, meta), null, 2);
|
|
448
|
+
else if (format === 'pbom') body = JSON.stringify(toPBOM(scan.fc || {}, meta), null, 2);
|
|
449
|
+
else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, meta), null, 2);
|
|
450
|
+
else if (format === 'aibom-md') body = aibomToMarkdown(buildAIBOM(scan, scan.fc || {}, meta));
|
|
451
|
+
else if (format === 'ship') body = toShipVerdict(scan, { profile: effProfile });
|
|
452
|
+
else if (format === 'pro') body = toProTable(scan, { profile: effProfile, columns: args.flags.columns });
|
|
453
|
+
else if (format === 'cli') body = toCLIByProfile(scan, { profile: effProfile, columns: args.flags.columns, verbose });
|
|
454
|
+
else body = toSummary(scan);
|
|
455
|
+
|
|
456
|
+
// v3 next-gen — supplementary blocks for human-readable formats. These
|
|
457
|
+
// are append-only and do not change the verdict / exit code. The blocks
|
|
458
|
+
// are only meaningful when v3 annotators have run (default scan path).
|
|
459
|
+
if (format === 'cli' || format === 'ship' || format === 'pro' || format === 'md' || format === 'markdown') {
|
|
460
|
+
body += renderV3Blocks(scan, args.flags);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (output) await fsp.writeFile(output, body);
|
|
464
|
+
else process.stdout.write(body + '\n');
|
|
465
|
+
|
|
466
|
+
// Persist last scan for /security-fix and /security-report
|
|
467
|
+
const stateDir = path.join(path.resolve(target), '.agentic-security');
|
|
468
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
469
|
+
const persistedScan = toJSON(scan, meta);
|
|
470
|
+
const lastScanBody = JSON.stringify(persistedScan, null, 2);
|
|
471
|
+
await fsp.writeFile(path.join(stateDir, 'last-scan.json'), lastScanBody);
|
|
472
|
+
try {
|
|
473
|
+
await fsp.writeFile(path.join(stateDir, 'last-scan.json.sig'), _signLastScan(lastScanBody));
|
|
474
|
+
} catch { /* non-fatal — sig file is best-effort */ }
|
|
475
|
+
|
|
476
|
+
// 0.14.0 — update streak / achievements after every full scan. Suppress
|
|
477
|
+
// streak side effects when the user only wants raw JSON output (CI piping).
|
|
478
|
+
try {
|
|
479
|
+
const streak = recordScan(stateDir, persistedScan);
|
|
480
|
+
// Print celebration / streak line to stderr so it doesn't pollute --format json
|
|
481
|
+
if (process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
|
|
482
|
+
const delta = formatGradeDelta(streak);
|
|
483
|
+
const line = formatStreakLine(streak);
|
|
484
|
+
if (delta) process.stderr.write('\n' + delta + '\n');
|
|
485
|
+
if (line) process.stderr.write('🛡️ ' + line + '\n');
|
|
486
|
+
}
|
|
487
|
+
} catch {}
|
|
488
|
+
|
|
489
|
+
return exitCodeFor(scan);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// /scan-all — vibecoder one-screen verdict (internal CLI subcommand: `ship`).
|
|
493
|
+
//
|
|
494
|
+
// Always returns shell exit 0 for a valid verdict (clean, low, high, or
|
|
495
|
+
// critical findings). Only a real engine error (exit 4) propagates. The
|
|
496
|
+
// slash-command UX surfaces "Not safe to deploy" as the answer the user
|
|
497
|
+
// asked for — it's information, not a process failure. CI consumers
|
|
498
|
+
// needing severity-based gating should use the `ci` subcommand which has
|
|
499
|
+
// explicit `--fail-on` policy control.
|
|
500
|
+
async function cmdShip(args) {
|
|
501
|
+
const target = args._[1] || '.';
|
|
502
|
+
args.flags.format = 'ship';
|
|
503
|
+
const code = await cmdScan(args);
|
|
504
|
+
return code >= 4 ? code : 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Detect the PR base ref from common CI environment variables. Returns null
|
|
508
|
+
// if no CI baseline ref is in scope. The CLI --baseline flag takes precedence.
|
|
509
|
+
function detectBaseline() {
|
|
510
|
+
return process.env.GITHUB_BASE_REF
|
|
511
|
+
|| process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME // GitLab
|
|
512
|
+
|| process.env.BUILDKITE_PULL_REQUEST_BASE_BRANCH // Buildkite
|
|
513
|
+
|| process.env.BITBUCKET_PR_DESTINATION_BRANCH // Bitbucket
|
|
514
|
+
|| null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Translate a scan exit code (0..3) and a --fail-on threshold into a CI exit code.
|
|
518
|
+
// Returns 0 (pass) or 1 (fail).
|
|
519
|
+
function ciExitCode(scanExitCode, failOn) {
|
|
520
|
+
switch (failOn) {
|
|
521
|
+
case 'none': return 0;
|
|
522
|
+
case 'critical': default: return scanExitCode >= 3 ? 1 : 0;
|
|
523
|
+
case 'high': return scanExitCode >= 2 ? 1 : 0;
|
|
524
|
+
case 'medium':
|
|
525
|
+
case 'low': return scanExitCode >= 1 ? 1 : 0;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// `agentic-security ci [path] [--baseline <ref>] [--fail-on <sev>]`
|
|
530
|
+
// Single-shot CI command: auto-detects PR base ref, runs a baseline-aware scan,
|
|
531
|
+
// writes findings.{sarif,junit.xml,json} to .agentic-security/, and exits per
|
|
532
|
+
// the --fail-on policy.
|
|
533
|
+
async function cmdCi(args) {
|
|
534
|
+
const target = args._[1] || '.';
|
|
535
|
+
const targetAbs = path.resolve(target);
|
|
536
|
+
const failOn = args.flags['fail-on'] || 'critical';
|
|
537
|
+
const baseline = args.flags.baseline || detectBaseline();
|
|
538
|
+
|
|
539
|
+
if (baseline) process.stderr.write(`[ci] baseline: ${baseline}\n`);
|
|
540
|
+
else process.stderr.write(`[ci] full scan (no baseline ref detected)\n`);
|
|
541
|
+
|
|
542
|
+
const profile = loadPersonaProfile(targetAbs, args);
|
|
543
|
+
const { scan, meta } = await runScan(target, { changedSince: baseline || null });
|
|
544
|
+
|
|
545
|
+
// Apply suppressions + overrides + packs, mirroring cmdScan's pipeline.
|
|
546
|
+
scan.findings = applySuppressions(scan.findings || [], targetAbs, profile);
|
|
547
|
+
scan.secrets = applySuppressions(scan.secrets || [], targetAbs, profile);
|
|
548
|
+
scan.logicVulns = applySuppressions(scan.logicVulns || [], targetAbs, profile);
|
|
549
|
+
scan.supplyChain = applySuppressions(scan.supplyChain || [], targetAbs, profile);
|
|
550
|
+
scan.findings = applyOverrides(scan.findings || [], targetAbs);
|
|
551
|
+
scan.secrets = applyOverrides(scan.secrets || [], targetAbs);
|
|
552
|
+
scan.logicVulns = applyOverrides(scan.logicVulns || [], targetAbs);
|
|
553
|
+
const packArg = args.flags.pack;
|
|
554
|
+
const packNames = packArg ? (Array.isArray(packArg) ? packArg : String(packArg).split(',')) : [];
|
|
555
|
+
if (packNames.length) Object.assign(scan, applyPacks(scan, packNames));
|
|
556
|
+
|
|
557
|
+
// Persist the three CI artifacts.
|
|
558
|
+
const stateDir = path.join(targetAbs, '.agentic-security');
|
|
559
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
560
|
+
await fsp.writeFile(path.join(stateDir, 'findings.json'),
|
|
561
|
+
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
562
|
+
await fsp.writeFile(path.join(stateDir, 'findings.sarif'),
|
|
563
|
+
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
564
|
+
await fsp.writeFile(path.join(stateDir, 'findings.junit.xml'),
|
|
565
|
+
toJUnit(scan, meta));
|
|
566
|
+
|
|
567
|
+
const scanCode = exitCodeFor(scan);
|
|
568
|
+
const findings = normalizeFindings(scan);
|
|
569
|
+
const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
570
|
+
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
571
|
+
process.stderr.write(
|
|
572
|
+
`[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
|
|
573
|
+
`[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n` +
|
|
574
|
+
`[ci] fail-on=${failOn} scan-exit=${scanCode}\n`
|
|
575
|
+
);
|
|
576
|
+
// FR-SDLC-9: when --policy <file.rego> is supplied, evaluate against the
|
|
577
|
+
// findings and fail the gate if the policy denies anything. Policy runs
|
|
578
|
+
// ALONGSIDE the --fail-on threshold; either gate can fail the build.
|
|
579
|
+
const policyFile = args.flags.policy;
|
|
580
|
+
if (policyFile) {
|
|
581
|
+
const { evaluatePolicy } = await import('../src/posture/policy-gate.js');
|
|
582
|
+
const r = evaluatePolicy(path.resolve(policyFile), findings);
|
|
583
|
+
if (!r.ok) {
|
|
584
|
+
console.error(`[ci] policy gate error: ${r.reason || 'unknown'}`);
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
if (r.denials.length) {
|
|
588
|
+
console.error(`[ci] policy gate FAILED (${r.runner}, ${r.denials.length} denial(s)):`);
|
|
589
|
+
for (const d of r.denials.slice(0, 20)) console.error(` - ${d}`);
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
process.stderr.write(`[ci] policy gate PASSED (${r.runner}, 0 denials)\n`);
|
|
593
|
+
}
|
|
594
|
+
return ciExitCode(scanCode, failOn);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// /accept --finding <id> --reason "..." (vibecoder soft 30-day suppression)
|
|
598
|
+
async function cmdAccept(args) {
|
|
599
|
+
const target = path.resolve(args._[1] || '.');
|
|
600
|
+
const id = args.flags.finding;
|
|
601
|
+
if (!id) { console.error('--finding <id> required'); return 4; }
|
|
602
|
+
const reason = args.flags.reason || 'vibecoded for now';
|
|
603
|
+
const lastScanPath = path.join(target, '.agentic-security', 'findings.json');
|
|
604
|
+
if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
|
|
605
|
+
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
606
|
+
const f = (last.findings || []).find(x => x.id === id);
|
|
607
|
+
if (!f) { console.error(`Finding ${id} not found.`); return 4; }
|
|
608
|
+
// Disallow accepting criticals without explicit flag.
|
|
609
|
+
if (f.severity === 'critical' && !args.flags['accept-critical']) {
|
|
610
|
+
console.error('Cannot soft-accept a CRITICAL finding without --accept-critical.');
|
|
611
|
+
return 4;
|
|
612
|
+
}
|
|
613
|
+
const expires = addSoftAcceptance(target, f, reason);
|
|
614
|
+
console.log(`✓ Accepted finding ${id} until ${expires}.`);
|
|
615
|
+
console.log(` ${ATTRIBUTION}`);
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// /profile set <name> | /profile show
|
|
620
|
+
async function cmdProfile(args) {
|
|
621
|
+
const target = path.resolve(args._[2] || '.');
|
|
622
|
+
const sub = args._[1];
|
|
623
|
+
if (sub === 'show') {
|
|
624
|
+
const p = loadProfile(target);
|
|
625
|
+
console.log(`Profile: ${p.profile}`);
|
|
626
|
+
console.log(` confidence threshold: ${p.confidenceMin}`);
|
|
627
|
+
console.log(` taxonomy visible: ${p.showTaxonomy}`);
|
|
628
|
+
console.log(` suppression schema: ${p.suppression}`);
|
|
629
|
+
console.log(` machine output: ${p.machineOutput ? 'always' : 'on-request'}`);
|
|
630
|
+
console.log(` ${ATTRIBUTION}`);
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
if (sub === 'set') {
|
|
634
|
+
const name = args._[2];
|
|
635
|
+
if (name !== 'vibecoder' && name !== 'pro') {
|
|
636
|
+
console.error('profile set <vibecoder|pro>'); return 4;
|
|
637
|
+
}
|
|
638
|
+
const next = saveProfile(target, { profile: name });
|
|
639
|
+
console.log(`✓ Profile set to: ${next.profile}`);
|
|
640
|
+
return 0;
|
|
641
|
+
}
|
|
642
|
+
if (sub === 'detect') {
|
|
643
|
+
const detected = detectProfile(target);
|
|
644
|
+
console.log(`Detected profile: ${detected}`);
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
console.error('profile show | profile set <vibecoder|pro> | profile detect');
|
|
648
|
+
return 4;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// /triage list | assign | transition | trend
|
|
652
|
+
async function cmdTriage(args) {
|
|
653
|
+
const target = path.resolve(args._[args._.length - 1] && !args._[args._.length - 1].startsWith('--') ? args._[args._.length - 1] : '.');
|
|
654
|
+
const profile = loadProfile(target);
|
|
655
|
+
if (profile.profile !== 'pro') {
|
|
656
|
+
console.error('Triage is a pro-mode feature. Run `agentic-security profile set pro` to enable.');
|
|
657
|
+
return 4;
|
|
658
|
+
}
|
|
659
|
+
const sub = args._[1];
|
|
660
|
+
// Sync first so list reflects the latest scan.
|
|
661
|
+
const lastScanPath = path.join(target, '.agentic-security', 'findings.json');
|
|
662
|
+
if (fs.existsSync(lastScanPath)) {
|
|
663
|
+
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
664
|
+
triage.syncWithScan(target, last.findings || []);
|
|
665
|
+
}
|
|
666
|
+
if (sub === 'list') {
|
|
667
|
+
const filter = {};
|
|
668
|
+
if (args.flags.status) filter.state = args.flags.status;
|
|
669
|
+
if (args.flags.severity) filter.severity = args.flags.severity;
|
|
670
|
+
if (args.flags.assignee) filter.assignee = args.flags.assignee;
|
|
671
|
+
if (args.flags.unassigned) filter.unassigned = true;
|
|
672
|
+
const items = triage.list(target, filter);
|
|
673
|
+
const hdr = ['ID', 'Severity', 'State', 'Assignee', 'File:Line', 'Vuln'].join(' ');
|
|
674
|
+
console.log(hdr);
|
|
675
|
+
console.log('─'.repeat(80));
|
|
676
|
+
for (const t of items.slice(0, 50)) {
|
|
677
|
+
console.log([
|
|
678
|
+
t.id.slice(0, 16),
|
|
679
|
+
(t.severity || '').padEnd(8),
|
|
680
|
+
t.state.padEnd(13),
|
|
681
|
+
(t.assignee || '—').padEnd(20),
|
|
682
|
+
`${t.file}:${t.line}`.padEnd(40),
|
|
683
|
+
t.vuln,
|
|
684
|
+
].join(' '));
|
|
685
|
+
}
|
|
686
|
+
return 0;
|
|
687
|
+
}
|
|
688
|
+
if (sub === 'assign') {
|
|
689
|
+
const id = args._[2];
|
|
690
|
+
const assignee = args._[3] || args.flags.assignee;
|
|
691
|
+
if (!id || !assignee) { console.error('triage assign <id> <assignee>'); return 4; }
|
|
692
|
+
const r = triage.assign(target, id, assignee);
|
|
693
|
+
if (!r.ok) { console.error(r.error); return 4; }
|
|
694
|
+
console.log(`✓ Assigned ${id} to ${assignee}`); return 0;
|
|
695
|
+
}
|
|
696
|
+
if (sub === 'transition') {
|
|
697
|
+
const id = args._[2];
|
|
698
|
+
const state = args._[3];
|
|
699
|
+
const r = triage.transition(target, id, state, args.flags.comment);
|
|
700
|
+
if (!r.ok) { console.error(r.error); return 4; }
|
|
701
|
+
console.log(`✓ ${id} → ${state}`); return 0;
|
|
702
|
+
}
|
|
703
|
+
if (sub === 'trend') {
|
|
704
|
+
const days = parseInt(args.flags.since || '30', 10);
|
|
705
|
+
const t = triage.trend(target, days);
|
|
706
|
+
console.log(`Trend over ${t.sinceDays} days:`);
|
|
707
|
+
console.log(` Opened: ${t.opened}`);
|
|
708
|
+
console.log(` Closed: ${t.closed}`);
|
|
709
|
+
console.log(` Net: ${t.net} (${t.net <= 0 ? 'improving' : 'regressing'})`);
|
|
710
|
+
console.log(` Open: critical=${t.openBySev.critical} high=${t.openBySev.high} medium=${t.openBySev.medium} low=${t.openBySev.low}`);
|
|
711
|
+
if (t.medianMttrDays != null) console.log(` MTTR median: ${t.medianMttrDays.toFixed(1)} days`);
|
|
712
|
+
console.log(` Total open: ${t.totalOpen}`);
|
|
713
|
+
return 0;
|
|
714
|
+
}
|
|
715
|
+
console.error('triage list | assign <id> <assignee> | transition <id> <state> | trend [--since N]');
|
|
716
|
+
return 4;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// /org-scan — clone or visit multiple repos, run scan, produce roll-up.
|
|
720
|
+
async function cmdOrgScan(args) {
|
|
721
|
+
const reposCsv = args.flags.repos;
|
|
722
|
+
if (!reposCsv) { console.error('--repos <path1,path2,...> required'); return 4; }
|
|
723
|
+
const repos = reposCsv.split(',').map(s => s.trim()).filter(Boolean);
|
|
724
|
+
const workers = parseInt(args.flags.workers || '4', 10);
|
|
725
|
+
const rollup = { scannedAt: new Date().toISOString(), repos: [] };
|
|
726
|
+
|
|
727
|
+
console.log(`🛡 agentic-security org-scan — ${repos.length} repo(s), ${workers} worker(s)`);
|
|
728
|
+
console.log(` created by ClearCapabilities.Com`);
|
|
729
|
+
console.log('');
|
|
730
|
+
|
|
731
|
+
// Simple bounded concurrency.
|
|
732
|
+
const queue = repos.slice();
|
|
733
|
+
const active = [];
|
|
734
|
+
while (queue.length || active.length) {
|
|
735
|
+
while (active.length < workers && queue.length) {
|
|
736
|
+
const repo = queue.shift();
|
|
737
|
+
const p = (async () => {
|
|
738
|
+
const t0 = Date.now();
|
|
739
|
+
try {
|
|
740
|
+
const { scan, meta } = await runScan(repo);
|
|
741
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
742
|
+
for (const f of scan.findings || []) counts[f.severity || 'medium']++;
|
|
743
|
+
for (const f of scan.secrets || []) counts[f.severity || 'high']++;
|
|
744
|
+
rollup.repos.push({
|
|
745
|
+
repo,
|
|
746
|
+
scanned: scan.filesScanned || 0,
|
|
747
|
+
critical: counts.critical, high: counts.high, medium: counts.medium, low: counts.low,
|
|
748
|
+
elapsed_ms: Date.now() - t0,
|
|
749
|
+
});
|
|
750
|
+
console.log(` ✓ ${repo.padEnd(60)} crit=${counts.critical} high=${counts.high} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
|
|
751
|
+
} catch (e) {
|
|
752
|
+
rollup.repos.push({ repo, error: e.message });
|
|
753
|
+
console.log(` ✗ ${repo.padEnd(60)} ERROR: ${e.message}`);
|
|
754
|
+
}
|
|
755
|
+
})();
|
|
756
|
+
active.push(p);
|
|
757
|
+
p.finally(() => { const i = active.indexOf(p); if (i >= 0) active.splice(i, 1); });
|
|
758
|
+
}
|
|
759
|
+
if (active.length) await Promise.race(active);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const total = rollup.repos.reduce((acc, r) => ({
|
|
763
|
+
critical: acc.critical + (r.critical || 0), high: acc.high + (r.high || 0),
|
|
764
|
+
medium: acc.medium + (r.medium || 0), low: acc.low + (r.low || 0),
|
|
765
|
+
}), { critical: 0, high: 0, medium: 0, low: 0 });
|
|
766
|
+
console.log('');
|
|
767
|
+
console.log('Org-wide summary:');
|
|
768
|
+
console.log(` Critical: ${total.critical} High: ${total.high} Medium: ${total.medium} Low: ${total.low}`);
|
|
769
|
+
const sorted = rollup.repos.filter(r => !r.error).sort((a, b) => (b.critical + b.high) - (a.critical + a.high)).slice(0, 5);
|
|
770
|
+
if (sorted.length) {
|
|
771
|
+
console.log('');
|
|
772
|
+
console.log('Top 5 repos by critical+high:');
|
|
773
|
+
for (const r of sorted) console.log(` ${r.repo.padEnd(60)} crit=${r.critical} high=${r.high}`);
|
|
774
|
+
}
|
|
775
|
+
// Write rollup JSON.
|
|
776
|
+
const out = args.flags.output || 'org-scan-' + new Date().toISOString().slice(0, 10) + '.json';
|
|
777
|
+
await fsp.writeFile(out, JSON.stringify(rollup, null, 2));
|
|
778
|
+
console.log(`\nFull rollup: ${out}`);
|
|
779
|
+
return 0;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// /rules validate | rules lock
|
|
783
|
+
async function cmdRules(args) {
|
|
784
|
+
const target = path.resolve(args._[2] || '.');
|
|
785
|
+
const sub = args._[1];
|
|
786
|
+
if (sub === 'validate') {
|
|
787
|
+
const r = validateOverrides(target);
|
|
788
|
+
if (r.ok) { console.log('✓ rules.yml is valid'); return 0; }
|
|
789
|
+
console.error('rules.yml has errors:');
|
|
790
|
+
for (const e of r.errors) console.error(' - ' + e);
|
|
791
|
+
return 4;
|
|
792
|
+
}
|
|
793
|
+
if (sub === 'lock') {
|
|
794
|
+
const { path: fp, lock } = writeLockfile(target);
|
|
795
|
+
console.log(`✓ wrote ${fp}`);
|
|
796
|
+
console.log(` scanner: ${lock.scannerVersion} rulePackHash: ${lock.rulePackHash}`);
|
|
797
|
+
return 0;
|
|
798
|
+
}
|
|
799
|
+
console.error('rules validate | rules lock'); return 4;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// `agentic-security secure [--launch]` — smart router. One command picks the
|
|
803
|
+
// right next step based on project state.
|
|
804
|
+
// `agentic-security harness [path] [--include-home] [--format ...]`
|
|
805
|
+
// Multi-harness sweep — discovers .claude/ .cursor/ .codex/ .gemini/ .kiro/
|
|
806
|
+
// .opencode/ .trae/ .qwen/ etc. at the project root (and optionally under ~/)
|
|
807
|
+
// and runs the harness-config detectors directly on each file. Bypasses
|
|
808
|
+
// runScan's shouldScan filter (which excludes .json / .md by default) so
|
|
809
|
+
// the harness-config files actually get inspected.
|
|
810
|
+
async function cmdHarness(args) {
|
|
811
|
+
const root = path.resolve(args._[1] || '.');
|
|
812
|
+
const includeHome = !!args.flags['include-home'];
|
|
813
|
+
const { discoverHarnessConfigs, summarizeHarnessPresence } = await import('../src/posture/harness-discovery.js');
|
|
814
|
+
const { scanClaudeSettings } = await import('../src/sast/claude-settings.js');
|
|
815
|
+
const { scanClaudeMdPromptInjection } = await import('../src/sast/claude-md-prompt-injection.js');
|
|
816
|
+
const { scanClaudeHookInjection } = await import('../src/sast/claude-hook-injection.js');
|
|
817
|
+
const { scanMCP } = await import('../src/sast/mcp-audit.js');
|
|
818
|
+
const { scanCredentials } = await import('../src/secrets/index.js');
|
|
819
|
+
|
|
820
|
+
const fileContents = await discoverHarnessConfigs(root, { includeHome });
|
|
821
|
+
const present = summarizeHarnessPresence(fileContents);
|
|
822
|
+
const fileCount = Object.keys(fileContents).length;
|
|
823
|
+
process.stderr.write(`[harness] discovered harnesses: ${present.length ? present.join(', ') : '(none found)'}\n`);
|
|
824
|
+
process.stderr.write(`[harness] scanning ${fileCount} config file(s)${includeHome ? ' (incl. ~/)' : ''}\n`);
|
|
825
|
+
if (fileCount === 0) {
|
|
826
|
+
process.stdout.write('No harness configuration files found.\n');
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const findings = [];
|
|
831
|
+
const secrets = [];
|
|
832
|
+
for (const [fp, content] of Object.entries(fileContents)) {
|
|
833
|
+
try { findings.push(...scanClaudeSettings(fp, content)); } catch {}
|
|
834
|
+
try { findings.push(...scanClaudeMdPromptInjection(fp, content)); } catch {}
|
|
835
|
+
try { findings.push(...scanClaudeHookInjection(fp, content)); } catch {}
|
|
836
|
+
try { findings.push(...scanMCP(fp, content)); } catch {}
|
|
837
|
+
try { secrets.push(...scanCredentials(fp, content)); } catch {}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Annotate each finding with a stable id and confidence default so the
|
|
841
|
+
// ship verdict has something to render.
|
|
842
|
+
for (const f of findings) {
|
|
843
|
+
if (!f.confidence) f.confidence = 0.9;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const scan = {
|
|
847
|
+
findings,
|
|
848
|
+
secrets,
|
|
849
|
+
logicVulns: [],
|
|
850
|
+
supplyChain: [],
|
|
851
|
+
routes: [],
|
|
852
|
+
components: [],
|
|
853
|
+
suppressions: [],
|
|
854
|
+
filesScanned: fileCount,
|
|
855
|
+
fc: fileContents,
|
|
856
|
+
};
|
|
857
|
+
const meta = { startedAt: new Date().toISOString(), durationMs: 0, mode: 'harness' };
|
|
858
|
+
|
|
859
|
+
const format = args.flags.format || 'cli';
|
|
860
|
+
let body;
|
|
861
|
+
if (format === 'json') body = JSON.stringify(toJSON(scan, meta), null, 2);
|
|
862
|
+
else if (format === 'sarif') body = JSON.stringify(toSARIF(scan, meta), null, 2);
|
|
863
|
+
else if (format === 'md' || format === 'markdown') body = toMarkdown(scan, meta);
|
|
864
|
+
else if (format === 'ship') body = toShipVerdict(scan, { profile: { profile: 'vibecoder', confidenceMin: 0 } });
|
|
865
|
+
else body = toCLIByProfile(scan, { profile: { profile: 'pro', confidenceMin: 0 } });
|
|
866
|
+
// Append a one-line harness-presence footer to CLI output.
|
|
867
|
+
if ((format === 'cli' || format === 'ship') && present.length) {
|
|
868
|
+
body += `\n\nHarnesses discovered: ${present.join(', ')}${includeHome ? ' (project + ~/)' : ' (project only)'}\n`;
|
|
869
|
+
}
|
|
870
|
+
if (args.flags.output) await fsp.writeFile(args.flags.output, body);
|
|
871
|
+
else process.stdout.write(body + '\n');
|
|
872
|
+
return exitCodeFor(scan);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// `agentic-security scan-baseline --previous a.json --current b.json [--format cli|json]`
|
|
876
|
+
// Finding-level diff between two scan JSON outputs. Independent of scanner
|
|
877
|
+
// version (use the dedicated `agentic-security-diff` bin for that).
|
|
878
|
+
async function cmdScanBaseline(args) {
|
|
879
|
+
const prevPath = args.flags.previous;
|
|
880
|
+
const currPath = args.flags.current;
|
|
881
|
+
if (!prevPath || !currPath) {
|
|
882
|
+
console.error('Usage: agentic-security scan-baseline --previous <a.json> --current <b.json> [--format cli|json]');
|
|
883
|
+
return 2;
|
|
884
|
+
}
|
|
885
|
+
let prev, curr;
|
|
886
|
+
try { prev = JSON.parse(fs.readFileSync(prevPath, 'utf8')); }
|
|
887
|
+
catch (e) { console.error(`Cannot read previous scan: ${e.message}`); return 2; }
|
|
888
|
+
try { curr = JSON.parse(fs.readFileSync(currPath, 'utf8')); }
|
|
889
|
+
catch (e) { console.error(`Cannot read current scan: ${e.message}`); return 2; }
|
|
890
|
+
const { diffScans, renderDiff } = await import('../src/posture/baseline-compare.js');
|
|
891
|
+
const diff = diffScans(prev, curr);
|
|
892
|
+
if (args.flags.format === 'json') {
|
|
893
|
+
process.stdout.write(JSON.stringify({ summary: { added: diff.added.length, removed: diff.removed.length, changed: diff.changed.length, unchanged: diff.unchanged }, diff }, null, 2));
|
|
894
|
+
} else {
|
|
895
|
+
process.stdout.write(renderDiff(diff));
|
|
896
|
+
}
|
|
897
|
+
// Exit 0 if no delta, 1 if delta — useful for CI gating.
|
|
898
|
+
const hasDelta = diff.added.length || diff.removed.length || diff.changed.length;
|
|
899
|
+
return hasDelta ? 1 : 0;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function cmdSecure(args) {
|
|
903
|
+
const scanRoot = path.resolve(args._[1] || '.');
|
|
904
|
+
const intent = args.flags.launch ? 'launch' : (args.flags.deploy ? 'deploy' : null);
|
|
905
|
+
const decision = decideNextAction({ scanRoot, intent });
|
|
906
|
+
process.stdout.write(explainDecision(decision));
|
|
907
|
+
if (args.flags.json) process.stdout.write(JSON.stringify(decision, null, 2) + '\n');
|
|
908
|
+
if (args.flags.run && /^agentic-security /.test(decision.command)) {
|
|
909
|
+
process.stderr.write(`\n[secure] running: ${decision.command}\n`);
|
|
910
|
+
const sub = decision.command.replace(/^agentic-security /, '').split(' ');
|
|
911
|
+
process.argv = [process.argv[0], process.argv[1], ...sub];
|
|
912
|
+
return main();
|
|
913
|
+
}
|
|
914
|
+
return 0;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// `agentic-security tickets sync --provider github|linear|jira [--severity high]`
|
|
918
|
+
async function cmdTickets(args) {
|
|
919
|
+
const sub = args._[1];
|
|
920
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
921
|
+
if (sub === 'sync') {
|
|
922
|
+
const provider = args.flags.provider;
|
|
923
|
+
if (!provider) { console.error('--provider github|linear|jira required'); return 4; }
|
|
924
|
+
const r = await syncTickets({
|
|
925
|
+
scanRoot,
|
|
926
|
+
provider,
|
|
927
|
+
severity: args.flags.severity || 'high',
|
|
928
|
+
repo: args.flags.repo,
|
|
929
|
+
teamId: args.flags['team-id'],
|
|
930
|
+
dryRun: !!args.flags['dry-run'],
|
|
931
|
+
});
|
|
932
|
+
if (!r.ok) { console.error(r.error); return 4; }
|
|
933
|
+
console.log(`✓ tickets sync (${provider}${args.flags['dry-run'] ? ', dry-run' : ''})`);
|
|
934
|
+
console.log(` created: ${r.created.length} closed: ${r.closed.length} failed: ${r.failed.length} tracked: ${r.totalTracked}`);
|
|
935
|
+
for (const c of r.created.slice(0, 10)) console.log(` + ${c.externalId || '(dry-run)'} ${c.id}`);
|
|
936
|
+
for (const c of r.closed.slice(0, 10)) console.log(` ↩ ${c.externalId || '(dry-run)'} ${c.id}`);
|
|
937
|
+
for (const f of r.failed.slice(0, 10)) console.log(` ✗ ${f.id} ${f.error}`);
|
|
938
|
+
return r.failed.length ? 1 : 0;
|
|
939
|
+
}
|
|
940
|
+
if (sub === 'list') {
|
|
941
|
+
const { readState } = await import('../src/integrations/tickets.js');
|
|
942
|
+
const state = readState(scanRoot);
|
|
943
|
+
const entries = Object.entries(state);
|
|
944
|
+
if (!entries.length) { console.log('No tracked tickets.'); return 0; }
|
|
945
|
+
for (const [id, e] of entries) {
|
|
946
|
+
console.log(` ${e.state.padEnd(7)} ${e.provider.padEnd(7)} ${e.externalUrl || e.externalId} ${id}`);
|
|
947
|
+
}
|
|
948
|
+
return 0;
|
|
949
|
+
}
|
|
950
|
+
console.error('Usage: agentic-security tickets sync --provider <github|linear|jira> [--repo OWNER/REPO] [--team-id ID] [--severity high|critical] [--dry-run]');
|
|
951
|
+
return 4;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// `agentic-security rule test <fixture-glob>` — test custom rules against fixtures.
|
|
955
|
+
async function cmdRule(args) {
|
|
956
|
+
const sub = args._[1];
|
|
957
|
+
if (sub === 'test') {
|
|
958
|
+
const glob = args._[2];
|
|
959
|
+
if (!glob) { console.error('Usage: agentic-security rule test <fixture-glob>'); return 4; }
|
|
960
|
+
const target = path.resolve(args.flags.root || '.');
|
|
961
|
+
const r = await runRuleTests(target, glob);
|
|
962
|
+
return r.ok ? 0 : 4;
|
|
963
|
+
}
|
|
964
|
+
if (sub === 'list') {
|
|
965
|
+
const target = path.resolve(args.flags.root || '.');
|
|
966
|
+
const rules = loadCustomRules(target);
|
|
967
|
+
if (!rules.length) {
|
|
968
|
+
console.log(`No custom rules in ${path.join(target, '.agentic-security/rules/')}.`);
|
|
969
|
+
return 0;
|
|
970
|
+
}
|
|
971
|
+
for (const r of rules) console.log(` ${r.id} [${r.severity}] ${r.title}`);
|
|
972
|
+
return 0;
|
|
973
|
+
}
|
|
974
|
+
console.error('Usage: agentic-security rule test <glob> | rule list');
|
|
975
|
+
return 4;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// packs list — enumerate the curated rule packs available to --pack.
|
|
979
|
+
// Premortem 3R-14: validator-cache GC. .agentic-security/llm-cache/ grows
|
|
980
|
+
// without bound — every cache miss writes a small JSON. After months of CI
|
|
981
|
+
// runs, a project carries hundreds of MB of stale verdicts whose prompt or
|
|
982
|
+
// model versions no longer match. This subcommand prunes entries by age and
|
|
983
|
+
// by prompt-version mismatch.
|
|
984
|
+
async function cmdValidatorCache(args) {
|
|
985
|
+
const sub = args._[1] || 'help';
|
|
986
|
+
const root = path.resolve(args._[2] || '.');
|
|
987
|
+
const cacheDir = path.join(root, '.agentic-security', 'llm-cache');
|
|
988
|
+
if (!fs.existsSync(cacheDir)) {
|
|
989
|
+
console.log(`No validator cache at ${cacheDir}`);
|
|
990
|
+
return 0;
|
|
991
|
+
}
|
|
992
|
+
if (sub === 'list' || sub === 'stats') {
|
|
993
|
+
const entries = await fsp.readdir(cacheDir);
|
|
994
|
+
let total = 0, bytes = 0;
|
|
995
|
+
for (const f of entries) {
|
|
996
|
+
if (!f.endsWith('.json')) continue;
|
|
997
|
+
try {
|
|
998
|
+
const st = await fsp.stat(path.join(cacheDir, f));
|
|
999
|
+
total++; bytes += st.size;
|
|
1000
|
+
} catch {}
|
|
1001
|
+
}
|
|
1002
|
+
console.log(`validator cache: ${total} entries, ${(bytes / 1024).toFixed(1)} KB at ${cacheDir}`);
|
|
1003
|
+
return 0;
|
|
1004
|
+
}
|
|
1005
|
+
if (sub === 'gc' || sub === 'prune') {
|
|
1006
|
+
const olderThanDays = parseInt(args.flags['older-than'] || '30', 10);
|
|
1007
|
+
const dryRun = !!args.flags['dry-run'];
|
|
1008
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
1009
|
+
// Premortem 4R-15: use the public PROMPT_VERSION export rather than
|
|
1010
|
+
// reaching through the underscore-prefixed _internal API.
|
|
1011
|
+
const { PROMPT_VERSION } = await import('../src/llm-validator/index.js');
|
|
1012
|
+
if (!PROMPT_VERSION) {
|
|
1013
|
+
console.error('agentic-security: validator module did not export PROMPT_VERSION — refusing to GC (would prune everything).');
|
|
1014
|
+
return 4;
|
|
1015
|
+
}
|
|
1016
|
+
const wantedPromptVersion = PROMPT_VERSION;
|
|
1017
|
+
const entries = await fsp.readdir(cacheDir);
|
|
1018
|
+
let removed = 0, kept = 0, bytesFreed = 0;
|
|
1019
|
+
for (const f of entries) {
|
|
1020
|
+
if (!f.endsWith('.json')) continue;
|
|
1021
|
+
const fp = path.join(cacheDir, f);
|
|
1022
|
+
let st, body;
|
|
1023
|
+
try { st = await fsp.stat(fp); } catch { continue; }
|
|
1024
|
+
try { body = JSON.parse(await fsp.readFile(fp, 'utf8')); } catch { body = null; }
|
|
1025
|
+
const tooOld = st.mtimeMs < cutoff;
|
|
1026
|
+
const wrongVersion = body && wantedPromptVersion && body.prompt_version && body.prompt_version !== wantedPromptVersion;
|
|
1027
|
+
if (tooOld || wrongVersion) {
|
|
1028
|
+
if (!dryRun) { try { await fsp.unlink(fp); } catch {} }
|
|
1029
|
+
removed++; bytesFreed += st.size;
|
|
1030
|
+
} else { kept++; }
|
|
1031
|
+
}
|
|
1032
|
+
console.log(`${dryRun ? '[dry-run] would remove' : 'removed'} ${removed} entries (${(bytesFreed / 1024).toFixed(1)} KB), kept ${kept}.`);
|
|
1033
|
+
return 0;
|
|
1034
|
+
}
|
|
1035
|
+
console.error('Usage: agentic-security validator-cache <stats|gc> [path] [--older-than <days>] [--dry-run]');
|
|
1036
|
+
return 4;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// `agentic-security verify [--finding <id>] [--target <url>] [--live]`
|
|
1040
|
+
//
|
|
1041
|
+
// Re-runs the verifier loop over the most-recent scan. Without --live, it
|
|
1042
|
+
// validates each finding's PoC (refuses destructive payloads, hardcoded
|
|
1043
|
+
// metadata IPs, runaway lengths) and assigns a static verdict. With --live
|
|
1044
|
+
// AND --target, it actually executes each PoC in a Docker sandbox (or
|
|
1045
|
+
// subprocess fallback) against the supplied URL.
|
|
1046
|
+
//
|
|
1047
|
+
// FR-VER-7 fail-closed: any error → cannot-verify, never silent drop.
|
|
1048
|
+
async function cmdVerify(args) {
|
|
1049
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1050
|
+
const lastScanPath = path.join(scanRoot, '.agentic-security', 'last-scan.json');
|
|
1051
|
+
if (!fs.existsSync(lastScanPath)) {
|
|
1052
|
+
console.error(`No prior scan found at ${lastScanPath}. Run \`agentic-security scan\` first.`);
|
|
1053
|
+
return 4;
|
|
1054
|
+
}
|
|
1055
|
+
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
1056
|
+
const findings = last.findings || [];
|
|
1057
|
+
let targetFlag = args.flags.target || process.env.AGENTIC_SECURITY_VERIFY_TARGET || null;
|
|
1058
|
+
const liveFlag = !!args.flags.live || process.env.AGENTIC_SECURITY_VERIFY_LIVE === '1';
|
|
1059
|
+
// FR-LIVE-HARNESS: if no --target was supplied, check the
|
|
1060
|
+
// .agentic-security/verifier-target.yaml manifest. We don't bring up the
|
|
1061
|
+
// target here (that's the operator's call); we surface the URL it declares.
|
|
1062
|
+
if (liveFlag && !targetFlag) {
|
|
1063
|
+
const { loadTargetManifest, describeTarget, validateTarget } =
|
|
1064
|
+
await import('../src/posture/verifier-target.js');
|
|
1065
|
+
const m = loadTargetManifest(scanRoot);
|
|
1066
|
+
if (m.ok) {
|
|
1067
|
+
const v = validateTarget(m.target);
|
|
1068
|
+
if (!v.ok) {
|
|
1069
|
+
console.error(`Verifier target manifest rejected: ${v.reason}`);
|
|
1070
|
+
return 4;
|
|
1071
|
+
}
|
|
1072
|
+
targetFlag = m.target.url;
|
|
1073
|
+
console.error(`Verifier target: ${describeTarget(m.target)}`);
|
|
1074
|
+
console.error(`(Read from .agentic-security/verifier-target.yaml; bring it up yourself before re-running --live.)`);
|
|
1075
|
+
} else {
|
|
1076
|
+
console.error('--live requires --target <url>, AGENTIC_SECURITY_VERIFY_TARGET, or a .agentic-security/verifier-target.yaml manifest.');
|
|
1077
|
+
console.error(` Manifest check: ${m.reason}`);
|
|
1078
|
+
return 4;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (liveFlag) {
|
|
1082
|
+
// Set the env so verifier.js picks it up. We don't permanently mutate
|
|
1083
|
+
// process.env beyond this run.
|
|
1084
|
+
process.env.AGENTIC_SECURITY_VERIFY_LIVE = '1';
|
|
1085
|
+
process.env.AGENTIC_SECURITY_VERIFY_TARGET = targetFlag;
|
|
1086
|
+
}
|
|
1087
|
+
const { annotateVerifierVerdicts, verifierCoverageSummary } = await import('../src/posture/verifier.js');
|
|
1088
|
+
const filter = args.flags.finding ? findings.filter(f => f.id === args.flags.finding || f.stableId === args.flags.finding) : findings;
|
|
1089
|
+
if (!filter.length) {
|
|
1090
|
+
console.error(`No matching findings (use --finding <id>).`);
|
|
1091
|
+
return 4;
|
|
1092
|
+
}
|
|
1093
|
+
// Load file contents so sanitizer-absence proofs can run. Only load the
|
|
1094
|
+
// files referenced by the findings being verified, to keep this fast even
|
|
1095
|
+
// on large projects.
|
|
1096
|
+
const fileContents = {};
|
|
1097
|
+
const fileSet = new Set();
|
|
1098
|
+
for (const f of filter) {
|
|
1099
|
+
const fp = f.file || f.sink?.file;
|
|
1100
|
+
if (fp) fileSet.add(fp);
|
|
1101
|
+
}
|
|
1102
|
+
for (const rel of fileSet) {
|
|
1103
|
+
try {
|
|
1104
|
+
const abs = path.resolve(scanRoot, rel);
|
|
1105
|
+
const st = fs.statSync(abs);
|
|
1106
|
+
if (st.size <= 500_000) fileContents[rel] = fs.readFileSync(abs, 'utf8');
|
|
1107
|
+
} catch { /* file missing or unreadable; skip */ }
|
|
1108
|
+
}
|
|
1109
|
+
annotateVerifierVerdicts(filter, { target: targetFlag, fileContents });
|
|
1110
|
+
const sum = verifierCoverageSummary(filter);
|
|
1111
|
+
console.log(`Verified ${filter.length} finding(s):`);
|
|
1112
|
+
for (const [k, v] of Object.entries(sum)) console.log(` ${k}: ${v}`);
|
|
1113
|
+
if (args.flags.verbose || args.flags.finding) {
|
|
1114
|
+
for (const f of filter) {
|
|
1115
|
+
console.log(` ${f.file}:${f.line} ${f.vuln}`);
|
|
1116
|
+
console.log(` → ${f.verifier_verdict || 'none'} (${f.verifier_reason || 'no-reason'})${f.verifier_runner ? ' [' + f.verifier_runner + ']' : ''}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
// Persist back to last-scan.json so downstream tools see the verdicts.
|
|
1120
|
+
last.findings = findings;
|
|
1121
|
+
await fsp.writeFile(lastScanPath, JSON.stringify(last, null, 2));
|
|
1122
|
+
return 0;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// `agentic-security reset [--yes] [--keep <rules|streak|...>]`
|
|
1126
|
+
//
|
|
1127
|
+
// FR-LEARN-7 right-to-delete: wipes the learned-state files under
|
|
1128
|
+
// .agentic-security/ that the engine accumulates across runs:
|
|
1129
|
+
//
|
|
1130
|
+
// - validator-metrics.json (per-CWE TP/FP scorecard)
|
|
1131
|
+
// - triage-feedback.json (active-learning verdicts)
|
|
1132
|
+
// - llm-cache/* (LLM validator responses)
|
|
1133
|
+
// - scan-history.json (security-trend snapshots)
|
|
1134
|
+
// - fix-history/{log,backups} (auto-fix history)
|
|
1135
|
+
// - last-scan.json[.sig]
|
|
1136
|
+
// - shadow-findings.json
|
|
1137
|
+
// - mcp-audit.log
|
|
1138
|
+
// - hook-throttle.json
|
|
1139
|
+
// - tickets.json (two-way ticket sync state)
|
|
1140
|
+
//
|
|
1141
|
+
// Preserves by default:
|
|
1142
|
+
// - rules.yml (operator-authored, not learned)
|
|
1143
|
+
// - rules/ (custom rule files)
|
|
1144
|
+
// - license-policy.yml (operator-authored)
|
|
1145
|
+
// - trusted-keys.json (signing trust root)
|
|
1146
|
+
// - ruleset-version.json (pinning intent)
|
|
1147
|
+
//
|
|
1148
|
+
// Use --keep <names> (comma-separated) to preserve specific items;
|
|
1149
|
+
// --yes to skip the confirmation prompt (for scripted use).
|
|
1150
|
+
async function cmdReset(args) {
|
|
1151
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1152
|
+
const stateDir = path.join(scanRoot, '.agentic-security');
|
|
1153
|
+
if (!fs.existsSync(stateDir)) {
|
|
1154
|
+
console.log(`No state to reset at ${stateDir}`);
|
|
1155
|
+
return 0;
|
|
1156
|
+
}
|
|
1157
|
+
const WIPE = new Set([
|
|
1158
|
+
'validator-metrics.json',
|
|
1159
|
+
'triage-feedback.json',
|
|
1160
|
+
'scan-history.json',
|
|
1161
|
+
'last-scan.json',
|
|
1162
|
+
'last-scan.json.sig',
|
|
1163
|
+
'shadow-findings.json',
|
|
1164
|
+
'mcp-audit.log',
|
|
1165
|
+
'hook-throttle.json',
|
|
1166
|
+
'tickets.json',
|
|
1167
|
+
'streak.json',
|
|
1168
|
+
'findings.json',
|
|
1169
|
+
'findings.sarif',
|
|
1170
|
+
'findings.csv',
|
|
1171
|
+
]);
|
|
1172
|
+
const WIPE_DIRS = new Set([
|
|
1173
|
+
'llm-cache',
|
|
1174
|
+
'fix-history',
|
|
1175
|
+
'fix-plans',
|
|
1176
|
+
]);
|
|
1177
|
+
const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
|
|
1178
|
+
const targets = [];
|
|
1179
|
+
for (const entry of await fsp.readdir(stateDir, { withFileTypes: true })) {
|
|
1180
|
+
if (keep.has(entry.name)) continue;
|
|
1181
|
+
if (WIPE.has(entry.name) || WIPE_DIRS.has(entry.name)) {
|
|
1182
|
+
targets.push({ name: entry.name, dir: entry.isDirectory() });
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (!targets.length) {
|
|
1186
|
+
console.log(`Nothing to reset under ${stateDir}.`);
|
|
1187
|
+
return 0;
|
|
1188
|
+
}
|
|
1189
|
+
console.log(`agentic-security reset — will remove from ${stateDir}:`);
|
|
1190
|
+
for (const t of targets) console.log(` ${t.name}${t.dir ? '/' : ''}`);
|
|
1191
|
+
console.log('');
|
|
1192
|
+
console.log('Preserving operator-authored config: rules.yml, rules/, license-policy.yml, trusted-keys.json, ruleset-version.json');
|
|
1193
|
+
if (!args.flags.yes) {
|
|
1194
|
+
console.log('');
|
|
1195
|
+
console.log('Pass --yes to proceed (or --keep <name,name> to spare specific items).');
|
|
1196
|
+
return 0;
|
|
1197
|
+
}
|
|
1198
|
+
for (const t of targets) {
|
|
1199
|
+
const p = path.join(stateDir, t.name);
|
|
1200
|
+
try {
|
|
1201
|
+
if (t.dir) await fsp.rm(p, { recursive: true, force: true });
|
|
1202
|
+
else await fsp.rm(p, { force: true });
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
console.error(`reset: failed to remove ${p}: ${e.message}`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
console.log(`Reset ${targets.length} item(s). Operator-authored config preserved.`);
|
|
1208
|
+
return 0;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// `agentic-security rule-synth [--dry-run] [--threshold N]`
|
|
1212
|
+
//
|
|
1213
|
+
// FR-LEARN-6: read triage-feedback.json, group repeated FP verdicts by
|
|
1214
|
+
// (family, dir prefix), and propose a suppression YAML when ≥ threshold
|
|
1215
|
+
// (default 5) verdicts cluster. Writes to .agentic-security/rules-proposed/.
|
|
1216
|
+
async function cmdRuleSynth(args) {
|
|
1217
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1218
|
+
const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
|
|
1219
|
+
const proposals = synthesizeRules(scanRoot, {
|
|
1220
|
+
threshold: args.flags.threshold,
|
|
1221
|
+
dryRun: !!args.flags['dry-run'],
|
|
1222
|
+
});
|
|
1223
|
+
if (!proposals.length) {
|
|
1224
|
+
console.log('No proposals — either no triage feedback, or no shape clustered above threshold.');
|
|
1225
|
+
return 0;
|
|
1226
|
+
}
|
|
1227
|
+
console.log(`Synthesised ${proposals.length} proposal(s) in .agentic-security/rules-proposed/:`);
|
|
1228
|
+
for (const p of proposals) {
|
|
1229
|
+
console.log(` ${p.file} (${p.count} FPs, family=${p.family || p.rule}, glob=${p.dirGlob})`);
|
|
1230
|
+
}
|
|
1231
|
+
console.log('');
|
|
1232
|
+
console.log('Review each YAML before moving it to .agentic-security/rules/ to make it active.');
|
|
1233
|
+
return 0;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
async function cmdPacks(args) {
|
|
1237
|
+
const sub = args._[1] || 'list';
|
|
1238
|
+
if (sub !== 'list') { console.error('Usage: agentic-security packs list'); return 4; }
|
|
1239
|
+
const rows = listPacks();
|
|
1240
|
+
const namePad = Math.max(...rows.map(r => r.name.length));
|
|
1241
|
+
console.log('Available rule packs (use --pack <name>):\n');
|
|
1242
|
+
for (const r of rows) {
|
|
1243
|
+
console.log(` ${r.name.padEnd(namePad)} ${r.description} [${r.cweCount} CWEs]`);
|
|
1244
|
+
}
|
|
1245
|
+
return 0;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// /digest --slack <webhook> | --discord <webhook>
|
|
1249
|
+
async function cmdDigest(args) {
|
|
1250
|
+
const target = path.resolve(args._[1] || '.');
|
|
1251
|
+
const profile = loadProfile(target);
|
|
1252
|
+
const lastScanPath = path.join(target, '.agentic-security', 'findings.json');
|
|
1253
|
+
if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found.'); return 4; }
|
|
1254
|
+
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
1255
|
+
const findings = (last.findings || []).filter(f => f.severity === 'critical' || f.severity === 'high');
|
|
1256
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
1257
|
+
for (const f of (last.findings || [])) summary[f.severity || 'medium']++;
|
|
1258
|
+
const project = args.flags.project || path.basename(target);
|
|
1259
|
+
if (args.flags.slack) {
|
|
1260
|
+
const payload = buildSlackDigest(findings, summary, { project });
|
|
1261
|
+
const r = await postWebhook(args.flags.slack, payload);
|
|
1262
|
+
console.log(r.ok ? `✓ Slack digest sent` : `✗ Slack failed: ${r.reason || r.status}`);
|
|
1263
|
+
return r.ok ? 0 : 4;
|
|
1264
|
+
}
|
|
1265
|
+
if (args.flags.discord) {
|
|
1266
|
+
const payload = buildDiscordDigest(findings, summary, { project });
|
|
1267
|
+
const r = await postWebhook(args.flags.discord, payload);
|
|
1268
|
+
console.log(r.ok ? `✓ Discord digest sent` : `✗ Discord failed: ${r.reason || r.status}`);
|
|
1269
|
+
return r.ok ? 0 : 4;
|
|
1270
|
+
}
|
|
1271
|
+
console.error('digest --slack <url> OR digest --discord <url>'); return 4;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async function cmdFix(args) {
|
|
1275
|
+
const id = args.flags.finding;
|
|
1276
|
+
const isPreview = !!args.flags.preview;
|
|
1277
|
+
const isApply = !!args.flags.apply;
|
|
1278
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1279
|
+
if (!id) { console.error('--finding <id> required'); return 4; }
|
|
1280
|
+
const lastScanPath = path.join(scanRoot, '.agentic-security', 'last-scan.json');
|
|
1281
|
+
if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
|
|
1282
|
+
const lastScanBody = await fsp.readFile(lastScanPath, 'utf8');
|
|
1283
|
+
const sigVerified = _verifyLastScan(lastScanBody, lastScanPath + '.sig');
|
|
1284
|
+
if (sigVerified === false) {
|
|
1285
|
+
console.error('Warning: last-scan.json integrity check failed — file may have been modified outside the scanner. Re-run `agentic-security scan` to refresh.');
|
|
1286
|
+
}
|
|
1287
|
+
const last = JSON.parse(lastScanBody);
|
|
1288
|
+
const f = (last.findings || []).find(x => x.id === id) || (last.secrets || []).find(x => x.id === id);
|
|
1289
|
+
if (!f) { console.error(`Finding ${id} not found in last scan.`); return 4; }
|
|
1290
|
+
|
|
1291
|
+
// Default mode: print the canonical template (back-compat — security-fixer subagent applies it).
|
|
1292
|
+
if (!isPreview && !isApply) {
|
|
1293
|
+
console.log(JSON.stringify(f, null, 2));
|
|
1294
|
+
if (f.fix?.code) { console.log('\n--- suggested patch ---\n'); console.log(f.fix.code); }
|
|
1295
|
+
console.log('\nUse --preview to see a diff, or --apply to apply directly.');
|
|
1296
|
+
return 0;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Both --preview and --apply require an actual replacement to operate on.
|
|
1300
|
+
// For now we accept either f.fix.replacement (full new file content) or
|
|
1301
|
+
// f.fix.replaceLine (single-line replacement). Anything else falls back
|
|
1302
|
+
// to the template output and tells the user to run the security-fixer subagent.
|
|
1303
|
+
const absFile = path.resolve(scanRoot, f.file);
|
|
1304
|
+
if (!fs.existsSync(absFile)) { console.error(`File not found: ${absFile}`); return 4; }
|
|
1305
|
+
const originalContent = await fsp.readFile(absFile, 'utf8');
|
|
1306
|
+
let newContent = null;
|
|
1307
|
+
if (typeof f.fix?.replacement === 'string') newContent = f.fix.replacement;
|
|
1308
|
+
else if (typeof f.fix?.replaceLine === 'string' && f.line) {
|
|
1309
|
+
const lines = originalContent.split('\n');
|
|
1310
|
+
if (lines[f.line - 1] !== undefined) {
|
|
1311
|
+
lines[f.line - 1] = f.fix.replaceLine;
|
|
1312
|
+
newContent = lines.join('\n');
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (newContent === null) {
|
|
1317
|
+
console.error('No mechanical fix is available for this finding. Use the security-fixer subagent (default `fix` mode) and apply with `--apply` after it produces a replacement.');
|
|
1318
|
+
return 4;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (isPreview) {
|
|
1322
|
+
console.log(previewDiff(originalContent, newContent, f.file));
|
|
1323
|
+
console.log('\nRun with --apply to write this change. Use `agentic-security undo` to revert.');
|
|
1324
|
+
return 0;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// --apply. Premortem 4R-8: pass stableId from the engine directly so the
|
|
1328
|
+
// recover() cross-check is robust against line-number drift (f.id is
|
|
1329
|
+
// `${file}:${line}:${rule}` and rotates when the user edits the file).
|
|
1330
|
+
const entry = await applyFix({
|
|
1331
|
+
scanRoot, file: f.file, originalContent, newContent,
|
|
1332
|
+
findingId: f.id, stableId: f.stableId || null,
|
|
1333
|
+
ruleId: f.cwe || f.title, vuln: f.vuln || f.title,
|
|
1334
|
+
});
|
|
1335
|
+
console.log(`✓ applied fix ${entry.id} (file: ${entry.file})`);
|
|
1336
|
+
console.log(` backup: ${entry.backupPath}`);
|
|
1337
|
+
console.log(` revert with: agentic-security undo`);
|
|
1338
|
+
return 0;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// `agentic-security undo` — revert the most recent fix (or --all).
|
|
1342
|
+
async function cmdUndo(args) {
|
|
1343
|
+
const scanRoot = path.resolve(args.flags.root || '.');
|
|
1344
|
+
if (args.flags.list) {
|
|
1345
|
+
const log = listHistory(scanRoot);
|
|
1346
|
+
if (!log.length) { console.log('No fix history.'); return 0; }
|
|
1347
|
+
for (const e of log) {
|
|
1348
|
+
const status = e.reverted ? '↩ reverted' : '✓ applied ';
|
|
1349
|
+
console.log(` ${status} ${e.id} ${e.file} (${e.vuln || e.findingId})`);
|
|
1350
|
+
}
|
|
1351
|
+
return 0;
|
|
1352
|
+
}
|
|
1353
|
+
if (args.flags.compact) {
|
|
1354
|
+
// Premortem 3R-17: surface log compaction so operators can keep the
|
|
1355
|
+
// fix-history dir bounded on long-lived projects.
|
|
1356
|
+
const retainDays = parseInt(args.flags['retain-days'] || '90', 10);
|
|
1357
|
+
const r = await compactLog(scanRoot, { retainDays, pruneBackups: !!args.flags['prune-backups'] });
|
|
1358
|
+
console.log(`Compacted: archived ${r.archived} entries, retained ${r.kept} in active log.`);
|
|
1359
|
+
return 0;
|
|
1360
|
+
}
|
|
1361
|
+
if (args.flags.all) {
|
|
1362
|
+
const reverted = await undoAll(scanRoot);
|
|
1363
|
+
if (!reverted.length) { console.log('Nothing to revert.'); return 0; }
|
|
1364
|
+
for (const e of reverted) console.log(`↩ reverted ${e.id} ${e.file}`);
|
|
1365
|
+
console.log(`Reverted ${reverted.length} fix(es).`);
|
|
1366
|
+
return 0;
|
|
1367
|
+
}
|
|
1368
|
+
const r = await undoLast(scanRoot);
|
|
1369
|
+
if (!r) { console.log('Nothing to revert.'); return 0; }
|
|
1370
|
+
if (r.error) { console.error(r.error); return 4; }
|
|
1371
|
+
console.log(`↩ reverted ${r.id} ${r.file}`);
|
|
1372
|
+
console.log(` finding: ${r.vuln || r.findingId}`);
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async function cmdSetup(args) {
|
|
1377
|
+
const projectDir = path.resolve(args._[1] || '.');
|
|
1378
|
+
const commandsDir = path.join(projectDir, '.claude', 'commands');
|
|
1379
|
+
await fsp.mkdir(commandsDir, { recursive: true });
|
|
1380
|
+
const bundle = path.resolve(process.argv[1]);
|
|
1381
|
+
|
|
1382
|
+
const commands = {
|
|
1383
|
+
'security-scan-all.md': `---
|
|
1384
|
+
description: Run a full security scan (SAST + SCA + Secrets) on this project or a given path.
|
|
1385
|
+
argument-hint: "[path]"
|
|
1386
|
+
---
|
|
1387
|
+
\`\`\`bash
|
|
1388
|
+
node ${bundle} scan \${1:-.}; ec=$?; [ $ec -le 3 ] && exit 0 || exit $ec
|
|
1389
|
+
\`\`\`
|
|
1390
|
+
Output is a grouped summary: severity counts, finding types by frequency, top affected files.
|
|
1391
|
+
Use \`--format cli\` for the full per-finding list. Findings are always saved to \`.agentic-security/last-scan.json\`.
|
|
1392
|
+
If you see critical findings, run \`/fix-all --severity critical\` to remediate.
|
|
1393
|
+
`,
|
|
1394
|
+
'security-fix.md': `---
|
|
1395
|
+
description: Apply a remediation patch for a single finding from the last scan.
|
|
1396
|
+
argument-hint: "<finding-id>"
|
|
1397
|
+
---
|
|
1398
|
+
\`\`\`bash
|
|
1399
|
+
node ${bundle} fix --finding \${1}
|
|
1400
|
+
\`\`\`
|
|
1401
|
+
Hand the finding to the security-fixer subagent: read the file, apply the fix template adapted to the surrounding code, and run the project's test command. Do not declare done until the finding no longer reproduces on re-scan.
|
|
1402
|
+
`,
|
|
1403
|
+
'fix-all.md': `---
|
|
1404
|
+
description: Remediate every finding at or above a severity threshold (default: critical).
|
|
1405
|
+
argument-hint: "[--severity critical|high|medium]"
|
|
1406
|
+
---
|
|
1407
|
+
|
|
1408
|
+
Read \`.agentic-security/last-scan.json\`. For every finding at or above \`\${1:-critical}\` severity, dispatch the security-fixer subagent in sequence — not in parallel, as each fix may change subsequent findings. After each batch, re-run \`/security-scan-all\` to confirm. Stop and report if any test fails.
|
|
1409
|
+
`,
|
|
1410
|
+
'security-report.md': `---
|
|
1411
|
+
description: Generate an HTML security report (or JSON / Markdown / SARIF).
|
|
1412
|
+
argument-hint: "[--format html|json|md|sarif] [--output <file>]"
|
|
1413
|
+
---
|
|
1414
|
+
\`\`\`bash
|
|
1415
|
+
node ${bundle} scan . --format \${1:-html} --output \${2:-security-report.html}
|
|
1416
|
+
\`\`\`
|
|
1417
|
+
Default produces \`security-report.html\` — a self-contained interactive page with severity charts and filterable findings. Open with \`open security-report.html\`.
|
|
1418
|
+
`,
|
|
1419
|
+
'security-sca.md': `---
|
|
1420
|
+
description: Run a dependency vulnerability scan (SCA only) against this project.
|
|
1421
|
+
argument-hint: "[path]"
|
|
1422
|
+
---
|
|
1423
|
+
\`\`\`bash
|
|
1424
|
+
node ${bundle} scan \${1:-.} --only sca --format cli
|
|
1425
|
+
\`\`\`
|
|
1426
|
+
`,
|
|
1427
|
+
'security-secrets.md': `---
|
|
1428
|
+
description: Scan for leaked credentials and hardcoded secrets.
|
|
1429
|
+
argument-hint: "[path]"
|
|
1430
|
+
---
|
|
1431
|
+
\`\`\`bash
|
|
1432
|
+
node ${bundle} scan \${1:-.} --only secrets --format cli
|
|
1433
|
+
\`\`\`
|
|
1434
|
+
`,
|
|
1435
|
+
'security-triage.md': `---
|
|
1436
|
+
description: Validate scan findings for false positives and suppress confirmed FPs before reporting.
|
|
1437
|
+
argument-hint: "[--severity critical|high|all]"
|
|
1438
|
+
---
|
|
1439
|
+
|
|
1440
|
+
Read \`.agentic-security/last-scan.json\` and validate each finding at or above \`\${1:-critical}\` severity for false positives.
|
|
1441
|
+
|
|
1442
|
+
For each finding:
|
|
1443
|
+
1. Read the file at the reported path and extract ±20 lines around the flagged line
|
|
1444
|
+
2. Evaluate whether it is a **true positive** using these criteria:
|
|
1445
|
+
- **True positive**: user-controlled input demonstrably reaches the sink without validation — flag it
|
|
1446
|
+
- **False positive**: the value is validated against an allowlist / switch / explicit enum before the sink, the sink is a safe API overload (e.g. \`execFile\` with an array, parameterized query), the finding is in a test fixture or mock, or the "source" is an internal constant rather than external input
|
|
1447
|
+
3. For each confirmed false positive, add a suppression entry to \`.agentic-security/rules.yml\`:
|
|
1448
|
+
|
|
1449
|
+
\`\`\`yaml
|
|
1450
|
+
suppressions:
|
|
1451
|
+
- rule: "<vuln name from finding>"
|
|
1452
|
+
files: ["<file path>"]
|
|
1453
|
+
reason: "<one sentence: why this is a FP>"
|
|
1454
|
+
\`\`\`
|
|
1455
|
+
|
|
1456
|
+
If \`.agentic-security/rules.yml\` does not exist, create it with the suppressions block.
|
|
1457
|
+
|
|
1458
|
+
After processing all findings, print a summary table:
|
|
1459
|
+
|
|
1460
|
+
| File:Line | Vulnerability | Verdict | Reason |
|
|
1461
|
+
|---|---|---|---|
|
|
1462
|
+
| ... | ... | TP / FP | ... |
|
|
1463
|
+
|
|
1464
|
+
Then re-run the scan so suppressions take effect:
|
|
1465
|
+
|
|
1466
|
+
\`\`\`bash
|
|
1467
|
+
node ${bundle} scan .; ec=$?; [ $ec -le 3 ] && exit 0 || exit $ec
|
|
1468
|
+
\`\`\`
|
|
1469
|
+
|
|
1470
|
+
Do not suppress anything you are not certain is a false positive. When in doubt, mark it TP and leave remediation to \`/security-fix\`.
|
|
1471
|
+
`,
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
for (const [name, content] of Object.entries(commands)) {
|
|
1475
|
+
await fsp.writeFile(path.join(commandsDir, name), content);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const names = Object.keys(commands).map(f => '/' + f.replace('.md', '')).join(', ');
|
|
1479
|
+
console.log(`✓ Installed ${Object.keys(commands).length} command shortcuts in ${commandsDir}`);
|
|
1480
|
+
console.log(` ${names}`);
|
|
1481
|
+
console.log('');
|
|
1482
|
+
console.log('These work in this project only. Re-run in other projects as needed.');
|
|
1483
|
+
return 0;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
async function main() {
|
|
1487
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1488
|
+
const cmd = args._[0];
|
|
1489
|
+
try {
|
|
1490
|
+
switch (cmd) {
|
|
1491
|
+
case 'scan': process.exit(await cmdScan(args));
|
|
1492
|
+
case 'ship': process.exit(await cmdShip(args));
|
|
1493
|
+
case 'ci': process.exit(await cmdCi(args));
|
|
1494
|
+
case 'fix': process.exit(await cmdFix(args));
|
|
1495
|
+
case 'undo': process.exit(await cmdUndo(args));
|
|
1496
|
+
case 'accept': process.exit(await cmdAccept(args));
|
|
1497
|
+
case 'profile': process.exit(await cmdProfile(args));
|
|
1498
|
+
case 'triage': process.exit(await cmdTriage(args));
|
|
1499
|
+
case 'org-scan': process.exit(await cmdOrgScan(args));
|
|
1500
|
+
case 'rules': process.exit(await cmdRules(args));
|
|
1501
|
+
case 'rule': process.exit(await cmdRule(args));
|
|
1502
|
+
case 'tickets': process.exit(await cmdTickets(args));
|
|
1503
|
+
case 'secure': process.exit(await cmdSecure(args));
|
|
1504
|
+
case 'packs': process.exit(await cmdPacks(args));
|
|
1505
|
+
case 'validator-cache': process.exit(await cmdValidatorCache(args));
|
|
1506
|
+
case 'verify': process.exit(await cmdVerify(args));
|
|
1507
|
+
case 'reset': process.exit(await cmdReset(args));
|
|
1508
|
+
case 'rule-synth': process.exit(await cmdRuleSynth(args));
|
|
1509
|
+
case 'digest': process.exit(await cmdDigest(args));
|
|
1510
|
+
case 'setup': process.exit(await cmdSetup(args));
|
|
1511
|
+
case 'mcp': {
|
|
1512
|
+
const { runStdio } = await import('../src/mcp/stdio.js');
|
|
1513
|
+
const root = args.flags.root || process.env.AGENTIC_SECURITY_MCP_ROOT || process.cwd();
|
|
1514
|
+
runStdio({ sessionRoot: path.resolve(root) });
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
case 'cve-watch': {
|
|
1518
|
+
// Continuous CVE-watch daemon (one-shot). Polls OSV for the project's
|
|
1519
|
+
// dependency tree, fires the configured webhook on each new advisory.
|
|
1520
|
+
// Designed to be invoked from cron or a GitHub Action; the state file
|
|
1521
|
+
// (.agentic-security/cve-alerts-state.json) deduplicates across runs.
|
|
1522
|
+
const { runOnce } = await import('../src/posture/cve-alert-daemon.js');
|
|
1523
|
+
const root = args.flags.root || process.cwd();
|
|
1524
|
+
const r = await runOnce(path.resolve(root), {
|
|
1525
|
+
alertUrl: args.flags['alert-url'],
|
|
1526
|
+
alertType: args.flags['alert-type'],
|
|
1527
|
+
minSeverity: args.flags['min-severity'],
|
|
1528
|
+
dryRun: args.flags['dry-run'] === true,
|
|
1529
|
+
});
|
|
1530
|
+
if (args.flags.json) {
|
|
1531
|
+
// Stringify Set/etc. safely.
|
|
1532
|
+
console.log(JSON.stringify(r, null, 2));
|
|
1533
|
+
} else if (!r.ok) {
|
|
1534
|
+
console.error(`cve-watch: ${r.reason || 'failed'}`);
|
|
1535
|
+
}
|
|
1536
|
+
process.exit(r.ok ? 0 : 1);
|
|
1537
|
+
}
|
|
1538
|
+
case 'pr-delta': {
|
|
1539
|
+
// Shadowscan: compute the security delta between two git refs.
|
|
1540
|
+
// Useful in PR CI to show ONLY what changed, not the absolute
|
|
1541
|
+
// finding count. Pairs with `pr-comment` to render the result.
|
|
1542
|
+
const { computePrDelta, renderPrDeltaText } = await import('../src/pr-delta.js');
|
|
1543
|
+
const root = args.flags.root || process.cwd();
|
|
1544
|
+
const baseRef = args.flags.base || args.flags.b;
|
|
1545
|
+
const headRef = args.flags.head || args.flags.h || 'HEAD';
|
|
1546
|
+
if (!baseRef) { console.error('pr-delta: --base <ref> is required'); process.exit(2); }
|
|
1547
|
+
const delta = await computePrDelta(path.resolve(root), { baseRef, headRef });
|
|
1548
|
+
if (args.flags.json) console.log(JSON.stringify(delta, null, 2));
|
|
1549
|
+
else console.log(renderPrDeltaText(delta));
|
|
1550
|
+
// Exit non-zero if any critical/high introduced (useful as CI gate).
|
|
1551
|
+
const i = delta.summary?.introduced || {};
|
|
1552
|
+
const blocking = (i.critical || 0) + (i.high || 0);
|
|
1553
|
+
process.exit(args.flags['fail-on-introduced'] && blocking > 0 ? 1 : 0);
|
|
1554
|
+
}
|
|
1555
|
+
case 'pr-comment': {
|
|
1556
|
+
// Render the advisor-tone PR comment from a delta (stdin or
|
|
1557
|
+
// pr-delta --json output). Reads JSON from --in <path> or stdin.
|
|
1558
|
+
const { renderPrComment } = await import('../src/pr-comment.js');
|
|
1559
|
+
const { computePrDelta } = await import('../src/pr-delta.js');
|
|
1560
|
+
const fs2 = await import('node:fs');
|
|
1561
|
+
let delta;
|
|
1562
|
+
if (args.flags.base) {
|
|
1563
|
+
const root = args.flags.root || process.cwd();
|
|
1564
|
+
delta = await computePrDelta(path.resolve(root), {
|
|
1565
|
+
baseRef: args.flags.base, headRef: args.flags.head || 'HEAD',
|
|
1566
|
+
});
|
|
1567
|
+
} else if (args.flags.in) {
|
|
1568
|
+
delta = JSON.parse(fs2.readFileSync(args.flags.in, 'utf8'));
|
|
1569
|
+
} else {
|
|
1570
|
+
const data = await new Promise(r => {
|
|
1571
|
+
const chunks = []; process.stdin.on('data', c => chunks.push(c));
|
|
1572
|
+
process.stdin.on('end', () => r(Buffer.concat(chunks).toString('utf8')));
|
|
1573
|
+
});
|
|
1574
|
+
delta = JSON.parse(data);
|
|
1575
|
+
}
|
|
1576
|
+
const comment = renderPrComment(delta, {
|
|
1577
|
+
repoName: args.flags.repo, prNumber: args.flags.pr, prTitle: args.flags.title,
|
|
1578
|
+
});
|
|
1579
|
+
console.log(comment);
|
|
1580
|
+
process.exit(0);
|
|
1581
|
+
}
|
|
1582
|
+
case 'badge': {
|
|
1583
|
+
// Emit a live SVG badge from the most recent scan. Drop the URL
|
|
1584
|
+
// (or inline SVG) into README for pull-marketing.
|
|
1585
|
+
const { renderBadge } = await import('../src/badge.js');
|
|
1586
|
+
const root = args.flags.root || process.cwd();
|
|
1587
|
+
const format = args.flags.format || 'svg';
|
|
1588
|
+
const style = args.flags.style || 'flat';
|
|
1589
|
+
console.log(renderBadge({ format, style, scanRoot: path.resolve(root) }));
|
|
1590
|
+
process.exit(0);
|
|
1591
|
+
}
|
|
1592
|
+
case 'leaderboard-row': {
|
|
1593
|
+
// Generate one repo's leaderboard row (JSON). The future public
|
|
1594
|
+
// leaderboard at agentic-security.dev/leaderboard aggregates rows.
|
|
1595
|
+
const { leaderboardRowFor } = await import('../src/leaderboard.js');
|
|
1596
|
+
const root = args.flags.root || process.cwd();
|
|
1597
|
+
const repo = args.flags.repo;
|
|
1598
|
+
if (!repo) { console.error('leaderboard-row: --repo <owner/name> is required'); process.exit(2); }
|
|
1599
|
+
const row = leaderboardRowFor({ scanRoot: path.resolve(root), repo });
|
|
1600
|
+
console.log(JSON.stringify(row, null, 2));
|
|
1601
|
+
process.exit(0);
|
|
1602
|
+
}
|
|
1603
|
+
case 'history': {
|
|
1604
|
+
// Time-travel scan. Walk N historical git refs within --since,
|
|
1605
|
+
// scan each, emit a per-ref timeline + introduced/resolved deltas
|
|
1606
|
+
// between consecutive snapshots.
|
|
1607
|
+
const { runHistory } = await import('../src/history-scan.js');
|
|
1608
|
+
const root = args.flags.root || process.cwd();
|
|
1609
|
+
const r = await runHistory(path.resolve(root), {
|
|
1610
|
+
since: args.flags.since || '6.months',
|
|
1611
|
+
interval: args.flags.interval || '1.month',
|
|
1612
|
+
});
|
|
1613
|
+
if (args.flags.json) console.log(JSON.stringify(r, null, 2));
|
|
1614
|
+
else if (r.error) console.error(`history: ${r.error}`);
|
|
1615
|
+
else {
|
|
1616
|
+
console.log(`Scanned ${r.refs.length} refs.`);
|
|
1617
|
+
for (const ev of r.timeline) {
|
|
1618
|
+
console.log(` ${ev.fromWhen} → ${ev.toWhen}: +${ev.introducedN} introduced, -${ev.resolvedN} resolved`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
process.exit(r.error ? 1 : 0);
|
|
1622
|
+
}
|
|
1623
|
+
case 'what-if': {
|
|
1624
|
+
// Counterfactual scan. Apply file overlays + virtual deletes to
|
|
1625
|
+
// the working tree, scan, return delta vs. baseline.
|
|
1626
|
+
const { runWhatIf } = await import('../src/history-scan.js');
|
|
1627
|
+
const root = args.flags.root || process.cwd();
|
|
1628
|
+
const overlays = [];
|
|
1629
|
+
const overlayArg = args.flags.overlay;
|
|
1630
|
+
if (overlayArg) {
|
|
1631
|
+
// overlay format: <relpath>:<source-file>
|
|
1632
|
+
for (const spec of Array.isArray(overlayArg) ? overlayArg : [overlayArg]) {
|
|
1633
|
+
const idx = spec.indexOf(':');
|
|
1634
|
+
if (idx < 0) continue;
|
|
1635
|
+
const file = spec.slice(0, idx);
|
|
1636
|
+
const src = spec.slice(idx + 1);
|
|
1637
|
+
try {
|
|
1638
|
+
overlays.push({ file, content: (await import('node:fs')).readFileSync(src, 'utf8') });
|
|
1639
|
+
} catch (e) {
|
|
1640
|
+
console.error(`what-if: cannot read overlay source ${src}: ${e.message}`);
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
const remove = args.flags.remove
|
|
1646
|
+
? (Array.isArray(args.flags.remove) ? args.flags.remove : [args.flags.remove])
|
|
1647
|
+
: [];
|
|
1648
|
+
const r = await runWhatIf(path.resolve(root), { overlays, remove });
|
|
1649
|
+
if (args.flags.json) console.log(JSON.stringify(r, null, 2));
|
|
1650
|
+
else {
|
|
1651
|
+
console.log(`baseline: ${r.baselineFindings} findings`);
|
|
1652
|
+
console.log(`what-if: ${r.whatIfFindings} findings (delta ${r.delta >= 0 ? '+' : ''}${r.delta})`);
|
|
1653
|
+
if (r.introduced.length) {
|
|
1654
|
+
console.log(`Introduced by this counterfactual:`);
|
|
1655
|
+
for (const f of r.introduced.slice(0, 20)) {
|
|
1656
|
+
console.log(` + ${f.severity} ${f.vuln} (${f.file}:${f.line})`);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (r.removed.length) {
|
|
1660
|
+
console.log(`Removed by this counterfactual:`);
|
|
1661
|
+
for (const f of r.removed.slice(0, 20)) {
|
|
1662
|
+
console.log(` - ${f.severity} ${f.vuln} (${f.file}:${f.line})`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
process.exit(0);
|
|
1667
|
+
}
|
|
1668
|
+
case 'version': console.log('agentic-security 0.74.0 · created by ClearCapabilities.Com'); process.exit(0);
|
|
1669
|
+
case 'banner': { printBanner(args); process.exit(0); }
|
|
1670
|
+
case 'harness': process.exit(await cmdHarness(args));
|
|
1671
|
+
case 'scan-baseline': process.exit(await cmdScanBaseline(args));
|
|
1672
|
+
case 'help': case '--help': case '-h': case undefined:
|
|
1673
|
+
console.log(USAGE); process.exit(cmd ? 0 : 1);
|
|
1674
|
+
default:
|
|
1675
|
+
console.error(`Unknown command: ${cmd}\n\n${USAGE}`); process.exit(4);
|
|
1676
|
+
}
|
|
1677
|
+
} catch (e) {
|
|
1678
|
+
console.error('agentic-security: error:', e?.stack || e?.message || e);
|
|
1679
|
+
process.exit(4);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
main();
|