@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,249 @@
|
|
|
1
|
+
// Streaks + achievements state module.
|
|
2
|
+
//
|
|
3
|
+
// Persists `.agentic-security/streak.json` so the user can see progress
|
|
4
|
+
// across sessions: days clean of critical findings, total scans, total
|
|
5
|
+
// fixes applied, achievements earned. Pure transform — recordScan reads
|
|
6
|
+
// the latest scan + previous streak file and writes the updated state.
|
|
7
|
+
//
|
|
8
|
+
// Achievement design: every achievement is derived from the streak state
|
|
9
|
+
// itself, so we never have to detect "a fix happened" — we just compare
|
|
10
|
+
// counters between scans.
|
|
11
|
+
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
|
|
15
|
+
function _streakPath(stateDir) {
|
|
16
|
+
return path.join(stateDir, 'streak.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const _DEFAULT_STREAK = {
|
|
20
|
+
firstScanDate: null,
|
|
21
|
+
lastScanDate: null,
|
|
22
|
+
totalScans: 0,
|
|
23
|
+
daysCleanCritical: 0,
|
|
24
|
+
lastCleanDate: null,
|
|
25
|
+
lastCriticalDate: null,
|
|
26
|
+
hasEverHadCritical: false,
|
|
27
|
+
bestDaysCleanCritical: 0,
|
|
28
|
+
totalFindingsAtFirstScan: null,
|
|
29
|
+
totalFindingsAtLastScan: null,
|
|
30
|
+
totalFixesInferred: 0,
|
|
31
|
+
lastGrade: null,
|
|
32
|
+
bestGrade: null,
|
|
33
|
+
launchCheckPassedAt: null,
|
|
34
|
+
achievements: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function loadStreak(stateDir) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(_streakPath(stateDir), 'utf8');
|
|
40
|
+
return { ..._DEFAULT_STREAK, ...JSON.parse(raw) };
|
|
41
|
+
} catch {
|
|
42
|
+
return { ..._DEFAULT_STREAK };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _todayUTC() {
|
|
47
|
+
return new Date().toISOString().slice(0, 10);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _daysBetween(aIso, bIso) {
|
|
51
|
+
if (!aIso || !bIso) return 0;
|
|
52
|
+
const a = new Date(aIso + 'T00:00:00Z').getTime();
|
|
53
|
+
const b = new Date(bIso + 'T00:00:00Z').getTime();
|
|
54
|
+
return Math.round((b - a) / 86_400_000);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Compute a letter grade from severity counts. Mirrors /security-grade so
|
|
58
|
+
// we can track grade-delta in the streak.
|
|
59
|
+
function _computeGrade(scan) {
|
|
60
|
+
const findings = scan?.findings || [];
|
|
61
|
+
const supplyChain = scan?.supplyChain || [];
|
|
62
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
63
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
64
|
+
for (const s of supplyChain.filter(s => s.type === 'vulnerable_dep')) {
|
|
65
|
+
counts[s.severity || 'high'] = (counts[s.severity || 'high'] || 0) + 1;
|
|
66
|
+
}
|
|
67
|
+
const kev = [...findings, ...supplyChain].filter(f => f.kev === true).length;
|
|
68
|
+
const c = counts.critical, h = counts.high;
|
|
69
|
+
if (c > 10 || (c > 5 && kev > 0)) return 'F';
|
|
70
|
+
if (c >= 6) return 'D';
|
|
71
|
+
if (kev > 0) return 'D';
|
|
72
|
+
if (c >= 3) return 'C-';
|
|
73
|
+
if (c >= 1) return 'C';
|
|
74
|
+
if (h > 10) return 'B-';
|
|
75
|
+
if (h >= 3) return 'B';
|
|
76
|
+
if (h > 0) return 'A-';
|
|
77
|
+
if (counts.medium > 0) return 'A';
|
|
78
|
+
return 'A+';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const _GRADE_RANK = { 'F': 0, 'D': 1, 'C-': 2, 'C': 3, 'B-': 4, 'B': 5, 'A-': 6, 'A': 7, 'A+': 8 };
|
|
82
|
+
|
|
83
|
+
function _gradeRank(g) { return _GRADE_RANK[g] ?? -1; }
|
|
84
|
+
|
|
85
|
+
function _computeAchievements(streak, scan) {
|
|
86
|
+
const earned = new Set(streak.achievements || []);
|
|
87
|
+
const findings = scan?.findings || [];
|
|
88
|
+
const counts = { critical: 0, high: 0, medium: 0 };
|
|
89
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
90
|
+
|
|
91
|
+
// Lifetime achievements (unlock once, never expire). Tiers escalate
|
|
92
|
+
// Bronze → Silver → Gold → Platinum → Diamond. Old IDs preserved for
|
|
93
|
+
// backward compatibility with persisted streak.json files.
|
|
94
|
+
if (streak.totalScans >= 1) earned.add('first-scan');
|
|
95
|
+
if (streak.hasEverHadCritical && counts.critical === 0) earned.add('clean-sweep');
|
|
96
|
+
if (streak.totalFixesInferred >= 1) earned.add('first-fix');
|
|
97
|
+
// Triage tiers (was: triage-master at 10)
|
|
98
|
+
if (streak.totalFixesInferred >= 10) earned.add('triage-master');
|
|
99
|
+
if (streak.totalFixesInferred >= 50) earned.add('triage-silver');
|
|
100
|
+
if (streak.totalFixesInferred >= 200) earned.add('triage-gold');
|
|
101
|
+
// Streak tiers (Bronze/Silver/Gold/Platinum/Diamond)
|
|
102
|
+
if (streak.daysCleanCritical >= 7) earned.add('streak-7'); // Bronze
|
|
103
|
+
if (streak.daysCleanCritical >= 30) earned.add('streak-30'); // Silver
|
|
104
|
+
if (streak.daysCleanCritical >= 90) earned.add('streak-90'); // Gold
|
|
105
|
+
if (streak.daysCleanCritical >= 180) earned.add('streak-180'); // Platinum
|
|
106
|
+
if (streak.daysCleanCritical >= 365) earned.add('streak-365'); // Diamond
|
|
107
|
+
if (_gradeRank(streak.lastGrade) >= _gradeRank('A')) earned.add('grade-a');
|
|
108
|
+
if (streak.lastGrade === 'A+') earned.add('grade-a-plus');
|
|
109
|
+
if (streak.launchCheckPassedAt) earned.add('launch-ready');
|
|
110
|
+
if (streak.totalScans >= 25) earned.add('scan-veteran-25');
|
|
111
|
+
if (streak.totalScans >= 100) earned.add('scan-veteran-100');
|
|
112
|
+
|
|
113
|
+
return [...earned].sort();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Public — invoked by the CLI after every full scan.
|
|
117
|
+
export function recordScan(stateDir, scan) {
|
|
118
|
+
try { fs.mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
119
|
+
const prev = loadStreak(stateDir);
|
|
120
|
+
const today = _todayUTC();
|
|
121
|
+
|
|
122
|
+
const findings = scan?.findings || [];
|
|
123
|
+
const supplyChain = scan?.supplyChain || [];
|
|
124
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
125
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
126
|
+
for (const s of supplyChain.filter(s => s.type === 'vulnerable_dep')) {
|
|
127
|
+
counts[s.severity || 'high'] = (counts[s.severity || 'high'] || 0) + 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const totalNow = findings.length + supplyChain.filter(s => s.type === 'vulnerable_dep').length;
|
|
131
|
+
const grade = _computeGrade(scan);
|
|
132
|
+
|
|
133
|
+
// Streak math: increment days-clean only when we're clean today AND today is a new date
|
|
134
|
+
let daysCleanCritical = prev.daysCleanCritical || 0;
|
|
135
|
+
let lastCleanDate = prev.lastCleanDate;
|
|
136
|
+
let lastCriticalDate = prev.lastCriticalDate;
|
|
137
|
+
let hasEverHadCritical = prev.hasEverHadCritical;
|
|
138
|
+
|
|
139
|
+
if (counts.critical === 0) {
|
|
140
|
+
if (lastCleanDate !== today) {
|
|
141
|
+
// If yesterday was the last clean, +1; otherwise reset to 1 (new clean run)
|
|
142
|
+
const gap = _daysBetween(lastCleanDate, today);
|
|
143
|
+
daysCleanCritical = gap === 1 ? daysCleanCritical + 1 : 1;
|
|
144
|
+
lastCleanDate = today;
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
daysCleanCritical = 0;
|
|
148
|
+
lastCriticalDate = today;
|
|
149
|
+
hasEverHadCritical = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bestDaysCleanCritical = Math.max(prev.bestDaysCleanCritical || 0, daysCleanCritical);
|
|
153
|
+
|
|
154
|
+
// Infer "fixes applied" from finding count drops between consecutive scans
|
|
155
|
+
let totalFixesInferred = prev.totalFixesInferred || 0;
|
|
156
|
+
if (prev.totalFindingsAtLastScan != null && totalNow < prev.totalFindingsAtLastScan) {
|
|
157
|
+
totalFixesInferred += (prev.totalFindingsAtLastScan - totalNow);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const next = {
|
|
161
|
+
...prev,
|
|
162
|
+
firstScanDate: prev.firstScanDate || new Date().toISOString(),
|
|
163
|
+
lastScanDate: new Date().toISOString(),
|
|
164
|
+
totalScans: (prev.totalScans || 0) + 1,
|
|
165
|
+
daysCleanCritical,
|
|
166
|
+
lastCleanDate,
|
|
167
|
+
lastCriticalDate,
|
|
168
|
+
hasEverHadCritical,
|
|
169
|
+
bestDaysCleanCritical,
|
|
170
|
+
totalFindingsAtFirstScan: prev.totalFindingsAtFirstScan ?? totalNow,
|
|
171
|
+
totalFindingsAtLastScan: totalNow,
|
|
172
|
+
totalFixesInferred,
|
|
173
|
+
previousGrade: prev.lastGrade,
|
|
174
|
+
lastGrade: grade,
|
|
175
|
+
bestGrade: prev.bestGrade && _gradeRank(prev.bestGrade) >= _gradeRank(grade) ? prev.bestGrade : grade,
|
|
176
|
+
};
|
|
177
|
+
next.achievements = _computeAchievements(next, scan);
|
|
178
|
+
|
|
179
|
+
try { fs.writeFileSync(_streakPath(stateDir), JSON.stringify(next, null, 2)); } catch {}
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Mark "launch check passed 10/10" — called from /security-launch-check
|
|
184
|
+
export function markLaunchCheckPassed(stateDir) {
|
|
185
|
+
const prev = loadStreak(stateDir);
|
|
186
|
+
const next = { ...prev, launchCheckPassedAt: new Date().toISOString() };
|
|
187
|
+
next.achievements = _computeAchievements(next, { findings: [] });
|
|
188
|
+
try { fs.mkdirSync(stateDir, { recursive: true }); fs.writeFileSync(_streakPath(stateDir), JSON.stringify(next, null, 2)); } catch {}
|
|
189
|
+
return next;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function formatStreakLine(streak) {
|
|
193
|
+
if (!streak || !streak.totalScans) return null;
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (streak.daysCleanCritical >= 1) {
|
|
196
|
+
const flame = streak.daysCleanCritical >= 7 ? '🔥 ' : '';
|
|
197
|
+
parts.push(`${flame}${streak.daysCleanCritical} day${streak.daysCleanCritical === 1 ? '' : 's'} clean of critical findings`);
|
|
198
|
+
}
|
|
199
|
+
if (streak.lastGrade) {
|
|
200
|
+
parts.push(`grade ${streak.lastGrade}`);
|
|
201
|
+
}
|
|
202
|
+
if (streak.totalFixesInferred > 0) {
|
|
203
|
+
parts.push(`${streak.totalFixesInferred} fix${streak.totalFixesInferred === 1 ? '' : 'es'} applied`);
|
|
204
|
+
}
|
|
205
|
+
return parts.length ? parts.join(' · ') : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function formatGradeDelta(streak) {
|
|
209
|
+
if (!streak || !streak.previousGrade || !streak.lastGrade) return null;
|
|
210
|
+
if (streak.previousGrade === streak.lastGrade) return null;
|
|
211
|
+
const prev = _gradeRank(streak.previousGrade);
|
|
212
|
+
const now = _gradeRank(streak.lastGrade);
|
|
213
|
+
if (now > prev) return `📈 Grade up: ${streak.previousGrade} → ${streak.lastGrade}`;
|
|
214
|
+
if (now < prev) return `📉 Grade down: ${streak.previousGrade} → ${streak.lastGrade}`;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ACHIEVEMENT_LABELS = {
|
|
219
|
+
'first-scan': { icon: '🛡️', label: 'First Scan', desc: 'Ran your first security scan' },
|
|
220
|
+
'first-fix': { icon: '🔧', label: 'First Fix', desc: 'Applied at least one fix' },
|
|
221
|
+
'clean-sweep': { icon: '🧹', label: 'Clean Sweep', desc: 'Took your project from criticals to zero' },
|
|
222
|
+
// Triage tiers
|
|
223
|
+
'triage-master': { icon: '🎯', label: 'Bronze Fixer', desc: '10+ findings remediated' },
|
|
224
|
+
'triage-silver': { icon: '🥈', label: 'Silver Fixer', desc: '50+ findings remediated' },
|
|
225
|
+
'triage-gold': { icon: '🥇', label: 'Gold Fixer', desc: '200+ findings remediated' },
|
|
226
|
+
// Streak tiers (Bronze → Diamond)
|
|
227
|
+
'streak-7': { icon: '🥉', label: 'Bronze Streak', desc: '7 days clean of critical findings' },
|
|
228
|
+
'streak-30': { icon: '🥈', label: 'Silver Streak', desc: '30 days clean of critical findings' },
|
|
229
|
+
'streak-90': { icon: '🥇', label: 'Gold Streak', desc: '90 days clean of critical findings' },
|
|
230
|
+
'streak-180': { icon: '💎', label: 'Platinum Streak', desc: '180 days clean of critical findings' },
|
|
231
|
+
'streak-365': { icon: '💠', label: 'Diamond Streak', desc: '365 days clean of critical findings' },
|
|
232
|
+
// Grade
|
|
233
|
+
'grade-a': { icon: '🏆', label: 'Grade A', desc: 'Reached an A-tier security grade' },
|
|
234
|
+
'grade-a-plus': { icon: '🌟', label: 'Grade A+', desc: 'Reached the perfect grade — zero findings' },
|
|
235
|
+
'launch-ready': { icon: '🚀', label: 'Launch Ready', desc: 'Passed all 10 launch-check items' },
|
|
236
|
+
// Scan veteran
|
|
237
|
+
'scan-veteran-25': { icon: '⭐', label: 'Scan Veteran (25)', desc: '25 scans completed' },
|
|
238
|
+
'scan-veteran-100':{ icon: '🎖️', label: 'Scan Centurion (100)', desc: '100 scans completed' },
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export function formatAchievements(streak) {
|
|
242
|
+
if (!streak?.achievements?.length) return [];
|
|
243
|
+
return streak.achievements.map(id => ({
|
|
244
|
+
id,
|
|
245
|
+
...ACHIEVEMENT_LABELS[id] || { icon: '🏅', label: id, desc: '' },
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const _internal = { _computeGrade, _GRADE_RANK, _DEFAULT_STREAK, ACHIEVEMENT_LABELS };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Dual suppression schemas (R4).
|
|
2
|
+
// - vibecoder: .agentic-security/accepted.json (soft, 30-day, auto-reminder)
|
|
3
|
+
// - pro: .agentic-security/suppressions.yml (audit-grade: reason +
|
|
4
|
+
// reviewer + expiry + rule-version pin)
|
|
5
|
+
// One function `applySuppressions(findings, scanRoot, profile)` filters in
|
|
6
|
+
// place. Loaders accept malformed input gracefully (skip bad entries, log).
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import * as yaml from 'js-yaml';
|
|
11
|
+
|
|
12
|
+
const VIBECODER_PATH = '.agentic-security/accepted.json';
|
|
13
|
+
const PRO_PATH = '.agentic-security/suppressions.yml';
|
|
14
|
+
|
|
15
|
+
const MS_PER_DAY = 86400000;
|
|
16
|
+
const SOFT_TTL_DAYS = 30;
|
|
17
|
+
|
|
18
|
+
function _now() { return Date.now(); }
|
|
19
|
+
function _dateOnly(iso) {
|
|
20
|
+
// Accept full ISO or YYYY-MM-DD.
|
|
21
|
+
try { return new Date(iso).getTime(); } catch (_) { return NaN; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadSoftAccepted(scanRoot) {
|
|
25
|
+
const fp = path.join(scanRoot || process.cwd(), VIBECODER_PATH);
|
|
26
|
+
if (!fs.existsSync(fp)) return [];
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
29
|
+
return Array.isArray(raw.accepted) ? raw.accepted : [];
|
|
30
|
+
} catch (_) { return []; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveSoftAccepted(scanRoot, items) {
|
|
34
|
+
const fp = path.join(scanRoot || process.cwd(), VIBECODER_PATH);
|
|
35
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
36
|
+
fs.writeFileSync(fp, JSON.stringify({ accepted: items }, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function addSoftAcceptance(scanRoot, finding, reason) {
|
|
40
|
+
const items = loadSoftAccepted(scanRoot);
|
|
41
|
+
const expires = new Date(_now() + SOFT_TTL_DAYS * MS_PER_DAY).toISOString().slice(0, 10);
|
|
42
|
+
items.push({
|
|
43
|
+
id: finding.id || `${finding.file}:${finding.line}:${finding.vuln}`,
|
|
44
|
+
file: finding.file,
|
|
45
|
+
line: finding.line,
|
|
46
|
+
vuln: finding.vuln,
|
|
47
|
+
reason: reason || 'vibecoded for now',
|
|
48
|
+
accepted_at: new Date().toISOString().slice(0, 10),
|
|
49
|
+
expires_at: expires,
|
|
50
|
+
});
|
|
51
|
+
saveSoftAccepted(scanRoot, items);
|
|
52
|
+
return expires;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadProSuppressions(scanRoot) {
|
|
56
|
+
const fp = path.join(scanRoot || process.cwd(), PRO_PATH);
|
|
57
|
+
if (!fs.existsSync(fp)) return [];
|
|
58
|
+
try {
|
|
59
|
+
const parsed = yaml.load(fs.readFileSync(fp, 'utf8'));
|
|
60
|
+
return Array.isArray(parsed) ? parsed : (parsed?.suppressions || []);
|
|
61
|
+
} catch (_) { return []; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Validate one entry. Returns { ok, errors }.
|
|
65
|
+
export function validateProSuppression(entry) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
for (const k of ['finding_id', 'file', 'reason', 'justification_signed_by', 'reviewer', 'expires_at']) {
|
|
68
|
+
if (!entry[k] || (typeof entry[k] === 'string' && !entry[k].trim())) errors.push(`missing: ${k}`);
|
|
69
|
+
}
|
|
70
|
+
if (entry.justification_signed_by && entry.reviewer && entry.justification_signed_by === entry.reviewer) {
|
|
71
|
+
errors.push('justification_signed_by must differ from reviewer (two-person rule)');
|
|
72
|
+
}
|
|
73
|
+
if (entry.expires_at) {
|
|
74
|
+
const t = _dateOnly(entry.expires_at);
|
|
75
|
+
if (!Number.isFinite(t)) errors.push('expires_at must be ISO date');
|
|
76
|
+
else if (t < _now()) errors.push('expires_at is in the past');
|
|
77
|
+
}
|
|
78
|
+
if (entry.severity === 'critical' && !entry._accept_critical) {
|
|
79
|
+
errors.push('cannot suppress critical without --accept-critical flag at suppress time');
|
|
80
|
+
}
|
|
81
|
+
return { ok: errors.length === 0, errors };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function applySuppressions(findings, scanRoot, profile) {
|
|
85
|
+
const isVib = (profile?.profile || 'vibecoder') === 'vibecoder';
|
|
86
|
+
const isPro = (profile?.profile) === 'pro';
|
|
87
|
+
const items = isPro ? loadProSuppressions(scanRoot) : loadSoftAccepted(scanRoot);
|
|
88
|
+
if (!items.length) return findings;
|
|
89
|
+
|
|
90
|
+
const now = _now();
|
|
91
|
+
const kept = [];
|
|
92
|
+
const suppressed = [];
|
|
93
|
+
|
|
94
|
+
for (const f of findings) {
|
|
95
|
+
const fid = f.id || `${f.file}:${f.line}:${f.vuln}`;
|
|
96
|
+
let matched = null;
|
|
97
|
+
for (const s of items) {
|
|
98
|
+
const matchId = s.id || s.finding_id;
|
|
99
|
+
if (matchId && matchId === fid) { matched = s; break; }
|
|
100
|
+
// Also match by (file, line, vuln) tuple.
|
|
101
|
+
if (s.file === f.file && s.line === f.line && s.vuln === f.vuln) { matched = s; break; }
|
|
102
|
+
}
|
|
103
|
+
if (matched) {
|
|
104
|
+
// Has it expired?
|
|
105
|
+
const exp = _dateOnly(matched.expires_at || matched.expires || '');
|
|
106
|
+
if (Number.isFinite(exp) && exp < now) {
|
|
107
|
+
kept.push({ ...f, _suppressionExpired: true });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// Pro: validate the entry still passes
|
|
111
|
+
if (isPro) {
|
|
112
|
+
const v = validateProSuppression({ ...matched, severity: f.severity });
|
|
113
|
+
if (!v.ok) { kept.push({ ...f, _suppressionInvalid: v.errors }); continue; }
|
|
114
|
+
}
|
|
115
|
+
suppressed.push({ ...f, _suppressed: matched });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
kept.push(f);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (process.env.DEBUG_SUPPRESSIONS) {
|
|
122
|
+
console.error(`[suppressions] ${suppressed.length} suppressed, ${kept.length} kept`);
|
|
123
|
+
}
|
|
124
|
+
return kept;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Return suppressions that have expired so callers can remind the user.
|
|
128
|
+
export function expiredSoftAcceptances(scanRoot) {
|
|
129
|
+
const items = loadSoftAccepted(scanRoot);
|
|
130
|
+
const now = _now();
|
|
131
|
+
return items.filter(s => {
|
|
132
|
+
const exp = _dateOnly(s.expires_at);
|
|
133
|
+
return Number.isFinite(exp) && exp < now;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// FR-PROD-2 — Production telemetry ingest.
|
|
2
|
+
//
|
|
3
|
+
// Build a route → request-count map from a customer-supplied telemetry
|
|
4
|
+
// digest. The customer-side shim is responsible for producing the digest
|
|
5
|
+
// (no PII; counts and rates only). Expected format at
|
|
6
|
+
// `.agentic-security/telemetry.json`:
|
|
7
|
+
//
|
|
8
|
+
// {
|
|
9
|
+
// "windowDays": 30,
|
|
10
|
+
// "routes": {
|
|
11
|
+
// "GET /api/health": { "count": 42000, "lastSeen": "2026-05-17T..." },
|
|
12
|
+
// "POST /admin/users": { "count": 0, "lastSeen": null },
|
|
13
|
+
// "POST /api/webhooks": { "count": 8800, "lastSeen": "2026-05-18T..." }
|
|
14
|
+
// }
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// Findings whose location matches a route with 0 requests over the window
|
|
18
|
+
// get `coldPath: true` and a confidence dampener — likely the code path is
|
|
19
|
+
// behind a flag or dead. Findings on hot paths (>1k req/30d) get
|
|
20
|
+
// `hotPath: true` and a small priority bump.
|
|
21
|
+
//
|
|
22
|
+
// We deliberately do NOT call Sentry/Datadog APIs from this module. The
|
|
23
|
+
// customer-side shim is in PRD as a separate workstream because cross-vendor
|
|
24
|
+
// API access in the scanner has too many privacy and auth-token concerns.
|
|
25
|
+
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
|
|
29
|
+
const CANDIDATE_PATHS = [
|
|
30
|
+
'.agentic-security/telemetry.json',
|
|
31
|
+
'.agentic-security/prod-telemetry.json',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const HOT_THRESHOLD = 1000; // requests / window — promotes to hot
|
|
35
|
+
const COLD_THRESHOLD = 0; // exactly zero requests → cold
|
|
36
|
+
|
|
37
|
+
export function loadTelemetry(scanRoot) {
|
|
38
|
+
const root = scanRoot || process.cwd();
|
|
39
|
+
for (const rel of CANDIDATE_PATHS) {
|
|
40
|
+
const fp = path.join(root, rel);
|
|
41
|
+
if (!fs.existsSync(fp)) continue;
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
44
|
+
if (data && typeof data === 'object' && data.routes) return data;
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Heuristic: extract a route-shape signal from a finding's file path or
|
|
51
|
+
// content. Returns null when no route is inferable.
|
|
52
|
+
function routeShapeFor(f) {
|
|
53
|
+
const fp = String(f.file || '');
|
|
54
|
+
const inferred = [];
|
|
55
|
+
// Next.js / SvelteKit / Remix style.
|
|
56
|
+
let m;
|
|
57
|
+
if ((m = /\/(?:app|pages|routes)\/(.+?)(?:\/route\.[a-z]+|\.[a-z]+)$/i.exec(fp))) {
|
|
58
|
+
let r = '/' + m[1].replace(/\/(?:index|page|route)$/i, '');
|
|
59
|
+
r = r.replace(/\[([^\]]+)\]/g, ':$1');
|
|
60
|
+
inferred.push('* ' + r);
|
|
61
|
+
}
|
|
62
|
+
// Express / FastAPI / Flask: search the file snippet for app.METHOD('/path', ...)
|
|
63
|
+
if (f.snippet && typeof f.snippet === 'string') {
|
|
64
|
+
const sm = /app\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/i.exec(f.snippet);
|
|
65
|
+
if (sm) inferred.push(`${sm[1].toUpperCase()} ${sm[2]}`);
|
|
66
|
+
}
|
|
67
|
+
return inferred.length ? inferred : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Match a finding's inferred routes against the telemetry route map.
|
|
71
|
+
// Telemetry routes are like "GET /api/health"; finding routes may have
|
|
72
|
+
// `:param` placeholders. Use a permissive parameter-equivalence match.
|
|
73
|
+
function findRouteEntry(routes, candidates) {
|
|
74
|
+
if (!routes || !candidates) return null;
|
|
75
|
+
const norm = (s) => s.replace(/:\w+|\{\w+\}/g, '_PARAM_').trim();
|
|
76
|
+
for (const c of candidates) {
|
|
77
|
+
const cn = norm(c);
|
|
78
|
+
for (const [route, info] of Object.entries(routes)) {
|
|
79
|
+
if (norm(route) === cn) return info;
|
|
80
|
+
// method-agnostic match for cases where the finding only knows the path
|
|
81
|
+
const cmStar = cn.startsWith('* ');
|
|
82
|
+
if (cmStar) {
|
|
83
|
+
const cpath = cn.slice(2);
|
|
84
|
+
if (norm(route).endsWith(' ' + cpath)) return info;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function annotateTelemetry(findings, scanRoot) {
|
|
92
|
+
if (!Array.isArray(findings)) return findings;
|
|
93
|
+
const telem = loadTelemetry(scanRoot);
|
|
94
|
+
if (!telem) return findings;
|
|
95
|
+
for (const f of findings) {
|
|
96
|
+
if (!f || typeof f !== 'object') continue;
|
|
97
|
+
const candidates = routeShapeFor(f);
|
|
98
|
+
if (!candidates) continue;
|
|
99
|
+
const entry = findRouteEntry(telem.routes, candidates);
|
|
100
|
+
if (!entry) continue;
|
|
101
|
+
f.prodRequestCount = entry.count || 0;
|
|
102
|
+
f.prodLastSeen = entry.lastSeen || null;
|
|
103
|
+
if ((entry.count || 0) <= COLD_THRESHOLD) {
|
|
104
|
+
f.coldPath = true;
|
|
105
|
+
if (typeof f.confidence === 'number') f.confidence = Math.max(0, f.confidence - 0.08);
|
|
106
|
+
} else if ((entry.count || 0) >= HOT_THRESHOLD) {
|
|
107
|
+
f.hotPath = true;
|
|
108
|
+
if (typeof f.confidence === 'number') f.confidence = Math.min(1, f.confidence + 0.05);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return findings;
|
|
112
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// FR-LOGIC-10 — Threat-model auto-derivation (STRIDE).
|
|
2
|
+
//
|
|
3
|
+
// Build a lightweight STRIDE-aligned threat model for the project from
|
|
4
|
+
// observed signals — data flows, trust boundaries, asset inventory. The
|
|
5
|
+
// output feeds two consumers:
|
|
6
|
+
//
|
|
7
|
+
// 1. The PR-comment bot, which uses the threat model to write a "what
|
|
8
|
+
// the attacker would do here" paragraph next to high+ findings.
|
|
9
|
+
// 2. persona-prioritization.js (FR-ADV-2), which uses the asset list
|
|
10
|
+
// to weight crown-jewel-adjacent findings higher for the personas
|
|
11
|
+
// that care.
|
|
12
|
+
//
|
|
13
|
+
// We do NOT attempt a full STRIDE walkthrough — that would require running
|
|
14
|
+
// an LLM over the whole codebase. We DO emit a structured summary that an
|
|
15
|
+
// LLM can hydrate into a full narrative on demand.
|
|
16
|
+
//
|
|
17
|
+
// Output shape:
|
|
18
|
+
// {
|
|
19
|
+
// assets: [{ name, file, line, category, exposure }],
|
|
20
|
+
// trustBoundaries: [{ type, location, traffic }],
|
|
21
|
+
// stride: {
|
|
22
|
+
// spoofing: [...] // findings that bear on this STRIDE category
|
|
23
|
+
// tampering: [...]
|
|
24
|
+
// repudiation: [...]
|
|
25
|
+
// informationDisclosure: [...]
|
|
26
|
+
// denialOfService: [...]
|
|
27
|
+
// elevationOfPrivilege: [...]
|
|
28
|
+
// },
|
|
29
|
+
// }
|
|
30
|
+
|
|
31
|
+
const ASSET_PATTERNS = [
|
|
32
|
+
// [regex, category, exposure]
|
|
33
|
+
[/(?:stripe|paddle|braintree)\.\w+\.create/g, 'payment-method', 'public-api'],
|
|
34
|
+
[/(?:User|users?)\.create\(|prisma\.user\.create/g, 'identity', 'public-api'],
|
|
35
|
+
[/(?:session|token|jwt)\s*=\s*/g, 'session', 'internal'],
|
|
36
|
+
[/process\.env\.([A-Z_]+(?:KEY|SECRET|TOKEN))/g, 'secret', 'internal'],
|
|
37
|
+
[/(?:aws-sdk|@aws-sdk).*\.upload|s3\.put/g, 'object-storage', 'public-api'],
|
|
38
|
+
[/(?:openai|anthropic|together|groq)\.(?:chat|messages|completions)/g, 'llm-egress', 'external-api'],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const TRUST_BOUNDARY_PATTERNS = [
|
|
42
|
+
[/app\.(?:get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/g, 'http-route'],
|
|
43
|
+
[/router\.(?:get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/g, 'http-route'],
|
|
44
|
+
[/@(?:Get|Post|Put|Patch|Delete)Mapping\s*\(/g, 'http-route-java'],
|
|
45
|
+
[/@(?:app|router)\.(?:get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/g, 'http-route-py'],
|
|
46
|
+
[/(?:kafka|pubsub|sqs|sns)\.consume|\.subscribe|\.receiveMessage/gi, 'queue-consumer'],
|
|
47
|
+
[/(?:kafka|pubsub|sqs|sns)\.produce|\.publish|\.sendMessage/gi, 'queue-producer'],
|
|
48
|
+
[/grpc\.Server|new\s+Server\s*\(\s*\)/g, 'grpc-server'],
|
|
49
|
+
[/(?:db|pool|client)\.(?:query|execute|raw)\s*\(/g, 'db-edge'],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export function buildAssetInventory(fileContents) {
|
|
53
|
+
const assets = [];
|
|
54
|
+
if (!fileContents) return assets;
|
|
55
|
+
for (const [fp, text] of Object.entries(fileContents)) {
|
|
56
|
+
if (!text || typeof text !== 'string') continue;
|
|
57
|
+
for (const [re, category, exposure] of ASSET_PATTERNS) {
|
|
58
|
+
re.lastIndex = 0;
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = re.exec(text))) {
|
|
61
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
62
|
+
const name = m[1] || category;
|
|
63
|
+
assets.push({ name, file: fp, line, category, exposure });
|
|
64
|
+
if (assets.length >= 200) return assets;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return assets;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildTrustBoundaries(fileContents) {
|
|
72
|
+
const boundaries = [];
|
|
73
|
+
if (!fileContents) return boundaries;
|
|
74
|
+
for (const [fp, text] of Object.entries(fileContents)) {
|
|
75
|
+
if (!text || typeof text !== 'string') continue;
|
|
76
|
+
for (const [re, type] of TRUST_BOUNDARY_PATTERNS) {
|
|
77
|
+
re.lastIndex = 0;
|
|
78
|
+
let m;
|
|
79
|
+
while ((m = re.exec(text))) {
|
|
80
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
81
|
+
boundaries.push({ type, file: fp, line, label: m[1] || null });
|
|
82
|
+
if (boundaries.length >= 500) return boundaries;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return boundaries;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function strideCategoryFor(f) {
|
|
90
|
+
const v = (f.vuln || '').toLowerCase();
|
|
91
|
+
if (/auth|jwt|session|csrf|spoof/.test(v)) return 'spoofing';
|
|
92
|
+
if (/injection|deserial|tampered|prototype.pollution|toctou|race/.test(v)) return 'tampering';
|
|
93
|
+
if (/log.injection|missing.audit|no.audit/.test(v)) return 'repudiation';
|
|
94
|
+
if (/leak|disclos|info.expos|stack.trace|verbose.err/.test(v)) return 'informationDisclosure';
|
|
95
|
+
if (/dos|denial|unbounded|max_tokens|rate.limit|redos/.test(v)) return 'denialOfService';
|
|
96
|
+
if (/idor|missing.authz|broken.access|priv.esc|admin/.test(v)) return 'elevationOfPrivilege';
|
|
97
|
+
if (/sql.injection|command.injection|ssrf|xxe|rce|code.injection/.test(v)) return 'tampering';
|
|
98
|
+
if (/xss/.test(v)) return 'tampering';
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function classifyFindingsByStride(findings) {
|
|
103
|
+
const out = {
|
|
104
|
+
spoofing: [], tampering: [], repudiation: [],
|
|
105
|
+
informationDisclosure: [], denialOfService: [], elevationOfPrivilege: [],
|
|
106
|
+
};
|
|
107
|
+
if (!Array.isArray(findings)) return out;
|
|
108
|
+
for (const f of findings) {
|
|
109
|
+
if (!f || typeof f !== 'object') continue;
|
|
110
|
+
const cat = strideCategoryFor(f);
|
|
111
|
+
if (!cat) continue;
|
|
112
|
+
out[cat].push({ vuln: f.vuln, file: f.file, line: f.line, severity: f.severity });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function buildThreatModel(findings, fileContents) {
|
|
118
|
+
const assets = buildAssetInventory(fileContents);
|
|
119
|
+
const trustBoundaries = buildTrustBoundaries(fileContents);
|
|
120
|
+
const stride = classifyFindingsByStride(findings || []);
|
|
121
|
+
// Cap each STRIDE bucket so the model object stays compact in SARIF.
|
|
122
|
+
for (const k of Object.keys(stride)) stride[k] = stride[k].slice(0, 25);
|
|
123
|
+
return {
|
|
124
|
+
summary: {
|
|
125
|
+
assetCount: assets.length,
|
|
126
|
+
boundaryCount: trustBoundaries.length,
|
|
127
|
+
strideCounts: Object.fromEntries(Object.entries(stride).map(([k, v]) => [k, v.length])),
|
|
128
|
+
},
|
|
129
|
+
assets: assets.slice(0, 50),
|
|
130
|
+
trustBoundaries: trustBoundaries.slice(0, 50),
|
|
131
|
+
stride,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Compose: annotate each finding with its STRIDE category so consumers can
|
|
136
|
+
// pivot by attacker objective rather than by CWE.
|
|
137
|
+
export function annotateStrideCategory(findings) {
|
|
138
|
+
if (!Array.isArray(findings)) return findings;
|
|
139
|
+
for (const f of findings) {
|
|
140
|
+
if (!f || typeof f !== 'object') continue;
|
|
141
|
+
const cat = strideCategoryFor(f);
|
|
142
|
+
if (cat) f.strideCategory = cat;
|
|
143
|
+
}
|
|
144
|
+
return findings;
|
|
145
|
+
}
|