@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,1136 @@
|
|
|
1
|
+
// Report writers — JSON / Markdown / SARIF.
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
import { _isCustomSuppressed } from '../engine.js';
|
|
4
|
+
import { alertFace, approveFace } from './mascot.js';
|
|
5
|
+
import { SCANNER_VERSION } from '../posture/version.js';
|
|
6
|
+
|
|
7
|
+
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
8
|
+
const SEV_TO_SARIF = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'none' };
|
|
9
|
+
|
|
10
|
+
function fingerprint(f){
|
|
11
|
+
const s = `${f.file}:${f.line||f.source?.line||0}:${f.vuln||f.type||''}`;
|
|
12
|
+
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeFindings(scan){
|
|
16
|
+
const out = [];
|
|
17
|
+
// Feat-4: filter findings via custom suppressions, recording the suppression
|
|
18
|
+
// in scan.suppressions so it shows up under --include-suppressed.
|
|
19
|
+
const suppress = (vuln, file, line, snippet) => {
|
|
20
|
+
const sup = _isCustomSuppressed(vuln, file || '');
|
|
21
|
+
if (!sup) return false;
|
|
22
|
+
(scan.suppressions = scan.suppressions || []).push({
|
|
23
|
+
vuln, file, line, snippet: snippet || '',
|
|
24
|
+
reason: 'custom-rule:' + (sup.reason || 'rule.yml suppression'),
|
|
25
|
+
});
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
for (const f of (scan.findings||[])) {
|
|
29
|
+
if (suppress(f.vuln || f.type, f.file, f.line || f.source?.line || 0, f.snippet)) continue;
|
|
30
|
+
out.push({
|
|
31
|
+
id: f.id || fingerprint(f),
|
|
32
|
+
kind: f.isCrossFile ? 'sast' : (f.kind || 'sast'),
|
|
33
|
+
severity: f.severity || 'medium',
|
|
34
|
+
vuln: f.vuln || f.type,
|
|
35
|
+
cwe: f.cwe || null,
|
|
36
|
+
owaspLlm: f.owaspLlm || null,
|
|
37
|
+
stride: f.stride || null,
|
|
38
|
+
file: f.file,
|
|
39
|
+
line: f.line || f.source?.line || f.sink?.line || 0,
|
|
40
|
+
snippet: f.snippet || f.source?.snippet || f.sink?.snippet || '',
|
|
41
|
+
fix: f.fix ? { description: f.fix, code: f.code || '' } : null,
|
|
42
|
+
reachable: f.reachable ?? null,
|
|
43
|
+
triage: f.triageScore ?? null,
|
|
44
|
+
dataClasses: f.dataClasses || [],
|
|
45
|
+
chain: Array.isArray(f.chain) ? f.chain : null,
|
|
46
|
+
confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
47
|
+
// 0.6.0 Feat-2
|
|
48
|
+
toxicity: f.toxicityScore ?? null,
|
|
49
|
+
toxicityFactors: f.toxicityFactors || null,
|
|
50
|
+
toxicityLabel: f.toxicityLabel || null,
|
|
51
|
+
sources: Array.isArray(f.sources) && f.sources.length ? f.sources : null,
|
|
52
|
+
epssScore: f.epssScore ?? null,
|
|
53
|
+
epssPercentile: f.epssPercentile ?? null,
|
|
54
|
+
epssCve: f.epssCve || null,
|
|
55
|
+
exploitedNow: f.exploitedNow === true,
|
|
56
|
+
tags: Array.isArray(f.tags) && f.tags.length ? f.tags : null,
|
|
57
|
+
blastRadius: f.blastRadius || null,
|
|
58
|
+
// Sentinel-parity (FR-PREC, FR-L3) preserved fields.
|
|
59
|
+
stableId: f.stableId || null,
|
|
60
|
+
confidenceTier: f.confidenceTier || null,
|
|
61
|
+
exploitability: typeof f.exploitability === 'number' ? f.exploitability : null,
|
|
62
|
+
exploitabilityTier: f.exploitabilityTier || null,
|
|
63
|
+
exploitabilityFactors: Array.isArray(f.exploitabilityFactors) ? f.exploitabilityFactors : null,
|
|
64
|
+
clusterSize: typeof f.clusterSize === 'number' ? f.clusterSize : null,
|
|
65
|
+
unreachable: f.unreachable === true,
|
|
66
|
+
validator_verdict: f.validator_verdict || null,
|
|
67
|
+
llm_confidence: typeof f.llm_confidence === 'number' ? f.llm_confidence : null,
|
|
68
|
+
unvalidated: f.unvalidated === true,
|
|
69
|
+
cross_language: f.cross_language === true,
|
|
70
|
+
family: f.family || null,
|
|
71
|
+
// Premortem #8: surface the parser field so downstream consumers
|
|
72
|
+
// (UI, SARIF, calibration) see the value the engine backfilled.
|
|
73
|
+
parser: f.parser || null,
|
|
74
|
+
// Premortem 3R-6 + 4R-10: audit-provenance fields. Collapse the two
|
|
75
|
+
// bool flags into one tri-state `signatureStatus` so consumers don't
|
|
76
|
+
// have to know that passThroughSigning supersedes unsigned. The legacy
|
|
77
|
+
// flags are kept on the normalized finding for one release of grace.
|
|
78
|
+
_unsigned: f._unsigned === true,
|
|
79
|
+
_passThroughSigning: f._passThroughSigning === true,
|
|
80
|
+
signatureStatus: f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified'),
|
|
81
|
+
// Phase-1 next-gen P1.1 (FR-VER-2): generated PoC, or null if the CWE
|
|
82
|
+
// family has no template in v1. `poc.code` is the runnable script;
|
|
83
|
+
// `poc.runHint` is the suggested invocation (e.g. `node poc.mjs`).
|
|
84
|
+
regression_test: f.regression_test && typeof f.regression_test === 'object' ? {
|
|
85
|
+
lang: f.regression_test.lang || null,
|
|
86
|
+
framework: f.regression_test.framework || null,
|
|
87
|
+
filename: f.regression_test.filename || null,
|
|
88
|
+
runHint: f.regression_test.runHint || null,
|
|
89
|
+
code: typeof f.regression_test.code === 'string' ? f.regression_test.code : null,
|
|
90
|
+
} : null,
|
|
91
|
+
poc: f.poc && typeof f.poc === 'object' ? {
|
|
92
|
+
lang: f.poc.lang || null,
|
|
93
|
+
kind: f.poc.kind || null,
|
|
94
|
+
cwe: f.poc.cwe || null,
|
|
95
|
+
family: f.poc.family || null,
|
|
96
|
+
runHint: f.poc.runHint || null,
|
|
97
|
+
code: typeof f.poc.code === 'string' ? f.poc.code : null,
|
|
98
|
+
// Harness-engineering: surface inference confidence so verifiers and
|
|
99
|
+
// regression-test-gen consumers can refuse to run low-confidence ones.
|
|
100
|
+
paramKey: f.poc.paramKey || null,
|
|
101
|
+
paramKeyConfidence: f.poc.paramKeyConfidence || null,
|
|
102
|
+
paramKeyInferred: typeof f.poc.paramKeyInferred === 'boolean' ? f.poc.paramKeyInferred : null,
|
|
103
|
+
} : null,
|
|
104
|
+
// Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
|
|
105
|
+
// 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
|
|
106
|
+
// for this family; `calibration_reason` explains why.
|
|
107
|
+
calibrated_confidence: typeof f.calibrated_confidence === 'number' ? f.calibrated_confidence : null,
|
|
108
|
+
calibrated_confidence_ci: Array.isArray(f.calibrated_confidence_ci) ? f.calibrated_confidence_ci : null,
|
|
109
|
+
calibrated_n: typeof f.calibrated_n === 'number' ? f.calibrated_n : 0,
|
|
110
|
+
calibration_reason: f.calibration_reason || null,
|
|
111
|
+
// Phase-1 next-gen P1.2 (FR-VER-6): verifier verdict.
|
|
112
|
+
verifier_verdict: f.verifier_verdict || null,
|
|
113
|
+
verifier_reason: f.verifier_reason || null,
|
|
114
|
+
verifier_runner: f.verifier_runner || null,
|
|
115
|
+
narration: typeof f.narration === 'string' ? f.narration : null,
|
|
116
|
+
// v3 next-gen — Pillars 8 (Adversary Simulation) + 9 (Production-Aware
|
|
117
|
+
// Reasoning) + spec/clone/ai/why-fired property bag.
|
|
118
|
+
mitigationVerdict: f.mitigationVerdict || null,
|
|
119
|
+
mitigationsApplied: Array.isArray(f.mitigationsApplied) ? f.mitigationsApplied : null,
|
|
120
|
+
mitigatedByWaf: f.mitigatedByWaf === true,
|
|
121
|
+
wafRuleId: f.wafRuleId || null,
|
|
122
|
+
mitigatedByAuth: f.mitigatedByAuth === true,
|
|
123
|
+
authMechanism: f.authMechanism || null,
|
|
124
|
+
mitigatedByNetwork: f.mitigatedByNetwork === true,
|
|
125
|
+
networkExposure: f.networkExposure || null,
|
|
126
|
+
featureFlag: f.featureFlag || null,
|
|
127
|
+
featureFlagState: f.featureFlagState || null,
|
|
128
|
+
featureFlagRollout: typeof f.featureFlagRollout === 'number' ? f.featureFlagRollout : null,
|
|
129
|
+
exposedInProd: f.exposedInProd === true,
|
|
130
|
+
unreachableInProd: f.unreachableInProd === true,
|
|
131
|
+
coldPath: f.coldPath === true,
|
|
132
|
+
hotPath: f.hotPath === true,
|
|
133
|
+
prodRequestCount: typeof f.prodRequestCount === 'number' ? f.prodRequestCount : null,
|
|
134
|
+
crownJewelScore: typeof f.crownJewelScore === 'number' ? f.crownJewelScore : null,
|
|
135
|
+
crownJewelTier: f.crownJewelTier || null,
|
|
136
|
+
crownJewelFactors: Array.isArray(f.crownJewelFactors) ? f.crownJewelFactors : null,
|
|
137
|
+
cloneClusterId: f.cloneClusterId || null,
|
|
138
|
+
cloneClusterSize: typeof f.cloneClusterSize === 'number' ? f.cloneClusterSize : null,
|
|
139
|
+
provenance: f.provenance || null,
|
|
140
|
+
provenanceScore: typeof f.provenanceScore === 'number' ? f.provenanceScore : null,
|
|
141
|
+
typeNarrowed: f.typeNarrowed || null,
|
|
142
|
+
strideCategory: f.strideCategory || null,
|
|
143
|
+
personaScores: f.personaScores || null,
|
|
144
|
+
personaTopTwo: Array.isArray(f.personaTopTwo) ? f.personaTopTwo : null,
|
|
145
|
+
personaMaxName: f.personaMaxName || null,
|
|
146
|
+
personaMaxScore: typeof f.personaMaxScore === 'number' ? f.personaMaxScore : null,
|
|
147
|
+
reverseExposure: f.reverseExposure || null,
|
|
148
|
+
specMined: f.specMined || null,
|
|
149
|
+
whyFired: f.whyFired || null,
|
|
150
|
+
adversaryTranscript: f.adversaryTranscript || null,
|
|
151
|
+
// v3 next-gen — bounty + playbook fields.
|
|
152
|
+
predictedBountyUsd: f.predictedBountyUsd || null,
|
|
153
|
+
bountyConfidence: f.bountyConfidence || null,
|
|
154
|
+
attackPlaybook: f.attackPlaybook || null,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const s of (scan.secrets||[])) {
|
|
158
|
+
if (suppress(s.vuln || 'Hardcoded Secret', s.file, s.line, s.snippet)) continue;
|
|
159
|
+
out.push({
|
|
160
|
+
id: s.id || fingerprint(s),
|
|
161
|
+
kind: 'secret',
|
|
162
|
+
severity: s.severity || 'high',
|
|
163
|
+
vuln: s.vuln || 'Hardcoded Secret',
|
|
164
|
+
cwe: s.cwe || 'CWE-798',
|
|
165
|
+
stride: s.stride || 'Information Disclosure',
|
|
166
|
+
file: s.file, line: s.line, snippet: s.snippet || '',
|
|
167
|
+
masked: s.masked || null,
|
|
168
|
+
fix: s.fix ? { description: s.fix, code: s.code || '' } : null,
|
|
169
|
+
blastRadius: s.blastRadius || null,
|
|
170
|
+
// Premortem #8: parser/family for downstream confidence + calibration.
|
|
171
|
+
parser: s.parser || 'SECRETS',
|
|
172
|
+
family: s.family || 'hardcoded-secret',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
for (const lv of (scan.logicVulns||[])) {
|
|
176
|
+
if (suppress(lv.vuln, lv.file, lv.line, lv.snippet)) continue;
|
|
177
|
+
out.push({
|
|
178
|
+
id: lv.id || fingerprint(lv),
|
|
179
|
+
kind: lv.kind || 'logic',
|
|
180
|
+
severity: lv.severity || 'medium',
|
|
181
|
+
vuln: lv.vuln,
|
|
182
|
+
cwe: lv.cwe || null,
|
|
183
|
+
stride: lv.stride || null,
|
|
184
|
+
file: lv.file, line: lv.line, snippet: lv.snippet || '',
|
|
185
|
+
fix: lv.fix ? { description: lv.fix, code: lv.code || '' } : null,
|
|
186
|
+
blastRadius: lv.blastRadius || null,
|
|
187
|
+
// Premortem #8.
|
|
188
|
+
parser: lv.parser || 'LOGIC',
|
|
189
|
+
family: lv.family || null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
for (const sc of (scan.supplyChain||[])) {
|
|
193
|
+
const scVuln = sc.vuln || sc.advisory || 'Vulnerable Dependency';
|
|
194
|
+
const scFile = sc.filePath || sc.file || 'package.json';
|
|
195
|
+
if (suppress(scVuln, scFile, 0, sc.description || '')) continue;
|
|
196
|
+
out.push({
|
|
197
|
+
id: fingerprint(sc),
|
|
198
|
+
kind: 'sca',
|
|
199
|
+
severity: sc.severity || 'high',
|
|
200
|
+
vuln: scVuln,
|
|
201
|
+
cwe: sc.cwe || null,
|
|
202
|
+
stride: null,
|
|
203
|
+
file: scFile,
|
|
204
|
+
line: 0,
|
|
205
|
+
// Premortem #8.
|
|
206
|
+
parser: 'SCA',
|
|
207
|
+
family: 'vulnerable-dep',
|
|
208
|
+
ecosystem: sc.ecosystem,
|
|
209
|
+
package: sc.name,
|
|
210
|
+
version: sc.version,
|
|
211
|
+
cveAliases: sc.cveAliases || [],
|
|
212
|
+
osvId: sc.osvId || null,
|
|
213
|
+
advisory: sc.advisory || sc.description || '',
|
|
214
|
+
fixedIn: sc.range || null,
|
|
215
|
+
// Feat-9: real-world risk signals
|
|
216
|
+
epssScore: sc.epssScore ?? null,
|
|
217
|
+
epssPercentile: sc.epssPercentile ?? null,
|
|
218
|
+
epssCve: sc.epssCve || null,
|
|
219
|
+
exploitedNow: sc.exploitedNow === true,
|
|
220
|
+
tags: Array.isArray(sc.tags) && sc.tags.length ? sc.tags : null,
|
|
221
|
+
blastRadius: sc.blastRadius || null,
|
|
222
|
+
cvssVector: sc.cvssVector || null,
|
|
223
|
+
functionReachable: sc.functionReachable || null,
|
|
224
|
+
// 0.10.0: CISA KEV — actively abused in the wild
|
|
225
|
+
kev: sc.kev === true,
|
|
226
|
+
kevDateAdded: sc.kevDateAdded || null,
|
|
227
|
+
kevRansomware: sc.kevRansomware === true,
|
|
228
|
+
weaponized: sc.weaponized === true,
|
|
229
|
+
// 0.6.0 Feat-2: toxicity score
|
|
230
|
+
toxicity: sc.toxicityScore ?? null,
|
|
231
|
+
toxicityFactors: sc.toxicityFactors || null,
|
|
232
|
+
toxicityLabel: sc.toxicityLabel || null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
// Sort by severity tier, then within a tier by EPSS percentile (desc) so that
|
|
236
|
+
// CVEs with active in-the-wild abuse float above theoretical CVEs.
|
|
237
|
+
return out.sort((a, b) => {
|
|
238
|
+
const sevDiff = (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9);
|
|
239
|
+
if (sevDiff !== 0) return sevDiff;
|
|
240
|
+
const ae = a.epssPercentile ?? -1;
|
|
241
|
+
const be = b.epssPercentile ?? -1;
|
|
242
|
+
return be - ae;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Feat-5: group findings that share a likely root cause so the fixer subagent
|
|
247
|
+
// can patch the helper once instead of N call sites. Heuristic: same vuln type,
|
|
248
|
+
// same sink "type" (Database Query, OS Command, etc.), and ≥80% overlap of
|
|
249
|
+
// usedVars across the group.
|
|
250
|
+
export function bundleFindingsByRootCause(findings){
|
|
251
|
+
const bundles = [];
|
|
252
|
+
const remaining = [];
|
|
253
|
+
// Bucket by (vuln, sinkType)
|
|
254
|
+
const buckets = new Map();
|
|
255
|
+
for (const f of findings) {
|
|
256
|
+
const sinkType = f.sink?.type || f.kind || 'unknown';
|
|
257
|
+
const key = `${f.vuln}::${sinkType}`;
|
|
258
|
+
(buckets.get(key) || buckets.set(key, []).get(key)).push(f);
|
|
259
|
+
}
|
|
260
|
+
for (const [key, group] of buckets) {
|
|
261
|
+
if (group.length < 3) { remaining.push(...group); continue; }
|
|
262
|
+
// Need actual usedVars to compare — only the SAST sink path has these
|
|
263
|
+
const withVars = group.filter(f => Array.isArray(f.sink?.usedVars) && f.sink.usedVars.length);
|
|
264
|
+
if (withVars.length < 3) { remaining.push(...group); continue; }
|
|
265
|
+
// Use the most common var across the group as the bundle key
|
|
266
|
+
const tally = new Map();
|
|
267
|
+
for (const f of withVars) for (const v of f.sink.usedVars) tally.set(v, (tally.get(v) || 0) + 1);
|
|
268
|
+
let bestVar = null, bestCount = 0;
|
|
269
|
+
for (const [v, c] of tally) if (c > bestCount && c >= Math.ceil(withVars.length * 0.8)) { bestVar = v; bestCount = c; }
|
|
270
|
+
if (!bestVar) { remaining.push(...group); continue; }
|
|
271
|
+
const children = withVars.filter(f => f.sink.usedVars.includes(bestVar));
|
|
272
|
+
if (children.length < 3) { remaining.push(...group); continue; }
|
|
273
|
+
const [vuln, sinkType] = key.split('::');
|
|
274
|
+
const bundleId = `bundle:${bestVar}:${vuln.replace(/\s/g, '_')}`;
|
|
275
|
+
for (const c of children) c.bundleId = bundleId;
|
|
276
|
+
bundles.push({
|
|
277
|
+
bundleId, vuln, sinkType,
|
|
278
|
+
sharedHelper: bestVar,
|
|
279
|
+
childCount: children.length,
|
|
280
|
+
childIds: children.map(c => c.id || `${c.file}:${c.line || c.source?.line || 0}`),
|
|
281
|
+
severity: children[0].severity,
|
|
282
|
+
cwe: children[0].cwe,
|
|
283
|
+
summary: `${children.length} ${vuln} findings share \`${bestVar}\`. Refactor at the helper for one patch, ${children.length} resolutions.`,
|
|
284
|
+
});
|
|
285
|
+
// Keep all children in the remaining list too — they still appear individually
|
|
286
|
+
// with bundleId set, but the bundle entry surfaces the root-cause story.
|
|
287
|
+
remaining.push(...group);
|
|
288
|
+
}
|
|
289
|
+
return { bundles, findings: remaining };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function toJSON(scan, meta={}, opts={}){
|
|
293
|
+
const findings = normalizeFindings(scan);
|
|
294
|
+
// Feat-5: surface root-cause bundles alongside individual findings.
|
|
295
|
+
// Each child finding now has bundleId set so the fixer subagent can
|
|
296
|
+
// detect "this is one of N findings that share a helper".
|
|
297
|
+
const { bundles } = bundleFindingsByRootCause(findings.map(f => {
|
|
298
|
+
// Pull sink.usedVars off the original raw finding (lost during normalize)
|
|
299
|
+
const raw = (scan.findings || []).find(r => (r.id || '') === f.id);
|
|
300
|
+
return raw?.sink ? { ...f, sink: raw.sink } : f;
|
|
301
|
+
}));
|
|
302
|
+
const out = {
|
|
303
|
+
scanId: meta.scanId || crypto.randomUUID(),
|
|
304
|
+
startedAt: meta.startedAt || new Date().toISOString(),
|
|
305
|
+
durationMs: meta.durationMs || 0,
|
|
306
|
+
scanned: { files: scan.filesScanned||0, lines: scan.linesScanned||0 },
|
|
307
|
+
findings,
|
|
308
|
+
bundles,
|
|
309
|
+
routes: scan.routes || [],
|
|
310
|
+
components: (scan.components||[]).map(c=>({
|
|
311
|
+
ecosystem: c.ecosystem, name: c.name, version: c.version,
|
|
312
|
+
reachable: c.reachable, hasVulns: c.hasVulns, isDeprecated: c.isDeprecated,
|
|
313
|
+
latestVersion: c.latestVersion, license: c.license,
|
|
314
|
+
})),
|
|
315
|
+
suppressedCount: (scan.suppressions||[]).length,
|
|
316
|
+
blastRadiusSignals: scan.blastRadiusSignals || null,
|
|
317
|
+
// v3 next-gen — scan-level reports (counterfactual SPOF list, threat model
|
|
318
|
+
// summary, trust-boundary Mermaid, calibration-drift alarms).
|
|
319
|
+
_v3: scan._v3 || null,
|
|
320
|
+
// Harness-engineering (post-derived): annotator-pipeline errors. Empty
|
|
321
|
+
// array means clean; entries here mean one or more posture annotators
|
|
322
|
+
// threw and were skipped. The findings still ship; downstream consumers
|
|
323
|
+
// see the gap.
|
|
324
|
+
annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
|
|
325
|
+
};
|
|
326
|
+
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// R2: Always-on CSV writer for pro mode. One row per finding, columns chosen
|
|
331
|
+
// for spreadsheet/Excel/BigQuery import.
|
|
332
|
+
// STIX 2.1 emit (FR-SDLC-5). One Vulnerability + Indicator SDO pair per
|
|
333
|
+
// finding, wrapped in a single Bundle. Lets threat-intel platforms consume
|
|
334
|
+
// the scanner output natively. Spec: https://docs.oasis-open.org/cti/stix/v2.1/
|
|
335
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
336
|
+
|
|
337
|
+
function _stixId(type, finding) {
|
|
338
|
+
// Deterministic UUID: derive from sha256(stableId||vuln||file||line) so
|
|
339
|
+
// re-runs produce stable ids per finding.
|
|
340
|
+
const seed = `${finding.stableId || ''}::${finding.vuln || ''}::${finding.file || ''}::${finding.line || ''}`;
|
|
341
|
+
const h = createHash('sha256').update(seed).digest('hex');
|
|
342
|
+
// Format as UUIDv4-shaped (8-4-4-4-12).
|
|
343
|
+
const u = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
344
|
+
return `${type}--${u}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function toSTIX(scan, meta = {}) {
|
|
348
|
+
const findings = normalizeFindings(scan);
|
|
349
|
+
const now = (meta.startedAt && new Date(meta.startedAt).toISOString()) || new Date().toISOString();
|
|
350
|
+
const objects = [];
|
|
351
|
+
for (const f of findings) {
|
|
352
|
+
const vulnId = _stixId('vulnerability', f);
|
|
353
|
+
const indId = _stixId('indicator', f);
|
|
354
|
+
const cweExt = f.cwe ? [{
|
|
355
|
+
source_name: 'cwe',
|
|
356
|
+
external_id: String(f.cwe),
|
|
357
|
+
url: `https://cwe.mitre.org/data/definitions/${String(f.cwe).replace(/[^0-9]/g, '')}.html`,
|
|
358
|
+
}] : [];
|
|
359
|
+
objects.push({
|
|
360
|
+
type: 'vulnerability',
|
|
361
|
+
spec_version: '2.1',
|
|
362
|
+
id: vulnId,
|
|
363
|
+
created: now,
|
|
364
|
+
modified: now,
|
|
365
|
+
name: `${f.vuln || 'Security finding'} at ${f.file || '?'}:${f.line || '?'}`,
|
|
366
|
+
description: f.fix?.description || f.vuln || '',
|
|
367
|
+
external_references: cweExt,
|
|
368
|
+
labels: [f.severity || 'unknown'],
|
|
369
|
+
// x_* extension fields — STIX 2.1 allows custom properties prefixed
|
|
370
|
+
// with x_ for tool-specific data.
|
|
371
|
+
x_severity: f.severity || 'unknown',
|
|
372
|
+
x_confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
373
|
+
x_calibrated_confidence: typeof f.calibrated_confidence === 'number' ? f.calibrated_confidence : null,
|
|
374
|
+
x_calibrated_ci: Array.isArray(f.calibrated_confidence_ci) ? f.calibrated_confidence_ci : null,
|
|
375
|
+
x_exploitability: typeof f.exploitability === 'number' ? f.exploitability : null,
|
|
376
|
+
x_verifier_verdict: f.verifier_verdict || null,
|
|
377
|
+
x_stable_id: f.stableId || null,
|
|
378
|
+
x_family: f.family || null,
|
|
379
|
+
});
|
|
380
|
+
objects.push({
|
|
381
|
+
type: 'indicator',
|
|
382
|
+
spec_version: '2.1',
|
|
383
|
+
id: indId,
|
|
384
|
+
created: now,
|
|
385
|
+
modified: now,
|
|
386
|
+
indicator_types: ['vulnerable'],
|
|
387
|
+
name: `${f.vuln || 'Security finding'} signature`,
|
|
388
|
+
pattern: `[file:name = '${(f.file || '').replace(/'/g, "\\'")}']`,
|
|
389
|
+
pattern_type: 'stix',
|
|
390
|
+
pattern_version: '2.1',
|
|
391
|
+
valid_from: now,
|
|
392
|
+
// Link to the vulnerability via a relationship object below.
|
|
393
|
+
});
|
|
394
|
+
objects.push({
|
|
395
|
+
type: 'relationship',
|
|
396
|
+
spec_version: '2.1',
|
|
397
|
+
id: _stixId('relationship', { ...f, vuln: 'relationship:' + (f.vuln || '') }),
|
|
398
|
+
created: now,
|
|
399
|
+
modified: now,
|
|
400
|
+
relationship_type: 'indicates',
|
|
401
|
+
source_ref: indId,
|
|
402
|
+
target_ref: vulnId,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
type: 'bundle',
|
|
407
|
+
id: `bundle--${randomUUID()}`,
|
|
408
|
+
objects,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function toCSV(scan){
|
|
413
|
+
const findings = normalizeFindings(scan);
|
|
414
|
+
const esc = v => {
|
|
415
|
+
if (v == null) return '';
|
|
416
|
+
const s = String(v);
|
|
417
|
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
418
|
+
};
|
|
419
|
+
const header = ['id', 'severity', 'vuln', 'cwe', 'cvss', 'owasp', 'file', 'line', 'confidence', 'reachable', 'kind', 'snippet'];
|
|
420
|
+
const rows = [header.join(',')];
|
|
421
|
+
for (const f of findings) {
|
|
422
|
+
rows.push([
|
|
423
|
+
esc(f.id), esc(f.severity), esc(f.vuln), esc(f.cwe), esc(f.cvss || ''),
|
|
424
|
+
esc(f.owasp || ''), esc(f.file), esc(f.line),
|
|
425
|
+
esc(f.confidence == null ? '' : f.confidence.toFixed(3)),
|
|
426
|
+
esc(f.reachable == null ? '' : f.reachable),
|
|
427
|
+
esc(f.kind), esc((f.snippet || '').slice(0, 200)),
|
|
428
|
+
].join(','));
|
|
429
|
+
}
|
|
430
|
+
return rows.join('\n');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// JUnit XML output — for CI test-report aggregators (Jenkins, GitLab, CircleCI).
|
|
434
|
+
// Each finding becomes one <testcase> with a <failure> child. The whole report
|
|
435
|
+
// is one <testsuite> wrapped in <testsuites>.
|
|
436
|
+
export function toJUnit(scan, meta={}){
|
|
437
|
+
const findings = normalizeFindings(scan);
|
|
438
|
+
const esc = v => {
|
|
439
|
+
if (v == null) return '';
|
|
440
|
+
return String(v)
|
|
441
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
442
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
443
|
+
};
|
|
444
|
+
const escCdata = v => String(v == null ? '' : v).replace(/]]>/g, ']]]]><![CDATA[>');
|
|
445
|
+
const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
446
|
+
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
447
|
+
const failures = findings.length;
|
|
448
|
+
const ts = meta.startedAt || new Date().toISOString();
|
|
449
|
+
const lines = [];
|
|
450
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
451
|
+
lines.push(`<testsuites name="agentic-security" tests="${failures}" failures="${failures}" timestamp="${esc(ts)}">`);
|
|
452
|
+
lines.push(` <testsuite name="agentic-security" tests="${failures}" failures="${failures}" timestamp="${esc(ts)}">`);
|
|
453
|
+
for (const f of findings) {
|
|
454
|
+
const classname = esc(f.cwe || f.kind || 'finding');
|
|
455
|
+
const name = esc(`${f.file || '?'}:${f.line || 0} ${f.vuln || ''}`.trim());
|
|
456
|
+
const failType = esc(f.severity || 'medium');
|
|
457
|
+
const failMsg = esc(f.vuln || 'finding');
|
|
458
|
+
const body = [
|
|
459
|
+
`severity: ${f.severity || 'medium'}`,
|
|
460
|
+
f.cwe ? `cwe: ${f.cwe}` : null,
|
|
461
|
+
`file: ${f.file || '?'}:${f.line || 0}`,
|
|
462
|
+
f.snippet ? `snippet: ${f.snippet}` : null,
|
|
463
|
+
f.fix?.description ? `\nremediation: ${f.fix.description}` : null,
|
|
464
|
+
f.fix?.code ? `\nfix:\n${f.fix.code}` : null,
|
|
465
|
+
].filter(Boolean).join('\n');
|
|
466
|
+
lines.push(` <testcase classname="${classname}" name="${name}">`);
|
|
467
|
+
lines.push(` <failure type="${failType}" message="${failMsg}"><![CDATA[${escCdata(body)}]]></failure>`);
|
|
468
|
+
lines.push(` </testcase>`);
|
|
469
|
+
}
|
|
470
|
+
lines.push(' </testsuite>');
|
|
471
|
+
lines.push('</testsuites>');
|
|
472
|
+
return lines.join('\n');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function toMarkdown(scan, meta={}){
|
|
476
|
+
const findings = normalizeFindings(scan);
|
|
477
|
+
const lines = ['# Agentic Security — Scan Report', ''];
|
|
478
|
+
lines.push(`**Files scanned:** ${scan.filesScanned||0} **Findings:** ${findings.length} **Generated:** ${meta.startedAt||new Date().toISOString()}`);
|
|
479
|
+
lines.push('');
|
|
480
|
+
const bySev = {};
|
|
481
|
+
for (const f of findings) (bySev[f.severity] ||= []).push(f);
|
|
482
|
+
// Premortem 4R-11: include a Validator column when at least one finding
|
|
483
|
+
// carries a verdict, so SCA findings tagged 'not-applicable' aren't
|
|
484
|
+
// invisible to a reader looking only at the report.
|
|
485
|
+
const showValidator = findings.some(f => f.validator_verdict);
|
|
486
|
+
for (const sev of ['critical','high','medium','low','info']) {
|
|
487
|
+
if (!bySev[sev]) continue;
|
|
488
|
+
lines.push(`## ${sev.toUpperCase()} (${bySev[sev].length})`);
|
|
489
|
+
lines.push('');
|
|
490
|
+
if (showValidator) {
|
|
491
|
+
lines.push('| File:Line | Vulnerability | CWE | EPSS | Validator | Fix |');
|
|
492
|
+
lines.push('|---|---|---|---|---|---|');
|
|
493
|
+
} else {
|
|
494
|
+
lines.push('| File:Line | Vulnerability | CWE | EPSS | Fix |');
|
|
495
|
+
lines.push('|---|---|---|---|---|');
|
|
496
|
+
}
|
|
497
|
+
for (const f of bySev[sev]) {
|
|
498
|
+
const fix = f.fix?.description || '';
|
|
499
|
+
const epss = f.epssScore != null ? `${Math.round(f.epssScore*100)}%` : '—';
|
|
500
|
+
if (showValidator) {
|
|
501
|
+
const v = f.validator_verdict || '—';
|
|
502
|
+
lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${v} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
|
|
503
|
+
} else {
|
|
504
|
+
lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
lines.push('');
|
|
508
|
+
}
|
|
509
|
+
return lines.join('\n');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function toSARIF(scan, meta={}){
|
|
513
|
+
const findings = normalizeFindings(scan);
|
|
514
|
+
const ruleMap = new Map();
|
|
515
|
+
for (const f of findings) if (f.vuln && !ruleMap.has(f.vuln)) ruleMap.set(f.vuln, {
|
|
516
|
+
id: f.vuln.replace(/[^a-zA-Z0-9]/g, '_'),
|
|
517
|
+
name: f.vuln,
|
|
518
|
+
shortDescription: { text: f.vuln },
|
|
519
|
+
fullDescription: { text: f.fix?.description || f.vuln },
|
|
520
|
+
helpUri: f.cwe ? `https://cwe.mitre.org/data/definitions/${f.cwe.replace(/[^0-9]/g,'')}.html` : undefined,
|
|
521
|
+
properties: { tags: [f.cwe, f.stride].filter(Boolean) },
|
|
522
|
+
});
|
|
523
|
+
// Premortem 2R1.1 / 2R5.3 / 2R-12: surface the load-bearing caveats in the
|
|
524
|
+
// SARIF run itself so machine consumers see them. Without these, a CI that
|
|
525
|
+
// ingests SARIF treats "confidence: 0.9" as a probability and the
|
|
526
|
+
// benchmark-tuned 0.907 number as quality evidence.
|
|
527
|
+
const SARIF_NOTIFICATIONS = [
|
|
528
|
+
{
|
|
529
|
+
id: 'scores-are-ordinal',
|
|
530
|
+
name: 'ScoresAreOrdinal',
|
|
531
|
+
shortDescription: { text: 'priority/exploitability scores are ordinal, not calibrated probabilities' },
|
|
532
|
+
defaultConfiguration: { level: 'note' },
|
|
533
|
+
fullDescription: { text: 'The properties.exploitability and properties.confidence fields on each result are ORDINAL priority scores used to rank findings within a scan. They are NOT calibrated probabilities; do not render them as percentages or feed them into pricing / risk-acceptance decisions. Use the tier labels (critical/high/medium/low) for coarse bucketing. See bench/README.md for the open calibration work.' },
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
id: 'owasp-benchmark-tuning',
|
|
537
|
+
name: 'OwaspBenchmarkTuning',
|
|
538
|
+
shortDescription: { text: 'engine ships OWASP-Benchmark-shape precision lifters; F1 numbers do not generalize' },
|
|
539
|
+
defaultConfiguration: { level: 'note' },
|
|
540
|
+
fullDescription: { text: 'The engine includes precision lifters (sast/primary-cwe-java.js, sast/java-constant-fold.js) whose heuristics are tuned to OWASP Benchmark v1.2 file shape (servlet-style files <=300 LoC, canonical variable names). F1 numbers cited against OWASP Benchmark do NOT generalize to arbitrary Java code. Expect higher FP rates on real-world codebases until per-customer tuning lands. See bench/README.md.' },
|
|
541
|
+
},
|
|
542
|
+
];
|
|
543
|
+
return {
|
|
544
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
545
|
+
version: '2.1.0',
|
|
546
|
+
runs: [{
|
|
547
|
+
tool: { driver: { name: 'agentic-security', version: SCANNER_VERSION, informationUri: 'https://github.com/Clear-Capabilities/agentic-security', rules: [...ruleMap.values()], notifications: SARIF_NOTIFICATIONS }},
|
|
548
|
+
invocations: [{
|
|
549
|
+
executionSuccessful: true,
|
|
550
|
+
toolExecutionNotifications: SARIF_NOTIFICATIONS.map(n => ({
|
|
551
|
+
descriptor: { id: n.id }, level: 'note',
|
|
552
|
+
message: { text: n.fullDescription.text.slice(0, 1000) },
|
|
553
|
+
})),
|
|
554
|
+
// Premortem 3R-6: surface the ruleset-version stamp at SARIF level so
|
|
555
|
+
// /security-trend regression analysis can attribute deltas to rule
|
|
556
|
+
// changes vs. code changes.
|
|
557
|
+
properties: {
|
|
558
|
+
...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
|
|
559
|
+
...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
|
|
560
|
+
...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
|
|
561
|
+
},
|
|
562
|
+
}],
|
|
563
|
+
results: findings.map(f => ({
|
|
564
|
+
ruleId: f.vuln ? f.vuln.replace(/[^a-zA-Z0-9]/g, '_') : 'unknown',
|
|
565
|
+
level: SEV_TO_SARIF[f.severity] || 'warning',
|
|
566
|
+
message: { text: f.fix?.description || f.vuln || 'Security finding' },
|
|
567
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: Math.max(1, f.line||1) } } }],
|
|
568
|
+
// Phase-1 (Sentinel-parity) fingerprint: stableId persists across
|
|
569
|
+
// refactors. Keep partialFingerprints intact for tools that key on
|
|
570
|
+
// the line-hash; add a 'stableId' fingerprint for tools that respect
|
|
571
|
+
// the SARIF stable-fingerprint convention.
|
|
572
|
+
partialFingerprints: {
|
|
573
|
+
primaryLocationLineHash: f.id,
|
|
574
|
+
...(f.stableId ? { stableId: f.stableId } : {}),
|
|
575
|
+
},
|
|
576
|
+
// Sentinel-parity SARIF extensions — namespaced under 'properties'.
|
|
577
|
+
properties: {
|
|
578
|
+
...(typeof f.confidence === 'number' ? { confidence: f.confidence } : {}),
|
|
579
|
+
...(f.confidenceTier ? { confidenceTier: f.confidenceTier } : {}),
|
|
580
|
+
...(typeof f.exploitability === 'number' ? { exploitability: f.exploitability } : {}),
|
|
581
|
+
...(f.exploitabilityTier ? { exploitabilityTier: f.exploitabilityTier } : {}),
|
|
582
|
+
...(Array.isArray(f.exploitabilityFactors) ? { exploitabilityFactors: f.exploitabilityFactors } : {}),
|
|
583
|
+
...(typeof f.clusterSize === 'number' ? { clusterSize: f.clusterSize } : {}),
|
|
584
|
+
...(f.unreachable ? { unreachable: true } : {}),
|
|
585
|
+
...(f.validator_verdict ? { validatorVerdict: f.validator_verdict } : {}),
|
|
586
|
+
...(typeof f.llm_confidence === 'number' ? { llmConfidence: f.llm_confidence } : {}),
|
|
587
|
+
...(f.unvalidated ? { unvalidated: true } : {}),
|
|
588
|
+
...(f.cross_language ? { crossLanguage: true } : {}),
|
|
589
|
+
...(f.family ? { family: f.family } : {}),
|
|
590
|
+
// Premortem 3R-6 + 4R-10: emit the single signatureStatus tri-state
|
|
591
|
+
// (verified | unsigned | pass-through). The legacy bool flags are
|
|
592
|
+
// emitted alongside for one release of grace so existing dashboards
|
|
593
|
+
// don't break; new integrations should switch to signatureStatus.
|
|
594
|
+
signatureStatus: f.signatureStatus || (f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified')),
|
|
595
|
+
...(f._unsigned ? { unsigned: true } : {}),
|
|
596
|
+
...(f._passThroughSigning ? { passThroughSigning: true } : {}),
|
|
597
|
+
},
|
|
598
|
+
})),
|
|
599
|
+
}],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Feat-8: Interactive HTML report — single self-contained file with no external
|
|
604
|
+
// resources (no CDN, no Google Fonts, no remote JS). Filterable by severity / kind
|
|
605
|
+
// / CWE; per-finding code snippet panel; STRIDE heatmap; hotspot file ranking.
|
|
606
|
+
function _esc(s) {
|
|
607
|
+
return String(s == null ? '' : s)
|
|
608
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
609
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function toHTML(scan, meta = {}) {
|
|
613
|
+
const findings = normalizeFindings(scan);
|
|
614
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
615
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
616
|
+
const stride = {};
|
|
617
|
+
for (const f of findings) if (f.stride) stride[f.stride] = (stride[f.stride] || 0) + 1;
|
|
618
|
+
const byFile = {};
|
|
619
|
+
for (const f of findings) byFile[f.file] = (byFile[f.file] || 0) + 1;
|
|
620
|
+
const hotspots = Object.entries(byFile).sort((a,b)=>b[1]-a[1]).slice(0, 10);
|
|
621
|
+
const data = JSON.stringify(findings).replace(/</g, '\\u003c');
|
|
622
|
+
const generatedAt = new Date().toISOString();
|
|
623
|
+
const SEV_HEX = { critical: '#ff2d55', high: '#ff6b35', medium: '#ffb800', low: '#34d058', info: '#82aaff' };
|
|
624
|
+
const sevBars = Object.entries(counts).map(([k, v]) =>
|
|
625
|
+
`<div class="sev-row"><span class="sev-tag" style="background:${SEV_HEX[k]}22;color:${SEV_HEX[k]}">${k}</span><span class="sev-bar" style="width:${Math.min(100, v * 4)}%;background:${SEV_HEX[k]}"></span><span class="sev-num">${v}</span></div>`
|
|
626
|
+
).join('');
|
|
627
|
+
const strideRows = ['Spoofing','Tampering','Repudiation','Information Disclosure','Denial of Service','Elevation of Privilege']
|
|
628
|
+
.map(s => `<tr><td>${_esc(s)}</td><td class="num">${stride[s] || 0}</td></tr>`).join('');
|
|
629
|
+
const hotRows = hotspots.map(([f, n]) => `<tr><td><code>${_esc(f)}</code></td><td class="num">${n}</td></tr>`).join('');
|
|
630
|
+
const SECTIONS = [
|
|
631
|
+
{ kind: 'iac', label: 'IaC', color: '#ffb800', icon: '🏗️' },
|
|
632
|
+
{ kind: 'logic', label: 'Logic', color: '#a78bfa', icon: '⚙️' },
|
|
633
|
+
{ kind: 'sast', label: 'SAST', color: '#38bdf8', icon: '🔍' },
|
|
634
|
+
{ kind: 'sca', label: 'SCA', color: '#34d058', icon: '📦' },
|
|
635
|
+
{ kind: 'secret', label: 'Secrets', color: '#f97316', icon: '🔑' },
|
|
636
|
+
];
|
|
637
|
+
return `<!doctype html>
|
|
638
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
639
|
+
<title>agentic-security — scan report</title>
|
|
640
|
+
<style>
|
|
641
|
+
*{box-sizing:border-box}
|
|
642
|
+
body{margin:0;padding:0;font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0b1020;color:#e2e8f4}
|
|
643
|
+
header{padding:24px 32px;border-bottom:1px solid #1e293b;background:#0f172a}
|
|
644
|
+
h1{margin:0 0 4px 0;font-size:22px;font-weight:600}
|
|
645
|
+
.meta{color:#64748b;font-size:13px}
|
|
646
|
+
main{padding:24px 32px}
|
|
647
|
+
.grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-bottom:24px}
|
|
648
|
+
.card{background:#0f172a;border:1px solid #1e293b;border-radius:6px;padding:16px}
|
|
649
|
+
.card h2{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#94a3b8;margin:0 0 12px 0}
|
|
650
|
+
.sev-row{display:flex;align-items:center;gap:12px;margin-bottom:6px;font-size:12px}
|
|
651
|
+
.sev-tag{padding:2px 8px;border-radius:3px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;min-width:64px;text-align:center}
|
|
652
|
+
.sev-bar{height:6px;border-radius:3px;flex:1}
|
|
653
|
+
.sev-num{min-width:32px;text-align:right;color:#94a3b8;font-variant-numeric:tabular-nums}
|
|
654
|
+
table{width:100%;border-collapse:collapse;font-size:12px}
|
|
655
|
+
table td{padding:6px 8px;border-bottom:1px solid #1e293b}
|
|
656
|
+
table .num{text-align:right;color:#94a3b8;font-variant-numeric:tabular-nums}
|
|
657
|
+
table code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;color:#e2e8f4}
|
|
658
|
+
.filters{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
|
659
|
+
.filters input,.filters select{padding:6px 10px;background:#0f172a;border:1px solid #1e293b;border-radius:4px;color:#e2e8f4;font:13px/1 -apple-system,system-ui,sans-serif}
|
|
660
|
+
.btn{padding:5px 12px;background:#1e293b;border:1px solid #334155;border-radius:4px;color:#94a3b8;font:12px/1 -apple-system,system-ui,sans-serif;cursor:pointer;white-space:nowrap}
|
|
661
|
+
.btn:hover{background:#273549;color:#e2e8f4}
|
|
662
|
+
.section{margin-bottom:28px}
|
|
663
|
+
.section-header{display:flex;align-items:center;gap:10px;margin-bottom:10px;cursor:pointer;user-select:none}
|
|
664
|
+
.section-title{font-size:15px;font-weight:700;letter-spacing:0.02em}
|
|
665
|
+
.section-count{font-size:12px;color:#94a3b8;background:#1e293b;padding:2px 8px;border-radius:10px;font-variant-numeric:tabular-nums}
|
|
666
|
+
.section-toggle{color:#475569;font-size:11px;margin-left:auto}
|
|
667
|
+
.section-body{display:flex;flex-direction:column;gap:8px}
|
|
668
|
+
.section-body.collapsed{display:none}
|
|
669
|
+
.section-empty{color:#475569;font-size:13px;font-style:italic;padding:8px 0}
|
|
670
|
+
.f{background:#0f172a;border:1px solid #1e293b;border-radius:6px;padding:12px 16px;cursor:pointer}
|
|
671
|
+
.f.expanded{border-color:#38bdf8}
|
|
672
|
+
.f-head{display:flex;align-items:center;gap:12px;font-size:13px}
|
|
673
|
+
.f-loc{color:#94a3b8;font-family:ui-monospace,monospace;font-size:11px}
|
|
674
|
+
.f-vuln{font-weight:600;flex:1}
|
|
675
|
+
.f-cwe{color:#64748b;font-size:11px;font-family:ui-monospace,monospace}
|
|
676
|
+
.f-epss{font-size:11px;font-weight:600;color:#f59e0b;background:#f59e0b18;padding:1px 6px;border-radius:3px;white-space:nowrap}
|
|
677
|
+
.f-body{display:none;margin-top:12px;padding-top:12px;border-top:1px solid #1e293b;font-size:12px}
|
|
678
|
+
.f.expanded .f-body{display:block}
|
|
679
|
+
.f-body pre{background:#020617;padding:10px;border-radius:4px;overflow-x:auto;font-size:11px;line-height:1.5}
|
|
680
|
+
.f-fix{background:#0d1f3d;border-left:3px solid #38bdf8;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
|
|
681
|
+
.hidden{display:none!important}
|
|
682
|
+
</style></head>
|
|
683
|
+
<body>
|
|
684
|
+
<header>
|
|
685
|
+
<h1>agentic-security — scan report</h1>
|
|
686
|
+
<div class="meta">${_esc(findings.length)} findings · ${_esc(scan.filesScanned||0)} files scanned · generated ${_esc(generatedAt)}</div>
|
|
687
|
+
</header>
|
|
688
|
+
<main>
|
|
689
|
+
<div class="grid">
|
|
690
|
+
<div class="card"><h2>By severity</h2>${sevBars}</div>
|
|
691
|
+
<div class="card"><h2>STRIDE coverage</h2><table><tbody>${strideRows}</tbody></table></div>
|
|
692
|
+
<div class="card"><h2>Top files</h2><table><tbody>${hotRows}</tbody></table></div>
|
|
693
|
+
</div>
|
|
694
|
+
<div class="filters">
|
|
695
|
+
<input id="q" placeholder="Filter by file, vuln, CWE…" />
|
|
696
|
+
<select id="sev"><option value="">All severities</option><option>critical</option><option>high</option><option>medium</option><option>low</option><option>info</option></select>
|
|
697
|
+
<select id="kind"><option value="">All scan types</option><option value="iac">IaC</option><option value="logic">Logic</option><option value="sast">SAST</option><option value="sca">SCA</option><option value="secret">Secrets</option></select>
|
|
698
|
+
<button class="btn" id="toggleAll">Collapse All</button>
|
|
699
|
+
</div>
|
|
700
|
+
<div id="findings"></div>
|
|
701
|
+
</main>
|
|
702
|
+
<script>
|
|
703
|
+
const FINDINGS = ${data};
|
|
704
|
+
const SEV_HEX = ${JSON.stringify(SEV_HEX)};
|
|
705
|
+
const SECTIONS = ${JSON.stringify(SECTIONS)};
|
|
706
|
+
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML}
|
|
707
|
+
|
|
708
|
+
let allCollapsed = false;
|
|
709
|
+
|
|
710
|
+
function makeCard(f) {
|
|
711
|
+
const hex = SEV_HEX[f.severity] || '#888';
|
|
712
|
+
const div = document.createElement('div');
|
|
713
|
+
div.className = 'f';
|
|
714
|
+
div.dataset.sev = f.severity;
|
|
715
|
+
div.dataset.file = (f.file||'').toLowerCase();
|
|
716
|
+
div.dataset.vuln = (f.vuln||'').toLowerCase();
|
|
717
|
+
div.dataset.cwe = (f.cwe||'').toLowerCase();
|
|
718
|
+
const epssHtml = f.epssScore != null
|
|
719
|
+
? '<span class="f-epss" title="EPSS: probability of abuse in the next 30 days">EPSS ' + Math.round(f.epssScore * 100) + '%</span>'
|
|
720
|
+
: '';
|
|
721
|
+
div.innerHTML =
|
|
722
|
+
'<div class="f-head">' +
|
|
723
|
+
'<span class="sev-tag" style="background:' + hex + '22;color:' + hex + '">' + esc(f.severity) + '</span>' +
|
|
724
|
+
'<span class="f-loc">' + esc(f.file) + ':' + esc(f.line) + '</span>' +
|
|
725
|
+
'<span class="f-vuln">' + esc(f.vuln) + '</span>' +
|
|
726
|
+
'<span class="f-cwe">' + esc(f.cwe||'') + '</span>' +
|
|
727
|
+
epssHtml +
|
|
728
|
+
'</div>' +
|
|
729
|
+
'<div class="f-body">' +
|
|
730
|
+
(f.snippet ? '<pre>' + esc(f.snippet) + '</pre>' : '') +
|
|
731
|
+
(f.masked ? '<pre style="color:#f97316">' + esc(f.masked) + ' (masked)</pre>' : '') +
|
|
732
|
+
(f.fix && f.fix.description ? '<div class="f-fix"><b>Fix:</b> ' + esc(f.fix.description) + (f.fix.code ? '<pre>' + esc(f.fix.code) + '</pre>' : '') + '</div>' : '') +
|
|
733
|
+
'</div>';
|
|
734
|
+
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
|
735
|
+
return div;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function render() {
|
|
739
|
+
const q = document.getElementById('q').value.toLowerCase();
|
|
740
|
+
const sev = document.getElementById('sev').value;
|
|
741
|
+
const kind = document.getElementById('kind').value;
|
|
742
|
+
const root = document.getElementById('findings');
|
|
743
|
+
root.innerHTML = '';
|
|
744
|
+
allCollapsed = false;
|
|
745
|
+
document.getElementById('toggleAll').textContent = 'Collapse All';
|
|
746
|
+
|
|
747
|
+
for (const sec of SECTIONS) {
|
|
748
|
+
if (kind && sec.kind !== kind) continue;
|
|
749
|
+
const secFindings = FINDINGS.filter(f => f.kind === sec.kind);
|
|
750
|
+
const visible = secFindings.filter(f =>
|
|
751
|
+
(!sev || f.severity === sev) &&
|
|
752
|
+
(!q || (f.file||'').toLowerCase().includes(q) || (f.vuln||'').toLowerCase().includes(q) || (f.cwe||'').toLowerCase().includes(q))
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
const section = document.createElement('div');
|
|
756
|
+
section.className = 'section';
|
|
757
|
+
|
|
758
|
+
const hdr = document.createElement('div');
|
|
759
|
+
hdr.className = 'section-header';
|
|
760
|
+
hdr.innerHTML =
|
|
761
|
+
'<span style="color:' + sec.color + ';font-size:16px">' + sec.icon + '</span>' +
|
|
762
|
+
'<span class="section-title" style="color:' + sec.color + '">' + esc(sec.label) + '</span>' +
|
|
763
|
+
'<span class="section-count">' + visible.length + ' of ' + secFindings.length + '</span>' +
|
|
764
|
+
'<span class="section-toggle">▾</span>';
|
|
765
|
+
|
|
766
|
+
const body = document.createElement('div');
|
|
767
|
+
body.className = 'section-body';
|
|
768
|
+
|
|
769
|
+
hdr.addEventListener('click', () => {
|
|
770
|
+
const collapsed = body.classList.toggle('collapsed');
|
|
771
|
+
hdr.querySelector('.section-toggle').textContent = collapsed ? '▸' : '▾';
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
if (visible.length === 0) {
|
|
775
|
+
const empty = document.createElement('div');
|
|
776
|
+
empty.className = 'section-empty';
|
|
777
|
+
empty.textContent = secFindings.length === 0 ? 'No findings.' : 'All findings filtered out.';
|
|
778
|
+
body.appendChild(empty);
|
|
779
|
+
} else {
|
|
780
|
+
for (const f of visible) body.appendChild(makeCard(f));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
section.appendChild(hdr);
|
|
784
|
+
section.appendChild(body);
|
|
785
|
+
root.appendChild(section);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
document.getElementById('toggleAll').addEventListener('click', () => {
|
|
790
|
+
allCollapsed = !allCollapsed;
|
|
791
|
+
document.getElementById('toggleAll').textContent = allCollapsed ? 'Expand All' : 'Collapse All';
|
|
792
|
+
document.querySelectorAll('.section-body').forEach(b => {
|
|
793
|
+
b.classList.toggle('collapsed', allCollapsed);
|
|
794
|
+
const toggle = b.previousElementSibling?.querySelector('.section-toggle');
|
|
795
|
+
if (toggle) toggle.textContent = allCollapsed ? '▸' : '▾';
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
document.getElementById('q').addEventListener('input', render);
|
|
800
|
+
document.getElementById('sev').addEventListener('change', render);
|
|
801
|
+
document.getElementById('kind').addEventListener('change', render);
|
|
802
|
+
render();
|
|
803
|
+
</script>
|
|
804
|
+
</body></html>`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const SEV_COLOR = { critical: '\x1b[91m', high: '\x1b[31m', medium: '\x1b[33m', low: '\x1b[32m', info: '\x1b[36m' };
|
|
808
|
+
const RESET = '\x1b[0m';
|
|
809
|
+
const DIM = '\x1b[2m';
|
|
810
|
+
const BOLD = '\x1b[1m';
|
|
811
|
+
|
|
812
|
+
export function toCLI(scan, { verbose=false, color=true }={}){
|
|
813
|
+
const findings = normalizeFindings(scan);
|
|
814
|
+
const lines = [];
|
|
815
|
+
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
816
|
+
lines.push(c(BOLD+`Agentic Security — ${findings.length} finding(s) across ${scan.filesScanned||0} file(s)`, ''));
|
|
817
|
+
lines.push('');
|
|
818
|
+
for (const f of findings) {
|
|
819
|
+
const sevTag = c(`[${f.severity.toUpperCase()}]`, SEV_COLOR[f.severity]||'');
|
|
820
|
+
const epssTag = f.epssScore != null ? c(` EPSS:${Math.round(f.epssScore*100)}%`, DIM) : '';
|
|
821
|
+
const kevTag = f.kev ? c(' KEV', '\x1b[1;31m') : '';
|
|
822
|
+
// Premortem 4R-11: surface the validator verdict so SCA findings (which
|
|
823
|
+
// get tagged 'not-applicable' deliberately) don't look like they slipped
|
|
824
|
+
// past validation. The dim tag is only rendered when verdict is set.
|
|
825
|
+
const verdictTag = f.validator_verdict
|
|
826
|
+
? c(` V:${f.validator_verdict}`, DIM)
|
|
827
|
+
: '';
|
|
828
|
+
lines.push(`${sevTag} ${c(f.cwe||' ', DIM)} ${f.file}:${f.line} ${BOLD}${f.vuln}${RESET}${epssTag}${kevTag}${verdictTag}`);
|
|
829
|
+
if (f.masked) lines.push(` ${c('value:', DIM)} ${f.masked}`);
|
|
830
|
+
if (verbose && f.fix?.description) {
|
|
831
|
+
lines.push(` ${c('fix:', DIM)} ${f.fix.description}`);
|
|
832
|
+
if (f.fix.code) for (const ln of f.fix.code.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
lines.push('');
|
|
836
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
837
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
|
|
838
|
+
lines.push(`${c('Critical:', SEV_COLOR.critical)} ${counts.critical} ${c('High:', SEV_COLOR.high)} ${counts.high} ${c('Medium:', SEV_COLOR.medium)} ${counts.medium} ${c('Low:', SEV_COLOR.low)} ${counts.low} ${c('Info:', SEV_COLOR.info)} ${counts.info}`);
|
|
839
|
+
return lines.join('\n');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
843
|
+
// PERSONA-AWARE RENDERERS (R3 + R5)
|
|
844
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
845
|
+
//
|
|
846
|
+
// toShipVerdict: vibecoder default — one-screen verdict, hides taxonomy,
|
|
847
|
+
// shows up to 3 actionable items each with inline fix snippet.
|
|
848
|
+
// toProTable: pro default — table with CWE/CVSS/OWASP/MITRE columns,
|
|
849
|
+
// ranked by triage score, full taxonomy visible.
|
|
850
|
+
//
|
|
851
|
+
// Both filter by `confidenceMin` from the profile.
|
|
852
|
+
|
|
853
|
+
const CONF_DEFAULT_VIB = 0.9;
|
|
854
|
+
const CONF_DEFAULT_PRO = 0.3;
|
|
855
|
+
|
|
856
|
+
function _withConfidence(findings, min) {
|
|
857
|
+
// confidence defaults to 1.0 when unset — engine doesn't always populate it,
|
|
858
|
+
// so we never silently drop unset findings. Power users can `--firehose`.
|
|
859
|
+
return findings.filter(f => (f.confidence == null ? 1.0 : f.confidence) >= min);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function _sevToEmoji(sev) {
|
|
863
|
+
return { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '⚪' }[sev] || '•';
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Per-category 0..100 score (Secrets / Permissions / Hooks / MCP / Agents).
|
|
867
|
+
// Each category starts at 100 and loses points by category-weighted severity.
|
|
868
|
+
// Used by the ship verdict to show a graded breakdown alongside the verdict.
|
|
869
|
+
export function categoryScores(findings) {
|
|
870
|
+
const cats = {
|
|
871
|
+
Secrets: { score: 100, hits: 0 },
|
|
872
|
+
Permissions: { score: 100, hits: 0 },
|
|
873
|
+
Hooks: { score: 100, hits: 0 },
|
|
874
|
+
MCP: { score: 100, hits: 0 },
|
|
875
|
+
Agents: { score: 100, hits: 0 },
|
|
876
|
+
};
|
|
877
|
+
const weight = { critical: 25, high: 15, medium: 6, low: 2, info: 0 };
|
|
878
|
+
const classify = (f) => {
|
|
879
|
+
const fam = String(f.family || '').toLowerCase();
|
|
880
|
+
const vuln = String(f.vuln || '').toLowerCase();
|
|
881
|
+
if (fam.includes('harness-config-secrets') || fam.includes('hardcoded') || /\bsecret\b|\bcredential\b|\bapi[-_ ]?key\b|\btoken\b/.test(vuln)) return 'Secrets';
|
|
882
|
+
if (fam.includes('harness-config-permissions') || /\ballow.list\b|\bdeny.list\b|\bpermission\b|\bdangerouslySkipPermissions\b/i.test(vuln)) return 'Permissions';
|
|
883
|
+
if (fam.includes('hook-command-injection') || /\bhook\b/i.test(vuln)) return 'Hooks';
|
|
884
|
+
if (fam.includes('mcp') || /\bmcp\b/i.test(vuln)) return 'MCP';
|
|
885
|
+
if (fam.includes('agent') || fam.includes('prompt-injection') || /\bagent\b|\bprompt[-\s]?injection\b/i.test(vuln)) return 'Agents';
|
|
886
|
+
return null;
|
|
887
|
+
};
|
|
888
|
+
for (const f of findings) {
|
|
889
|
+
const cat = classify(f);
|
|
890
|
+
if (!cat) continue;
|
|
891
|
+
const w = weight[(f.severity || 'low').toLowerCase()] ?? 2;
|
|
892
|
+
cats[cat].score = Math.max(0, cats[cat].score - w);
|
|
893
|
+
cats[cat].hits += 1;
|
|
894
|
+
}
|
|
895
|
+
return cats;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function _renderCategoryBars(cats, color) {
|
|
899
|
+
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
900
|
+
const BAR_W = 20;
|
|
901
|
+
const lines = [];
|
|
902
|
+
for (const [name, info] of Object.entries(cats)) {
|
|
903
|
+
if (info.hits === 0 && info.score === 100) continue; // skip uncontributed categories in compact mode
|
|
904
|
+
const filled = Math.round((info.score / 100) * BAR_W);
|
|
905
|
+
const empty = BAR_W - filled;
|
|
906
|
+
const tier = info.score >= 90 ? SEV_COLOR.low : info.score >= 60 ? '\x1b[33m' : info.score >= 30 ? SEV_COLOR.high : SEV_COLOR.critical;
|
|
907
|
+
const bar = c('█'.repeat(filled), tier) + c('░'.repeat(empty), DIM);
|
|
908
|
+
lines.push(` ${name.padEnd(14)} ${bar} ${String(info.score).padStart(3)} ${c(`(${info.hits} finding${info.hits === 1 ? '' : 's'})`, DIM)}`);
|
|
909
|
+
}
|
|
910
|
+
return lines;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export function toShipVerdict(scan, options = {}) {
|
|
914
|
+
const profile = options.profile || { confidenceMin: CONF_DEFAULT_VIB, showTaxonomy: false };
|
|
915
|
+
const color = options.color !== false;
|
|
916
|
+
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
917
|
+
const findings = _withConfidence(normalizeFindings(scan), profile.confidenceMin ?? CONF_DEFAULT_VIB);
|
|
918
|
+
const actionable = findings.filter(f => /critical|high/.test(f.severity));
|
|
919
|
+
const advisoryCount = findings.length - actionable.length;
|
|
920
|
+
const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
921
|
+
for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
|
|
922
|
+
const kevCount = findings.filter(f => f.kev === true).length;
|
|
923
|
+
const confirmedCount = findings.filter(f => f.validated === true || f.confirmed === true).length;
|
|
924
|
+
const cats = categoryScores(findings);
|
|
925
|
+
|
|
926
|
+
const lines = [];
|
|
927
|
+
const bar = '─────────────────────────────────────────';
|
|
928
|
+
// Patch the mascot reacts to the result — APPROVE if clean, ALERT if findings.
|
|
929
|
+
lines.push(actionable.length === 0 ? approveFace({ color }) : alertFace({ color }));
|
|
930
|
+
lines.push(bar);
|
|
931
|
+
if (actionable.length === 0) {
|
|
932
|
+
lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
|
|
933
|
+
} else {
|
|
934
|
+
lines.push(c(' ❌ Not safe to deploy', SEV_COLOR.critical + BOLD));
|
|
935
|
+
}
|
|
936
|
+
lines.push(bar);
|
|
937
|
+
lines.push(` • ${sev.critical} critical · ${sev.high} high · ${advisoryCount} advisory`);
|
|
938
|
+
// Per-category 0..100 score bars — Secrets / Permissions / Hooks / MCP / Agents.
|
|
939
|
+
// Only render when at least one category has been contributed to (skip the
|
|
940
|
+
// 100/100/100/100/100 baseline on pure application-code scans).
|
|
941
|
+
const hasCategoryFindings = Object.values(cats).some(c => c.hits > 0);
|
|
942
|
+
if (hasCategoryFindings) {
|
|
943
|
+
lines.push('');
|
|
944
|
+
lines.push(c(' Category breakdown', BOLD));
|
|
945
|
+
for (const ln of _renderCategoryBars(cats, color)) lines.push(ln);
|
|
946
|
+
}
|
|
947
|
+
// KEV: surface "being exploited now" — more visceral than $-cost framing alone.
|
|
948
|
+
// Only show when at least one finding is in CISA KEV (known-exploited).
|
|
949
|
+
if (kevCount > 0) {
|
|
950
|
+
lines.push(c(` 🔥 ${kevCount} actively exploited in the wild (CISA KEV)`, SEV_COLOR.critical + BOLD));
|
|
951
|
+
}
|
|
952
|
+
// CONFIRMED: surface validator-confirmed criticals as a trust signal —
|
|
953
|
+
// distinguishes "tool said so" from "tool built a PoC and it ran."
|
|
954
|
+
if (confirmedCount > 0) {
|
|
955
|
+
lines.push(c(` ✓ ${confirmedCount} CONFIRMED (PoC built by /validate-findings)`, '\x1b[1;32m'));
|
|
956
|
+
}
|
|
957
|
+
lines.push('');
|
|
958
|
+
|
|
959
|
+
if (actionable.length) {
|
|
960
|
+
// Cumulative counts so each option shows what the user is actually
|
|
961
|
+
// signing up for (e.g., option 2 fixes both critical AND high).
|
|
962
|
+
const nCrit = sev.critical || 0;
|
|
963
|
+
const nHigh = nCrit + (sev.high || 0);
|
|
964
|
+
const nMed = nHigh + (sev.medium || 0);
|
|
965
|
+
const nAll = nMed + (sev.low || 0);
|
|
966
|
+
const fix = (n) => `${n} ${n === 1 ? 'fix' : 'fixes'}`;
|
|
967
|
+
|
|
968
|
+
lines.push(c(' How many do you want to fix?', BOLD));
|
|
969
|
+
lines.push('');
|
|
970
|
+
if (nCrit > 0) lines.push(` 1. Critical only (${fix(nCrit)})`);
|
|
971
|
+
if (nHigh > nCrit) lines.push(` 2. Critical + High (${fix(nHigh)})`);
|
|
972
|
+
if (nMed > nHigh) lines.push(` 3. Critical + High + Medium (${fix(nMed)})`);
|
|
973
|
+
if (nAll > nMed) lines.push(` 4. All (${fix(nAll)})`);
|
|
974
|
+
lines.push('');
|
|
975
|
+
lines.push(c(' Reply with 1, 2, 3, or 4.', DIM));
|
|
976
|
+
lines.push('');
|
|
977
|
+
lines.push(c(' Or pick a single one:', DIM));
|
|
978
|
+
lines.push(c(' /security-scan-all --firehose see every finding', DIM));
|
|
979
|
+
lines.push(c(' /security-fix --finding <id> fix exactly one', DIM));
|
|
980
|
+
} else if (advisoryCount > 0) {
|
|
981
|
+
lines.push(c(` ${advisoryCount} advisory item${advisoryCount === 1 ? '' : 's'} — run /security-scan-all --firehose to see them.`, DIM));
|
|
982
|
+
}
|
|
983
|
+
lines.push('');
|
|
984
|
+
lines.push(c(' 🛡 agentic-security · created by ClearCapabilities.Com', DIM));
|
|
985
|
+
return lines.join('\n');
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
export function toProTable(scan, options = {}) {
|
|
989
|
+
const profile = options.profile || { confidenceMin: CONF_DEFAULT_PRO, showTaxonomy: true };
|
|
990
|
+
const color = options.color !== false;
|
|
991
|
+
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
992
|
+
const columns = options.columns || 'standard'; // 'standard' | 'mitre' | 'capec' | 'owasp'
|
|
993
|
+
const findings = _withConfidence(normalizeFindings(scan), profile.confidenceMin ?? CONF_DEFAULT_PRO);
|
|
994
|
+
|
|
995
|
+
// Rank by triage score (or severity rank if absent).
|
|
996
|
+
findings.sort((a, b) => {
|
|
997
|
+
const ea = a.triage ?? (1 - (SEV_RANK[a.severity] || 0) / 4);
|
|
998
|
+
const eb = b.triage ?? (1 - (SEV_RANK[b.severity] || 0) / 4);
|
|
999
|
+
return eb - ea;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const lines = [];
|
|
1003
|
+
lines.push(c(BOLD + `agentic-security — pro mode · ${findings.length} finding(s) across ${scan.filesScanned || 0} file(s)`, ''));
|
|
1004
|
+
lines.push(c('created by ClearCapabilities.Com', DIM));
|
|
1005
|
+
lines.push('');
|
|
1006
|
+
|
|
1007
|
+
// Header row depends on column profile.
|
|
1008
|
+
const hdr = (() => {
|
|
1009
|
+
if (columns === 'mitre') return ['Severity', 'File:Line', 'ATT&CK', 'Vuln', 'Conf'];
|
|
1010
|
+
if (columns === 'capec') return ['Severity', 'File:Line', 'CAPEC', 'Vuln', 'Conf'];
|
|
1011
|
+
if (columns === 'owasp') return ['Severity', 'File:Line', 'CWE', 'OWASP', 'Vuln', 'Conf'];
|
|
1012
|
+
return ['Severity', 'File:Line', 'CWE', 'CVSS', 'OWASP', 'Vuln', 'Conf'];
|
|
1013
|
+
})();
|
|
1014
|
+
lines.push(c(hdr.join(' '), BOLD));
|
|
1015
|
+
lines.push(c('─'.repeat(80), DIM));
|
|
1016
|
+
|
|
1017
|
+
for (const f of findings) {
|
|
1018
|
+
const sev = c(_sevToEmoji(f.severity) + ' ' + (f.severity || '').toUpperCase().padEnd(8), SEV_COLOR[f.severity] || '');
|
|
1019
|
+
const where = `${f.file}:${f.line}`.padEnd(40);
|
|
1020
|
+
const cwe = (f.cwe || '—').padEnd(10);
|
|
1021
|
+
const cvss = (f.cvss || f.cvssV3?.score || '—').toString().padEnd(5);
|
|
1022
|
+
const owasp = (f.owasp || f.owaspCategory || '—').padEnd(10);
|
|
1023
|
+
const mitre = (f.mitreAttack || f.attckTechnique || '—').padEnd(20);
|
|
1024
|
+
const capec = (f.capec || '—').padEnd(10);
|
|
1025
|
+
const conf = (f.confidence == null ? '—' : f.confidence.toFixed(2));
|
|
1026
|
+
const vuln = (f.vuln || '').slice(0, 60);
|
|
1027
|
+
|
|
1028
|
+
if (columns === 'mitre') lines.push(`${sev} ${where} ${mitre} ${vuln} ${conf}`);
|
|
1029
|
+
else if (columns === 'capec') lines.push(`${sev} ${where} ${capec} ${vuln} ${conf}`);
|
|
1030
|
+
else if (columns === 'owasp') lines.push(`${sev} ${where} ${cwe} ${owasp} ${vuln} ${conf}`);
|
|
1031
|
+
else lines.push(`${sev} ${where} ${cwe} ${cvss} ${owasp} ${vuln} ${conf}`);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Footer counts.
|
|
1035
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
1036
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
1037
|
+
lines.push('');
|
|
1038
|
+
lines.push(
|
|
1039
|
+
`${c('Critical:', SEV_COLOR.critical)} ${counts.critical} ` +
|
|
1040
|
+
`${c('High:', SEV_COLOR.high)} ${counts.high} ` +
|
|
1041
|
+
`${c('Medium:', SEV_COLOR.medium)} ${counts.medium} ` +
|
|
1042
|
+
`${c('Low:', SEV_COLOR.low)} ${counts.low} ` +
|
|
1043
|
+
`${c('Info:', SEV_COLOR.info)} ${counts.info}`
|
|
1044
|
+
);
|
|
1045
|
+
lines.push('');
|
|
1046
|
+
lines.push(c('Machine-readable output written to .agentic-security/findings.{sarif,json,csv}', DIM));
|
|
1047
|
+
return lines.join('\n');
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Persona dispatcher. Picks the renderer based on the profile.
|
|
1051
|
+
export function toCLIByProfile(scan, options = {}) {
|
|
1052
|
+
const profile = options.profile || {};
|
|
1053
|
+
if (profile.profile === 'pro') return toProTable(scan, options);
|
|
1054
|
+
return toShipVerdict(scan, options);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
export function toSummary(scan, { color=true }={}){
|
|
1058
|
+
const findings = normalizeFindings(scan);
|
|
1059
|
+
const lines = [];
|
|
1060
|
+
const c = (s, code) => color ? `${code}${s}${RESET}` : s;
|
|
1061
|
+
|
|
1062
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
1063
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
|
|
1064
|
+
|
|
1065
|
+
lines.push(c(BOLD + `Agentic Security — ${findings.length} finding(s) across ${scan.filesScanned||0} file(s)`, ''));
|
|
1066
|
+
lines.push('');
|
|
1067
|
+
lines.push(
|
|
1068
|
+
` ${c('Critical', SEV_COLOR.critical)} ${String(counts.critical).padEnd(4)}` +
|
|
1069
|
+
` ${c('High', SEV_COLOR.high)} ${String(counts.high).padEnd(4)}` +
|
|
1070
|
+
` ${c('Medium', SEV_COLOR.medium)} ${String(counts.medium).padEnd(4)}` +
|
|
1071
|
+
` ${c('Low', SEV_COLOR.low)} ${String(counts.low).padEnd(4)}` +
|
|
1072
|
+
` ${c('Info', SEV_COLOR.info)} ${counts.info}`
|
|
1073
|
+
);
|
|
1074
|
+
lines.push('');
|
|
1075
|
+
|
|
1076
|
+
// Group findings by severity then vuln type, show top 3 examples per group
|
|
1077
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
1078
|
+
for (const sev of SEVERITIES) {
|
|
1079
|
+
const sevFindings = findings.filter(f => f.severity === sev);
|
|
1080
|
+
if (!sevFindings.length) continue;
|
|
1081
|
+
|
|
1082
|
+
const label = sev.charAt(0).toUpperCase() + sev.slice(1);
|
|
1083
|
+
lines.push(c(`${label} (${sevFindings.length})`, SEV_COLOR[sev]));
|
|
1084
|
+
|
|
1085
|
+
// Group by vuln type
|
|
1086
|
+
const byVuln = new Map();
|
|
1087
|
+
for (const f of sevFindings) {
|
|
1088
|
+
if (!byVuln.has(f.vuln)) byVuln.set(f.vuln, []);
|
|
1089
|
+
byVuln.get(f.vuln).push(f);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const vulnEntries = [...byVuln.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
1093
|
+
const shown = vulnEntries.slice(0, 6);
|
|
1094
|
+
const hiddenTypes = vulnEntries.length - shown.length;
|
|
1095
|
+
|
|
1096
|
+
for (let i = 0; i < shown.length; i++) {
|
|
1097
|
+
const [vuln, instances] = shown[i];
|
|
1098
|
+
const isLast = i === shown.length - 1 && hiddenTypes === 0;
|
|
1099
|
+
const prefix = isLast ? '└──' : '├──';
|
|
1100
|
+
const examples = instances.slice(0, 2).map(f => `${f.file}:${f.line}`).join(', ');
|
|
1101
|
+
const more = instances.length > 2 ? c(` +${instances.length - 2} more`, DIM) : '';
|
|
1102
|
+
const epssScores = instances.map(f => f.epssScore).filter(s => s != null);
|
|
1103
|
+
const epssTag = epssScores.length ? c(` EPSS:${Math.round(Math.max(...epssScores)*100)}%`, DIM) : '';
|
|
1104
|
+
lines.push(` ${prefix} ${c(`${vuln} ×${instances.length}`, BOLD)} ${c(examples, DIM)}${more}${epssTag}`);
|
|
1105
|
+
}
|
|
1106
|
+
if (hiddenTypes > 0) {
|
|
1107
|
+
lines.push(` └── ${c(`…and ${hiddenTypes} more vulnerability type${hiddenTypes > 1 ? 's' : ''}`, DIM)}`);
|
|
1108
|
+
}
|
|
1109
|
+
lines.push('');
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Top files by finding count
|
|
1113
|
+
const byFile = new Map();
|
|
1114
|
+
for (const f of findings.filter(x => x.severity !== 'info')) {
|
|
1115
|
+
byFile.set(f.file, (byFile.get(f.file) || 0) + 1);
|
|
1116
|
+
}
|
|
1117
|
+
const topFiles = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
1118
|
+
if (topFiles.length) {
|
|
1119
|
+
lines.push(c('Top files', BOLD));
|
|
1120
|
+
for (const [file, count] of topFiles) {
|
|
1121
|
+
lines.push(` ${c(String(count).padStart(3), SEV_COLOR.high)} ${file}`);
|
|
1122
|
+
}
|
|
1123
|
+
lines.push('');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
lines.push(c(`Run /security-report to generate a full interactive HTML report.`, DIM));
|
|
1127
|
+
return lines.join('\n');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
export function exitCodeFor(scan){
|
|
1131
|
+
const findings = normalizeFindings(scan);
|
|
1132
|
+
if (findings.some(f=>f.severity==='critical')) return 3;
|
|
1133
|
+
if (findings.some(f=>f.severity==='high')) return 2;
|
|
1134
|
+
if (findings.some(f=>f.severity==='medium' || f.severity==='low')) return 1;
|
|
1135
|
+
return 0;
|
|
1136
|
+
}
|