@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.
Files changed (331) hide show
  1. package/CHANGELOG.md +1580 -0
  2. package/bin/.agentic-security/findings.json +1577 -0
  3. package/bin/.agentic-security/last-scan.json +1577 -0
  4. package/bin/.agentic-security/last-scan.json.sig +1 -0
  5. package/bin/.agentic-security/scan-history.json +465 -0
  6. package/bin/.agentic-security/streak.json +25 -0
  7. package/bin/agentic-security-audit.js +198 -0
  8. package/bin/agentic-security-consistency.js +80 -0
  9. package/bin/agentic-security-diff.js +136 -0
  10. package/bin/agentic-security-lsp.js +12 -0
  11. package/bin/agentic-security-mcp.js +40 -0
  12. package/bin/agentic-security-rule.js +153 -0
  13. package/bin/agentic-security.js +1683 -0
  14. package/dist/117.index.js +207 -0
  15. package/dist/178.index.js +250 -0
  16. package/dist/218.index.js +793 -0
  17. package/dist/227.index.js +192 -0
  18. package/dist/301.index.js +167 -0
  19. package/dist/384.index.js +18 -0
  20. package/dist/476.index.js +126 -0
  21. package/dist/513.index.js +373 -0
  22. package/dist/520.index.js +13 -0
  23. package/dist/601.index.js +1038 -0
  24. package/dist/634.index.js +1892 -0
  25. package/dist/637.index.js +216 -0
  26. package/dist/660.index.js +131 -0
  27. package/dist/675.index.js +451 -0
  28. package/dist/826.index.js +188 -0
  29. package/dist/830.index.js +133 -0
  30. package/dist/agentic-security.mjs +272 -0
  31. package/dist/agentic-security.mjs.sha256 +1 -0
  32. package/dist/calibration-seed.json +27 -0
  33. package/package.json +77 -0
  34. package/src/.agentic-security/findings.json +80844 -0
  35. package/src/.agentic-security/last-scan.json +80844 -0
  36. package/src/.agentic-security/last-scan.json.sig +1 -0
  37. package/src/.agentic-security/scan-history.json +8408 -0
  38. package/src/.agentic-security/streak.json +26 -0
  39. package/src/badge.js +188 -0
  40. package/src/compare.js +203 -0
  41. package/src/dataflow/.agentic-security/findings.json +3487 -0
  42. package/src/dataflow/.agentic-security/last-scan.json +3487 -0
  43. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  44. package/src/dataflow/.agentic-security/scan-history.json +735 -0
  45. package/src/dataflow/.agentic-security/streak.json +24 -0
  46. package/src/dataflow/CLAUDE.md +38 -0
  47. package/src/dataflow/access-paths.js +172 -0
  48. package/src/dataflow/async-sequencing.js +177 -0
  49. package/src/dataflow/backward.js +201 -0
  50. package/src/dataflow/catalog-expanded.js +485 -0
  51. package/src/dataflow/catalog.js +659 -0
  52. package/src/dataflow/cross-repo.js +219 -0
  53. package/src/dataflow/engine.js +588 -0
  54. package/src/dataflow/exception-flow.js +116 -0
  55. package/src/dataflow/exploit-prover.js +187 -0
  56. package/src/dataflow/higher-order.js +221 -0
  57. package/src/dataflow/ifds.js +347 -0
  58. package/src/dataflow/implicit-flow.js +129 -0
  59. package/src/dataflow/incremental.js +229 -0
  60. package/src/dataflow/index.js +181 -0
  61. package/src/dataflow/numeric-domain.js +192 -0
  62. package/src/dataflow/path-feasibility.js +114 -0
  63. package/src/dataflow/points-to.js +337 -0
  64. package/src/dataflow/polyglot.js +190 -0
  65. package/src/dataflow/proven-clean.js +159 -0
  66. package/src/dataflow/receiver-context.js +76 -0
  67. package/src/dataflow/sanitizer-proof.js +154 -0
  68. package/src/dataflow/soft-taint.js +140 -0
  69. package/src/dataflow/string-domain.js +234 -0
  70. package/src/dataflow/stub-aware-filter.js +100 -0
  71. package/src/dataflow/summaries.js +132 -0
  72. package/src/dataflow/symbolic-exec.js +238 -0
  73. package/src/dataflow/tabulation.js +135 -0
  74. package/src/engine.js +7763 -0
  75. package/src/history-scan.js +229 -0
  76. package/src/index.js +3 -0
  77. package/src/integrations/.agentic-security/findings.json +1504 -0
  78. package/src/integrations/.agentic-security/last-scan.json +1504 -0
  79. package/src/integrations/.agentic-security/scan-history.json +40 -0
  80. package/src/integrations/.agentic-security/streak.json +21 -0
  81. package/src/integrations/index.js +321 -0
  82. package/src/integrations/tickets.js +200 -0
  83. package/src/ir/.agentic-security/findings.json +3036 -0
  84. package/src/ir/.agentic-security/last-scan.json +3036 -0
  85. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  86. package/src/ir/.agentic-security/scan-history.json +364 -0
  87. package/src/ir/.agentic-security/streak.json +23 -0
  88. package/src/ir/CLAUDE.md +172 -0
  89. package/src/ir/callgraph.js +73 -0
  90. package/src/ir/class-hierarchy.js +195 -0
  91. package/src/ir/index.js +152 -0
  92. package/src/ir/parser-cs.js +260 -0
  93. package/src/ir/parser-java.js +286 -0
  94. package/src/ir/parser-js.js +413 -0
  95. package/src/ir/parser-kt.js +258 -0
  96. package/src/ir/parser-py-cst.js +136 -0
  97. package/src/ir/parser-py.helper.py +501 -0
  98. package/src/ir/parser-py.js +312 -0
  99. package/src/ir/ssa.js +315 -0
  100. package/src/ir/type-stubs.js +288 -0
  101. package/src/leaderboard.js +152 -0
  102. package/src/llm-validator/.agentic-security/findings.json +1891 -0
  103. package/src/llm-validator/.agentic-security/last-scan.json +1891 -0
  104. package/src/llm-validator/.agentic-security/last-scan.json.sig +1 -0
  105. package/src/llm-validator/.agentic-security/scan-history.json +168 -0
  106. package/src/llm-validator/.agentic-security/streak.json +20 -0
  107. package/src/llm-validator/consistency.js +141 -0
  108. package/src/llm-validator/index.js +437 -0
  109. package/src/lsp/.agentic-security/findings.json +28 -0
  110. package/src/lsp/.agentic-security/last-scan.json +28 -0
  111. package/src/lsp/.agentic-security/scan-history.json +79 -0
  112. package/src/lsp/.agentic-security/streak.json +22 -0
  113. package/src/lsp/server.js +275 -0
  114. package/src/mcp/.agentic-security/findings.json +8358 -0
  115. package/src/mcp/.agentic-security/last-scan.json +8358 -0
  116. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  117. package/src/mcp/.agentic-security/scan-history.json +1125 -0
  118. package/src/mcp/.agentic-security/streak.json +22 -0
  119. package/src/mcp/CLAUDE.md +54 -0
  120. package/src/mcp/audit.js +136 -0
  121. package/src/mcp/redact.js +75 -0
  122. package/src/mcp/server.js +158 -0
  123. package/src/mcp/stdio.js +83 -0
  124. package/src/mcp/tools.js +940 -0
  125. package/src/mcp/validate.js +49 -0
  126. package/src/personality.js +164 -0
  127. package/src/poc-video.js +239 -0
  128. package/src/posture/.agentic-security/findings.json +51239 -0
  129. package/src/posture/.agentic-security/last-scan.json +51239 -0
  130. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  131. package/src/posture/.agentic-security/scan-history.json +5557 -0
  132. package/src/posture/.agentic-security/streak.json +24 -0
  133. package/src/posture/CLAUDE.md +42 -0
  134. package/src/posture/adversarial-self-test.js +114 -0
  135. package/src/posture/adversary-agent.js +204 -0
  136. package/src/posture/agents-memory.js +135 -0
  137. package/src/posture/ai-code-fingerprint.js +171 -0
  138. package/src/posture/aibom.js +284 -0
  139. package/src/posture/api-inventory.js +96 -0
  140. package/src/posture/attack-playbooks.js +305 -0
  141. package/src/posture/auditor-agent.js +115 -0
  142. package/src/posture/auth-posture-import.js +135 -0
  143. package/src/posture/baseline-compare.js +114 -0
  144. package/src/posture/blast-radius.js +836 -0
  145. package/src/posture/bounty-prediction.js +141 -0
  146. package/src/posture/business-logic.js +239 -0
  147. package/src/posture/calibration-drift.js +93 -0
  148. package/src/posture/calibration-seed.json +27 -0
  149. package/src/posture/calibration.js +204 -0
  150. package/src/posture/clustering.js +75 -0
  151. package/src/posture/concurrency-checker.js +265 -0
  152. package/src/posture/confidence.js +65 -0
  153. package/src/posture/container-runtime.js +149 -0
  154. package/src/posture/counterfactual.js +109 -0
  155. package/src/posture/cross-lang-graphql.js +165 -0
  156. package/src/posture/cross-lang-grpc.js +166 -0
  157. package/src/posture/cross-lang-meta.js +101 -0
  158. package/src/posture/cross-lang-openapi.js +187 -0
  159. package/src/posture/cross-lang-orm.js +153 -0
  160. package/src/posture/cross-lang-queues.js +210 -0
  161. package/src/posture/crown-jewels.js +110 -0
  162. package/src/posture/custom-rules.js +361 -0
  163. package/src/posture/cve-alert-daemon.js +433 -0
  164. package/src/posture/cve-lookup.js +129 -0
  165. package/src/posture/dead-code.js +430 -0
  166. package/src/posture/defender-agent.js +158 -0
  167. package/src/posture/deploy-platform.js +204 -0
  168. package/src/posture/detector-fuzz.js +61 -0
  169. package/src/posture/deterministic.js +99 -0
  170. package/src/posture/drift.js +165 -0
  171. package/src/posture/epss.js +156 -0
  172. package/src/posture/exploitability-probability.js +212 -0
  173. package/src/posture/exploitability.js +121 -0
  174. package/src/posture/feature-flags.js +110 -0
  175. package/src/posture/finding-defaults.js +132 -0
  176. package/src/posture/fix-history.js +411 -0
  177. package/src/posture/fix-plan.js +121 -0
  178. package/src/posture/fix-verify-loop.js +157 -0
  179. package/src/posture/fix-verify.js +130 -0
  180. package/src/posture/flow-narration.js +105 -0
  181. package/src/posture/grader-calibration.js +156 -0
  182. package/src/posture/harness-discovery.js +113 -0
  183. package/src/posture/holdout-eval.js +144 -0
  184. package/src/posture/iac-reachability.js +163 -0
  185. package/src/posture/iam-policy.js +128 -0
  186. package/src/posture/integrity.js +97 -0
  187. package/src/posture/learning.js +166 -0
  188. package/src/posture/license-policy.js +109 -0
  189. package/src/posture/llm-redteam-prompts.js +418 -0
  190. package/src/posture/llm-redteam.js +303 -0
  191. package/src/posture/material-change.js +163 -0
  192. package/src/posture/mitigation-composite.js +55 -0
  193. package/src/posture/mttr.js +91 -0
  194. package/src/posture/network-policy-import.js +126 -0
  195. package/src/posture/path-predicates.js +99 -0
  196. package/src/posture/persona-prioritization.js +153 -0
  197. package/src/posture/poc-cwe-map.js +51 -0
  198. package/src/posture/poc-generator.js +500 -0
  199. package/src/posture/policy-gate.js +174 -0
  200. package/src/posture/pre-incident-archaeology.js +110 -0
  201. package/src/posture/profile.js +93 -0
  202. package/src/posture/reachability-filter.js +42 -0
  203. package/src/posture/regression-test-gen.js +200 -0
  204. package/src/posture/reverse-blast-radius.js +110 -0
  205. package/src/posture/router.js +109 -0
  206. package/src/posture/rule-overrides.js +198 -0
  207. package/src/posture/rule-pack-signing.js +209 -0
  208. package/src/posture/rule-packs.js +143 -0
  209. package/src/posture/rule-synthesis.js +108 -0
  210. package/src/posture/ruleset-version.js +71 -0
  211. package/src/posture/sbom.js +129 -0
  212. package/src/posture/schema-aware-bridge.js +207 -0
  213. package/src/posture/security-trend.js +87 -0
  214. package/src/posture/semantic-clone.js +114 -0
  215. package/src/posture/specification-mining.js +170 -0
  216. package/src/posture/stable-id.js +75 -0
  217. package/src/posture/stack-playbook.js +229 -0
  218. package/src/posture/streak.js +249 -0
  219. package/src/posture/suppressions.js +135 -0
  220. package/src/posture/telemetry-ingest.js +112 -0
  221. package/src/posture/threat-model.js +145 -0
  222. package/src/posture/three-agent-pipeline.js +74 -0
  223. package/src/posture/triage.js +146 -0
  224. package/src/posture/trust-boundary-diagram.js +115 -0
  225. package/src/posture/type-narrowing.js +129 -0
  226. package/src/posture/validator-metrics.js +179 -0
  227. package/src/posture/verifier-ephemeral.js +118 -0
  228. package/src/posture/verifier-target.js +147 -0
  229. package/src/posture/verifier.js +257 -0
  230. package/src/posture/version.js +75 -0
  231. package/src/posture/waf-ingest.js +200 -0
  232. package/src/posture/why-fired.js +141 -0
  233. package/src/pr-comment.js +172 -0
  234. package/src/pr-delta.js +198 -0
  235. package/src/report/.agentic-security/findings.json +79 -0
  236. package/src/report/.agentic-security/last-scan.json +79 -0
  237. package/src/report/.agentic-security/last-scan.json.sig +1 -0
  238. package/src/report/.agentic-security/scan-history.json +332 -0
  239. package/src/report/.agentic-security/streak.json +23 -0
  240. package/src/report/index.js +1136 -0
  241. package/src/report/mascot.js +42 -0
  242. package/src/runScan.js +141 -0
  243. package/src/sast/.agentic-security/findings.json +5051 -0
  244. package/src/sast/.agentic-security/last-scan.json +5051 -0
  245. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  246. package/src/sast/.agentic-security/scan-history.json +788 -0
  247. package/src/sast/.agentic-security/streak.json +23 -0
  248. package/src/sast/CLAUDE.md +39 -0
  249. package/src/sast/_comment-strip.js +46 -0
  250. package/src/sast/agent-tool-escalation.js +131 -0
  251. package/src/sast/auth-provider.js +171 -0
  252. package/src/sast/authz.js +236 -0
  253. package/src/sast/bench-shape/.agentic-security/findings.json +28 -0
  254. package/src/sast/bench-shape/.agentic-security/last-scan.json +28 -0
  255. package/src/sast/bench-shape/.agentic-security/scan-history.json +24 -0
  256. package/src/sast/bench-shape/.agentic-security/streak.json +22 -0
  257. package/src/sast/bench-shape/index.js +62 -0
  258. package/src/sast/claude-hook-injection.js +199 -0
  259. package/src/sast/claude-md-prompt-injection.js +170 -0
  260. package/src/sast/claude-settings.js +165 -0
  261. package/src/sast/client-side.js +149 -0
  262. package/src/sast/cpp-bench-extras.js +122 -0
  263. package/src/sast/cpp-dataflow.js +430 -0
  264. package/src/sast/cpp.js +248 -0
  265. package/src/sast/csharp.js +152 -0
  266. package/src/sast/csrf.js +82 -0
  267. package/src/sast/dart-flutter.js +173 -0
  268. package/src/sast/db-rls.js +147 -0
  269. package/src/sast/db-taint.js +215 -0
  270. package/src/sast/defi-deep.js +242 -0
  271. package/src/sast/deserialization-gadgets.js +113 -0
  272. package/src/sast/django-hardening.js +230 -0
  273. package/src/sast/env-hygiene.js +125 -0
  274. package/src/sast/fastapi-hardening.js +145 -0
  275. package/src/sast/go-extended.js +84 -0
  276. package/src/sast/host-header.js +106 -0
  277. package/src/sast/index.js +17 -0
  278. package/src/sast/java-ast-folding.js +561 -0
  279. package/src/sast/java-bench-extras.js +708 -0
  280. package/src/sast/java-collection-passthrough.js +178 -0
  281. package/src/sast/java-constant-fold.js +244 -0
  282. package/src/sast/java-deserialization.js +125 -0
  283. package/src/sast/jndi.js +104 -0
  284. package/src/sast/juliet-shape.js +324 -0
  285. package/src/sast/jwt-exp.js +104 -0
  286. package/src/sast/kotlin.js +82 -0
  287. package/src/sast/laravel-hardening.js +198 -0
  288. package/src/sast/ldap-injection.js +100 -0
  289. package/src/sast/llm-owasp.js +465 -0
  290. package/src/sast/llm-stored-prompt.js +103 -0
  291. package/src/sast/llm-trading-agent.js +161 -0
  292. package/src/sast/llm.js +308 -0
  293. package/src/sast/logic.js +140 -0
  294. package/src/sast/mass-assignment.js +101 -0
  295. package/src/sast/mcp-audit.js +242 -0
  296. package/src/sast/mobile-manifest.js +195 -0
  297. package/src/sast/model-load.js +164 -0
  298. package/src/sast/mutation-xss.js +87 -0
  299. package/src/sast/nosql-injection.js +82 -0
  300. package/src/sast/open-redirect.js +119 -0
  301. package/src/sast/php.js +91 -0
  302. package/src/sast/pipeline.js +122 -0
  303. package/src/sast/primary-cwe-java.js +155 -0
  304. package/src/sast/prompt-firewall.js +151 -0
  305. package/src/sast/prompt-template.js +157 -0
  306. package/src/sast/prototype-pollution.js +112 -0
  307. package/src/sast/python-sinks.js +195 -0
  308. package/src/sast/quarkus-hardening.js +102 -0
  309. package/src/sast/rag-poisoning.js +118 -0
  310. package/src/sast/rate-limit.js +128 -0
  311. package/src/sast/response-splitting.js +138 -0
  312. package/src/sast/ruby.js +108 -0
  313. package/src/sast/rust.js +105 -0
  314. package/src/sast/solidity.js +167 -0
  315. package/src/sast/springboot-hardening.js +186 -0
  316. package/src/sast/ssrf-cloud-metadata.js +80 -0
  317. package/src/sast/ssti.js +116 -0
  318. package/src/sast/swift.js +162 -0
  319. package/src/sast/toctou.js +95 -0
  320. package/src/sast/webhook.js +101 -0
  321. package/src/sast/xpath-injection.js +51 -0
  322. package/src/sast/xxe.js +140 -0
  323. package/src/sast/zip-slip.js +200 -0
  324. package/src/sca/base-images.json +45 -0
  325. package/src/sca/container.js +107 -0
  326. package/src/sca/dep-confusion.js +134 -0
  327. package/src/sca/index.js +6 -0
  328. package/src/sca/popular-packages.json +41 -0
  329. package/src/sca/sarif-ingest.js +187 -0
  330. package/src/sca/vuln-function-hints.json +89 -0
  331. 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
+ }