@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,71 @@
|
|
|
1
|
+
// Ruleset version stamp (Sentinel-parity audit P2-13).
|
|
2
|
+
//
|
|
3
|
+
// The engine's built-in rules (engine.js, sast/*.js) evolve every release.
|
|
4
|
+
// A change that's net-positive on the benchmark may regress on a specific
|
|
5
|
+
// customer's codebase. Operators need a way to PIN the rule set so an
|
|
6
|
+
// upgrade doesn't silently shift their finding stream.
|
|
7
|
+
//
|
|
8
|
+
// Mechanism:
|
|
9
|
+
// 1. Each release stamps a RULESET_VERSION string (e.g. "0.45.0-2026-05-18").
|
|
10
|
+
// 2. Operators write the version they want to use into
|
|
11
|
+
// .agentic-security/ruleset-version.json:
|
|
12
|
+
// { "version": "0.43.0-...", "pinned": true }
|
|
13
|
+
// 3. The engine reads this at scan time. When pinned to an OLDER version,
|
|
14
|
+
// it logs a notice saying which scanner build is installed but which
|
|
15
|
+
// ruleset version is being honored.
|
|
16
|
+
// 4. The version stamp is included in last-scan.json so /security-trend
|
|
17
|
+
// can attribute finding deltas to ruleset changes vs. code changes.
|
|
18
|
+
//
|
|
19
|
+
// LIMITATION: today, "pinning" is informational — it records intent but
|
|
20
|
+
// doesn't actually run a different rule set. A future release will ship a
|
|
21
|
+
// versioned ruleset-pack mechanism so old versions can be re-activated.
|
|
22
|
+
// This module is the foothold for that work.
|
|
23
|
+
|
|
24
|
+
import * as fs from 'node:fs';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { SCANNER_VERSION } from './version.js';
|
|
27
|
+
|
|
28
|
+
// Tied to scanner/package.json via posture/version.js — they cannot diverge
|
|
29
|
+
// (premortem 3R1.3).
|
|
30
|
+
export const CURRENT_RULESET_VERSION = SCANNER_VERSION;
|
|
31
|
+
|
|
32
|
+
const FILE = '.agentic-security/ruleset-version.json';
|
|
33
|
+
|
|
34
|
+
export function readPinned(scanRoot) {
|
|
35
|
+
const fp = path.join(scanRoot || process.cwd(), FILE);
|
|
36
|
+
if (!fs.existsSync(fp)) return null;
|
|
37
|
+
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolve the effective ruleset version: env override > pinned file > current.
|
|
42
|
+
export function effectiveVersion(scanRoot) {
|
|
43
|
+
if (process.env.AGENTIC_SECURITY_RULESET_VERSION) {
|
|
44
|
+
return { version: process.env.AGENTIC_SECURITY_RULESET_VERSION, source: 'env' };
|
|
45
|
+
}
|
|
46
|
+
const pinned = readPinned(scanRoot);
|
|
47
|
+
if (pinned && pinned.version) {
|
|
48
|
+
return { version: pinned.version, pinned: !!pinned.pinned, source: 'file' };
|
|
49
|
+
}
|
|
50
|
+
return { version: CURRENT_RULESET_VERSION, source: 'default' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Annotate a scan result with the ruleset version stamp.
|
|
54
|
+
export function stampScan(scanRoot, scan) {
|
|
55
|
+
if (!scan || typeof scan !== 'object') return scan;
|
|
56
|
+
const v = effectiveVersion(scanRoot);
|
|
57
|
+
scan._rulesetVersion = v.version;
|
|
58
|
+
scan._rulesetVersionSource = v.source;
|
|
59
|
+
if (v.version !== CURRENT_RULESET_VERSION && v.source !== 'default') {
|
|
60
|
+
// The operator pinned an older/newer version than what's installed.
|
|
61
|
+
// We surface this so they know the scan result reflects an intent
|
|
62
|
+
// mismatch (today, the pinning is informational — we don't actually
|
|
63
|
+
// run different rules — but the trail of intent is recorded).
|
|
64
|
+
scan._rulesetVersionMismatch = {
|
|
65
|
+
installed: CURRENT_RULESET_VERSION,
|
|
66
|
+
pinned: v.version,
|
|
67
|
+
note: 'Today the pinning is informational; future releases will honor it by running the historical rule set.',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return scan;
|
|
71
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// 0.7.0 Feat-6: SBOM emitters — CycloneDX 1.6 (JSON) + SPDX 2.3 (JSON).
|
|
2
|
+
//
|
|
3
|
+
// Reuses scan.components (parseManifests output) and scan.supplyChain to attach
|
|
4
|
+
// vulnerability metadata to each component. No outbound calls; pure transform.
|
|
5
|
+
//
|
|
6
|
+
// CycloneDX schema reference: https://cyclonedx.org/docs/1.6/json/
|
|
7
|
+
// SPDX 2.3 schema reference: https://spdx.github.io/spdx-spec/v2.3/
|
|
8
|
+
|
|
9
|
+
import * as crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
function _purl(c) {
|
|
12
|
+
if (c.purl) return c.purl;
|
|
13
|
+
const eco = c.ecosystem || 'generic';
|
|
14
|
+
const name = encodeURIComponent(c.name || '');
|
|
15
|
+
const ver = encodeURIComponent(c.version || '');
|
|
16
|
+
// pkg:npm/<name>@<version> — pkg URL spec
|
|
17
|
+
return `pkg:${eco === 'npm' ? 'npm' : eco === 'pypi' ? 'pypi' : eco === 'maven' ? 'maven' : eco === 'cargo' ? 'cargo' : eco === 'go' ? 'golang' : eco === 'rubygems' ? 'gem' : eco === 'composer' ? 'composer' : eco}/${name}@${ver}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function _bomRef(c) {
|
|
21
|
+
return `${c.ecosystem || 'pkg'}:${c.name}@${c.version}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function toCycloneDX(scan, meta = {}) {
|
|
25
|
+
const components = scan.components || [];
|
|
26
|
+
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
27
|
+
const serialNumber = `urn:uuid:${crypto.randomUUID()}`;
|
|
28
|
+
|
|
29
|
+
const cdxComponents = components.map(c => ({
|
|
30
|
+
type: 'library',
|
|
31
|
+
'bom-ref': _bomRef(c),
|
|
32
|
+
name: c.name,
|
|
33
|
+
version: c.version,
|
|
34
|
+
purl: _purl(c),
|
|
35
|
+
...(c.license ? { licenses: [{ license: { id: c.license } }] } : {}),
|
|
36
|
+
...(c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const vulnerabilities = supplyChain.map(s => ({
|
|
40
|
+
'bom-ref': `${_bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version })}#${s.osvId || s.advisory || crypto.randomUUID()}`,
|
|
41
|
+
id: s.osvId || (s.cveAliases || [])[0] || s.advisory,
|
|
42
|
+
source: { name: 'OSV.dev', url: `https://osv.dev/vulnerability/${s.osvId || ''}` },
|
|
43
|
+
references: (s.cveAliases || []).map(cve => ({ id: cve, source: { name: 'NVD' } })),
|
|
44
|
+
ratings: [
|
|
45
|
+
...(s.severity ? [{ severity: s.severity, method: 'other' }] : []),
|
|
46
|
+
...(s.cvssVector ? [{ vector: s.cvssVector, method: 'CVSSv3' }] : []),
|
|
47
|
+
],
|
|
48
|
+
description: s.description || s.advisory || '',
|
|
49
|
+
affects: [{ ref: _bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version }) }],
|
|
50
|
+
properties: [
|
|
51
|
+
...(s.epssScore != null ? [{ name: 'epss:score', value: String(s.epssScore) }] : []),
|
|
52
|
+
...(s.epssPercentile != null ? [{ name: 'epss:percentile', value: String(s.epssPercentile) }] : []),
|
|
53
|
+
...(s.functionReachable ? [{ name: 'agentic-security:functionReachable', value: s.functionReachable }] : []),
|
|
54
|
+
],
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
bomFormat: 'CycloneDX',
|
|
59
|
+
specVersion: '1.6',
|
|
60
|
+
serialNumber,
|
|
61
|
+
version: 1,
|
|
62
|
+
metadata: {
|
|
63
|
+
timestamp: meta.startedAt || new Date().toISOString(),
|
|
64
|
+
tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: '0.7.0' }],
|
|
65
|
+
component: { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
66
|
+
},
|
|
67
|
+
components: cdxComponents,
|
|
68
|
+
...(vulnerabilities.length ? { vulnerabilities } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function toSPDX(scan, meta = {}) {
|
|
73
|
+
const components = scan.components || [];
|
|
74
|
+
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
75
|
+
const docNamespace = `https://agentic-security.local/spdx/${crypto.randomUUID()}`;
|
|
76
|
+
const ts = meta.startedAt || new Date().toISOString();
|
|
77
|
+
|
|
78
|
+
const packages = components.map((c, i) => ({
|
|
79
|
+
SPDXID: `SPDXRef-Package-${i}`,
|
|
80
|
+
name: c.name,
|
|
81
|
+
versionInfo: c.version,
|
|
82
|
+
downloadLocation: 'NOASSERTION',
|
|
83
|
+
filesAnalyzed: false,
|
|
84
|
+
licenseConcluded: c.license || 'NOASSERTION',
|
|
85
|
+
licenseDeclared: c.license || 'NOASSERTION',
|
|
86
|
+
copyrightText: 'NOASSERTION',
|
|
87
|
+
externalRefs: [{
|
|
88
|
+
referenceCategory: 'PACKAGE-MANAGER',
|
|
89
|
+
referenceType: 'purl',
|
|
90
|
+
referenceLocator: _purl(c),
|
|
91
|
+
}],
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// SPDX expresses CVEs as external refs on the package, not separate elements
|
|
95
|
+
const cveByName = {};
|
|
96
|
+
for (const s of supplyChain) {
|
|
97
|
+
const k = `${s.ecosystem}:${s.name}@${s.version}`;
|
|
98
|
+
(cveByName[k] = cveByName[k] || []).push(...(s.cveAliases || (s.osvId ? [s.osvId] : [])));
|
|
99
|
+
}
|
|
100
|
+
for (let i = 0; i < components.length; i++) {
|
|
101
|
+
const c = components[i];
|
|
102
|
+
const k = `${c.ecosystem}:${c.name}@${c.version}`;
|
|
103
|
+
if (cveByName[k] && cveByName[k].length) {
|
|
104
|
+
packages[i].externalRefs.push(...cveByName[k].map(cve => ({
|
|
105
|
+
referenceCategory: 'SECURITY',
|
|
106
|
+
referenceType: 'cve',
|
|
107
|
+
referenceLocator: cve,
|
|
108
|
+
})));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
spdxVersion: 'SPDX-2.3',
|
|
114
|
+
dataLicense: 'CC0-1.0',
|
|
115
|
+
SPDXID: 'SPDXRef-DOCUMENT',
|
|
116
|
+
name: 'agentic-security-sbom',
|
|
117
|
+
documentNamespace: docNamespace,
|
|
118
|
+
creationInfo: {
|
|
119
|
+
created: ts,
|
|
120
|
+
creators: ['Tool: agentic-security-0.7.0'],
|
|
121
|
+
},
|
|
122
|
+
packages,
|
|
123
|
+
relationships: packages.map(p => ({
|
|
124
|
+
spdxElementId: 'SPDXRef-DOCUMENT',
|
|
125
|
+
relatedSpdxElement: p.SPDXID,
|
|
126
|
+
relationshipType: 'DESCRIBES',
|
|
127
|
+
})),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Schema-aware cross-language bridges (P4.1).
|
|
2
|
+
//
|
|
3
|
+
// The existing cross-lang bridges (`cross-lang-openapi.js`, `cross-lang-grpc.js`,
|
|
4
|
+
// `cross-lang-graphql.js`) work by NAME-MATCHING: they pair a JS client's
|
|
5
|
+
// `fetch('/api/users/:id')` with a Python `@app.get('/api/users/<id>')`.
|
|
6
|
+
// That misses real attack paths whenever:
|
|
7
|
+
// - the client and server disagree on the path shape (`:id` vs `<id>` vs `{id}`)
|
|
8
|
+
// - a field is named differently on each side (clientside `user.email`
|
|
9
|
+
// posted as JSON; serverside reads `data['emailAddress']`)
|
|
10
|
+
// - the schema permits extra fields the server silently uses
|
|
11
|
+
//
|
|
12
|
+
// SCHEMA-AWARE bridging uses the actual schema document (OpenAPI / proto /
|
|
13
|
+
// SDL) as the ground truth and propagates taint via STRUCTURAL FIELD
|
|
14
|
+
// IDENTITY rather than name string-equality:
|
|
15
|
+
//
|
|
16
|
+
// client posts `{ email, password }` to /signup
|
|
17
|
+
// ↓ (schema says /signup accepts { emailAddress: string, password: string })
|
|
18
|
+
// ↓ rename client.email → schema.emailAddress
|
|
19
|
+
// server reads request.body.emailAddress
|
|
20
|
+
// ↓ inherits client-side taint via schema.emailAddress
|
|
21
|
+
// server passes emailAddress into raw_sql query
|
|
22
|
+
// ↓ cross-language SQL-i chain
|
|
23
|
+
//
|
|
24
|
+
// This module builds the SCHEMA FIELD GRAPH for an OpenAPI / proto / SDL
|
|
25
|
+
// document and exposes a `matchEndpoint(schemaDoc, clientCall)` that
|
|
26
|
+
// returns the canonical endpoint shape (path-template + body-schema +
|
|
27
|
+
// param-schema) — usable by the existing bridges as an upgrade-in-place.
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize an OpenAPI 3.x path template to a canonical shape.
|
|
31
|
+
* `/users/{id}/posts/{postId}` → `/users/:_/posts/:_`
|
|
32
|
+
* `/users/<id>` → `/users/:_`
|
|
33
|
+
* `/users/:id` → `/users/:_`
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalizePath(p) {
|
|
36
|
+
if (typeof p !== 'string') return '';
|
|
37
|
+
return p
|
|
38
|
+
.replace(/\{[^}]+\}/g, ':_')
|
|
39
|
+
.replace(/<[^>]+>/g, ':_')
|
|
40
|
+
.replace(/:[A-Za-z0-9_]+/g, ':_')
|
|
41
|
+
.replace(/\/+$/g, '')
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** OpenAPI 3.x → flat list of endpoints with normalized shapes. */
|
|
46
|
+
export function indexOpenApi(doc) {
|
|
47
|
+
if (!doc || typeof doc !== 'object') return [];
|
|
48
|
+
const paths = doc.paths || {};
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const [rawPath, ops] of Object.entries(paths)) {
|
|
51
|
+
if (!ops || typeof ops !== 'object') continue;
|
|
52
|
+
for (const [method, def] of Object.entries(ops)) {
|
|
53
|
+
if (!/^(get|post|put|patch|delete|head|options)$/i.test(method)) continue;
|
|
54
|
+
if (!def || typeof def !== 'object') continue;
|
|
55
|
+
const bodySchema = resolveBodySchema(def, doc);
|
|
56
|
+
const paramFields = (def.parameters || [])
|
|
57
|
+
.filter(p => p && (p.in === 'query' || p.in === 'path' || p.in === 'header' || p.in === 'cookie'))
|
|
58
|
+
.map(p => ({ name: p.name, in: p.in, schema: p.schema || null }));
|
|
59
|
+
out.push({
|
|
60
|
+
method: method.toUpperCase(),
|
|
61
|
+
pathRaw: rawPath,
|
|
62
|
+
pathCanon: canonicalizePath(rawPath),
|
|
63
|
+
operationId: def.operationId || null,
|
|
64
|
+
bodyFields: flattenSchemaFields(bodySchema, doc),
|
|
65
|
+
paramFields,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveBodySchema(def, doc) {
|
|
73
|
+
const rb = def.requestBody;
|
|
74
|
+
if (!rb) return null;
|
|
75
|
+
const content = rb.content || {};
|
|
76
|
+
// Prefer application/json.
|
|
77
|
+
const json = content['application/json'] || content['application/x-www-form-urlencoded'] || Object.values(content)[0];
|
|
78
|
+
if (!json) return null;
|
|
79
|
+
return resolveRef(json.schema, doc);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveRef(node, doc) {
|
|
83
|
+
if (!node) return null;
|
|
84
|
+
if (node.$ref) {
|
|
85
|
+
const m = /^#\/components\/schemas\/([^/]+)$/.exec(node.$ref);
|
|
86
|
+
if (m) return doc?.components?.schemas?.[m[1]] || null;
|
|
87
|
+
}
|
|
88
|
+
return node;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Walk a JSON schema and return a flat list of `{ path, type }` field descriptors. */
|
|
92
|
+
export function flattenSchemaFields(schema, doc, prefix = '') {
|
|
93
|
+
if (!schema) return [];
|
|
94
|
+
const resolved = resolveRef(schema, doc);
|
|
95
|
+
if (!resolved) return [];
|
|
96
|
+
if (resolved.type === 'object' && resolved.properties) {
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const [name, prop] of Object.entries(resolved.properties)) {
|
|
99
|
+
const next = prefix ? `${prefix}.${name}` : name;
|
|
100
|
+
const childResolved = resolveRef(prop, doc);
|
|
101
|
+
if (childResolved && (childResolved.type === 'object' || childResolved.properties)) {
|
|
102
|
+
out.push(...flattenSchemaFields(childResolved, doc, next));
|
|
103
|
+
} else {
|
|
104
|
+
out.push({ path: next, type: childResolved?.type || 'unknown' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
if (resolved.type === 'array' && resolved.items) {
|
|
110
|
+
return flattenSchemaFields(resolved.items, doc, prefix ? `${prefix}[*]` : '[*]');
|
|
111
|
+
}
|
|
112
|
+
return prefix ? [{ path: prefix, type: resolved.type || 'unknown' }] : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Match a client-side call site `{ method, path, bodyKeys, queryKeys }`
|
|
117
|
+
* against the schema's endpoints. Returns the matched endpoint (or null)
|
|
118
|
+
* + a field-renaming map { clientKey: serverField } when synonyms are
|
|
119
|
+
* detected.
|
|
120
|
+
*
|
|
121
|
+
* Synonym rules (case-insensitive):
|
|
122
|
+
* email ↔ emailAddress ↔ mail
|
|
123
|
+
* pwd ↔ password
|
|
124
|
+
* id ↔ userId ↔ uid
|
|
125
|
+
* token ↔ accessToken ↔ authToken
|
|
126
|
+
*/
|
|
127
|
+
const SYNONYMS = [
|
|
128
|
+
['email', 'emailaddress', 'mail', 'email_address'],
|
|
129
|
+
['pwd', 'password', 'pass', 'passwd'],
|
|
130
|
+
['id', 'userid', 'uid', 'user_id'],
|
|
131
|
+
['token', 'accesstoken', 'authtoken', 'access_token'],
|
|
132
|
+
['name', 'fullname', 'displayname', 'full_name'],
|
|
133
|
+
];
|
|
134
|
+
const SYN_INDEX = new Map();
|
|
135
|
+
for (const grp of SYNONYMS) for (const w of grp) SYN_INDEX.set(w, grp);
|
|
136
|
+
|
|
137
|
+
function _norm(s) { return String(s || '').toLowerCase().replace(/[_-]/g, ''); }
|
|
138
|
+
function _areSynonyms(a, b) {
|
|
139
|
+
const na = _norm(a), nb = _norm(b);
|
|
140
|
+
if (na === nb) return true;
|
|
141
|
+
const ga = SYN_INDEX.get(na);
|
|
142
|
+
if (!ga) return false;
|
|
143
|
+
return ga.includes(nb);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function matchEndpoint(schemaIndex, clientCall) {
|
|
147
|
+
if (!Array.isArray(schemaIndex) || !clientCall) return null;
|
|
148
|
+
const methodU = (clientCall.method || 'GET').toUpperCase();
|
|
149
|
+
const pathC = canonicalizePath(clientCall.path || '');
|
|
150
|
+
// Exact (method, path canonical) match first.
|
|
151
|
+
let best = null;
|
|
152
|
+
for (const ep of schemaIndex) {
|
|
153
|
+
if (ep.method !== methodU) continue;
|
|
154
|
+
if (ep.pathCanon !== pathC) continue;
|
|
155
|
+
best = ep; break;
|
|
156
|
+
}
|
|
157
|
+
if (!best) return null;
|
|
158
|
+
// Build the rename map between client-side body keys and server-side fields.
|
|
159
|
+
const rename = {};
|
|
160
|
+
const clientKeys = Array.isArray(clientCall.bodyKeys) ? clientCall.bodyKeys : [];
|
|
161
|
+
const serverFields = best.bodyFields.map(f => f.path);
|
|
162
|
+
for (const ck of clientKeys) {
|
|
163
|
+
if (serverFields.includes(ck)) { rename[ck] = ck; continue; }
|
|
164
|
+
const hit = serverFields.find(sf => _areSynonyms(ck, sf));
|
|
165
|
+
if (hit) rename[ck] = hit;
|
|
166
|
+
}
|
|
167
|
+
return { endpoint: best, rename };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* gRPC proto → endpoint index. Accepts the AST shape produced by a generic
|
|
172
|
+
* proto3 parser (just the field tuples we care about: service + rpc + msg).
|
|
173
|
+
*/
|
|
174
|
+
export function indexProto(protoAst) {
|
|
175
|
+
if (!protoAst || !Array.isArray(protoAst.services)) return [];
|
|
176
|
+
const messages = new Map();
|
|
177
|
+
for (const m of (protoAst.messages || [])) messages.set(m.name, m.fields || []);
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const svc of protoAst.services) {
|
|
180
|
+
for (const rpc of (svc.rpcs || [])) {
|
|
181
|
+
out.push({
|
|
182
|
+
service: svc.name,
|
|
183
|
+
method: rpc.name,
|
|
184
|
+
requestType: rpc.requestType,
|
|
185
|
+
responseType: rpc.responseType,
|
|
186
|
+
requestFields: messages.get(rpc.requestType) || [],
|
|
187
|
+
responseFields: messages.get(rpc.responseType) || [],
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* GraphQL SDL → operation index.
|
|
196
|
+
*/
|
|
197
|
+
export function indexGraphQL(sdlAst) {
|
|
198
|
+
if (!sdlAst || !Array.isArray(sdlAst.types)) return [];
|
|
199
|
+
const types = new Map();
|
|
200
|
+
for (const t of sdlAst.types) types.set(t.name, t.fields || []);
|
|
201
|
+
const query = types.get('Query') || [];
|
|
202
|
+
const mutation = types.get('Mutation') || [];
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const f of query) out.push({ op: 'Query', name: f.name, args: f.args || [], returns: f.returns });
|
|
205
|
+
for (const f of mutation) out.push({ op: 'Mutation', name: f.name, args: f.args || [], returns: f.returns });
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Security regression scorecard — trend tracking across scans.
|
|
2
|
+
//
|
|
3
|
+
// Reads .agentic-security/scan-history.json (appended to by the post-edit hook
|
|
4
|
+
// and runScan) and returns a delta summary: findings added, findings fixed,
|
|
5
|
+
// net change, and which files regressed.
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const HISTORY_FILE = '.agentic-security/scan-history.json';
|
|
11
|
+
const MAX_HISTORY = 30; // rolling window
|
|
12
|
+
|
|
13
|
+
function _readHistory(scanRoot) {
|
|
14
|
+
const histPath = scanRoot ? path.join(scanRoot, HISTORY_FILE) : HISTORY_FILE;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _writeHistory(scanRoot, history) {
|
|
23
|
+
const histPath = scanRoot ? path.join(scanRoot, HISTORY_FILE) : HISTORY_FILE;
|
|
24
|
+
try {
|
|
25
|
+
fs.mkdirSync(path.dirname(histPath), { recursive: true });
|
|
26
|
+
fs.writeFileSync(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _snapshotFromScan(scan, label) {
|
|
31
|
+
const findings = scan.findings || [];
|
|
32
|
+
return {
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
label: label || 'scan',
|
|
35
|
+
total: findings.length,
|
|
36
|
+
critical: findings.filter(f => f.severity === 'critical').length,
|
|
37
|
+
high: findings.filter(f => f.severity === 'high').length,
|
|
38
|
+
medium: findings.filter(f => f.severity === 'medium').length,
|
|
39
|
+
low: findings.filter(f => f.severity === 'low').length,
|
|
40
|
+
kev: findings.filter(f => f.kev).length,
|
|
41
|
+
ids: new Set(findings.map(f => f.id).filter(Boolean)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function appendScanSnapshot(scan, scanRoot, label) {
|
|
46
|
+
const history = _readHistory(scanRoot);
|
|
47
|
+
const snap = _snapshotFromScan(scan, label);
|
|
48
|
+
// Don't store the full id Set in JSON — store sorted array
|
|
49
|
+
const entry = { ...snap, ids: [...snap.ids].sort() };
|
|
50
|
+
history.push(entry);
|
|
51
|
+
_writeHistory(scanRoot, history);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function computeTrend(scanRoot) {
|
|
55
|
+
const history = _readHistory(scanRoot);
|
|
56
|
+
if (history.length < 2) {
|
|
57
|
+
return { hasTrend: false, snapshots: history, message: 'Need at least 2 scans to show a trend.' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const prev = history[history.length - 2];
|
|
61
|
+
const curr = history[history.length - 1];
|
|
62
|
+
|
|
63
|
+
const prevIds = new Set(prev.ids || []);
|
|
64
|
+
const currIds = new Set(curr.ids || []);
|
|
65
|
+
|
|
66
|
+
const introduced = [...currIds].filter(id => !prevIds.has(id));
|
|
67
|
+
const fixed = [...prevIds].filter(id => !currIds.has(id));
|
|
68
|
+
|
|
69
|
+
const delta = curr.total - prev.total;
|
|
70
|
+
const critDelta = curr.critical - prev.critical;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
hasTrend: true,
|
|
74
|
+
snapshots: history,
|
|
75
|
+
prev: { timestamp: prev.timestamp, total: prev.total, critical: prev.critical, high: prev.high },
|
|
76
|
+
curr: { timestamp: curr.timestamp, total: curr.total, critical: curr.critical, high: curr.high },
|
|
77
|
+
introduced: introduced.length,
|
|
78
|
+
fixed: fixed.length,
|
|
79
|
+
delta,
|
|
80
|
+
critDelta,
|
|
81
|
+
improving: delta <= 0 && critDelta <= 0,
|
|
82
|
+
introducedIds: introduced.slice(0, 10),
|
|
83
|
+
fixedCount: fixed.length,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { appendScanSnapshot, computeTrend };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// FR-SEM-8 — Semantic clone / equivalence detection.
|
|
2
|
+
//
|
|
3
|
+
// Cluster findings whose sink-side code "shape" is structurally equivalent
|
|
4
|
+
// even when surface text differs (renamed variables, reordered statements,
|
|
5
|
+
// reformatted whitespace). The shape is a normalized AST-token hash: strip
|
|
6
|
+
// identifiers down to their kind (id/lit/op), collapse whitespace, drop
|
|
7
|
+
// comments. Two functions that compute the same thing under different names
|
|
8
|
+
// produce the same shape hash.
|
|
9
|
+
//
|
|
10
|
+
// Two uses:
|
|
11
|
+
// 1. Dedupe near-identical findings across cloned code regions
|
|
12
|
+
// (annotate `cloneClusterId` so /fix can patch the canonical instance).
|
|
13
|
+
// 2. Surface "you have 3 SQL escaper functions, 1 is broken" — emit an
|
|
14
|
+
// info finding when a clone cluster contains a mix of vulnerable and
|
|
15
|
+
// non-vulnerable members (the broken one is the outlier).
|
|
16
|
+
//
|
|
17
|
+
// This is intentionally a coarse approximation, not a full structural
|
|
18
|
+
// equivalence proof. It catches the common case (copy-paste with renaming);
|
|
19
|
+
// it does not catch true semantic equivalence under arbitrary refactoring.
|
|
20
|
+
|
|
21
|
+
import * as crypto from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
const JS_TOKEN_RE = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\b(?:if|else|for|while|switch|case|return|break|continue|throw|try|catch|finally|function|const|let|var|class|new|this|super|import|export|from|as|async|await|yield|of|in|typeof|instanceof|null|true|false|undefined)\b|\b[A-Za-z_$][\w$]*\b|0x[0-9a-fA-F]+|\d+(?:\.\d+)?|[(){}\[\];,.<>!?:+\-*\/=&|^~%]+|\s+)/g;
|
|
24
|
+
|
|
25
|
+
const JS_KEYWORDS = new Set([
|
|
26
|
+
'if','else','for','while','switch','case','return','break','continue','throw',
|
|
27
|
+
'try','catch','finally','function','const','let','var','class','new','this','super',
|
|
28
|
+
'import','export','from','as','async','await','yield','of','in','typeof','instanceof',
|
|
29
|
+
'null','true','false','undefined',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function tokenize(snippet) {
|
|
33
|
+
if (!snippet || typeof snippet !== 'string') return [];
|
|
34
|
+
const tokens = [];
|
|
35
|
+
for (const m of snippet.matchAll(JS_TOKEN_RE)) {
|
|
36
|
+
const t = m[0];
|
|
37
|
+
if (/^\s+$/.test(t)) continue;
|
|
38
|
+
if (/^\/[\/*]/.test(t)) continue; // comment
|
|
39
|
+
if (/^["'`]/.test(t)) { tokens.push('LIT'); continue; }
|
|
40
|
+
if (/^0x[0-9a-fA-F]+$|^\d/.test(t)) { tokens.push('NUM'); continue; }
|
|
41
|
+
if (JS_KEYWORDS.has(t)) { tokens.push(`K:${t}`); continue; }
|
|
42
|
+
if (/^[A-Za-z_$]/.test(t)) { tokens.push('ID'); continue; }
|
|
43
|
+
tokens.push(t);
|
|
44
|
+
}
|
|
45
|
+
return tokens;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function shapeHash(snippet, opts = {}) {
|
|
49
|
+
const tokens = tokenize(snippet);
|
|
50
|
+
if (tokens.length < (opts.minTokens ?? 8)) return null;
|
|
51
|
+
return crypto.createHash('sha256').update(tokens.join(' ')).digest('hex').slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Cluster findings by snippet shape. Returns the same array with two fields
|
|
55
|
+
// added to each finding: cloneClusterId (16-hex or null), cloneClusterSize.
|
|
56
|
+
export function annotateCloneClusters(findings) {
|
|
57
|
+
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
|
58
|
+
const buckets = new Map();
|
|
59
|
+
for (const f of findings) {
|
|
60
|
+
if (!f || typeof f !== 'object') continue;
|
|
61
|
+
const snippet = f.snippet || f.sink?.snippet || f.source?.snippet || '';
|
|
62
|
+
const hash = shapeHash(snippet);
|
|
63
|
+
if (!hash) { f.cloneClusterId = null; f.cloneClusterSize = 1; continue; }
|
|
64
|
+
f.cloneClusterId = hash;
|
|
65
|
+
if (!buckets.has(hash)) buckets.set(hash, []);
|
|
66
|
+
buckets.get(hash).push(f);
|
|
67
|
+
}
|
|
68
|
+
for (const [, group] of buckets) {
|
|
69
|
+
for (const f of group) f.cloneClusterSize = group.length;
|
|
70
|
+
}
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Surface "outlier in clone cluster" infos — when a cluster contains 2+
|
|
75
|
+
// members and they disagree on severity, the high-sev member is likely the
|
|
76
|
+
// broken implementation among siblings.
|
|
77
|
+
export function findCloneOutliers(findings) {
|
|
78
|
+
if (!Array.isArray(findings)) return [];
|
|
79
|
+
const buckets = new Map();
|
|
80
|
+
for (const f of findings) {
|
|
81
|
+
if (!f || !f.cloneClusterId) continue;
|
|
82
|
+
if (!buckets.has(f.cloneClusterId)) buckets.set(f.cloneClusterId, []);
|
|
83
|
+
buckets.get(f.cloneClusterId).push(f);
|
|
84
|
+
}
|
|
85
|
+
const out = [];
|
|
86
|
+
const SEV = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
87
|
+
for (const [hash, group] of buckets) {
|
|
88
|
+
if (group.length < 3) continue; // benchmark fixtures cluster as pairs constantly — require 3+
|
|
89
|
+
const sevs = new Set(group.map(g => g.severity));
|
|
90
|
+
if (sevs.size < 2) continue; // homogeneous cluster, not an outlier
|
|
91
|
+
group.sort((a, b) => (SEV[a.severity] ?? 9) - (SEV[b.severity] ?? 9));
|
|
92
|
+
const worst = group[0];
|
|
93
|
+
const worstRank = SEV[worst.severity] ?? 9;
|
|
94
|
+
const bestRank = SEV[group[group.length - 1].severity] ?? 9;
|
|
95
|
+
// Require: worst is high+/critical AND spread ≥ 2 severity tiers. This
|
|
96
|
+
// narrows the rule to genuine "3 sanitizers, 1 is broken" cases and
|
|
97
|
+
// suppresses benchmark-shape clones that vary only by safe/unsafe label.
|
|
98
|
+
if (worstRank > 1) continue; // worst must be at least 'high'
|
|
99
|
+
if (bestRank - worstRank < 2) continue; // need a real gap
|
|
100
|
+
out.push({
|
|
101
|
+
id: `clone-outlier:${hash}`,
|
|
102
|
+
file: worst.file,
|
|
103
|
+
line: worst.line || 0,
|
|
104
|
+
vuln: 'Structural clone outlier — one member of a cloned-code cluster is more severe than its siblings',
|
|
105
|
+
severity: 'info',
|
|
106
|
+
family: 'clone-outlier',
|
|
107
|
+
cloneClusterId: hash,
|
|
108
|
+
cloneClusterSize: group.length,
|
|
109
|
+
description: `${group.length} structurally-equivalent code regions detected; the one at ${worst.file}:${worst.line} carries ${worst.severity} severity vs. its siblings — likely the broken implementation among copy-pasted helpers.`,
|
|
110
|
+
remediation: 'Compare implementations across the cluster and either harmonize or remove the divergent member.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|