security-mcp 1.1.3 → 1.3.1

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 (133) hide show
  1. package/README.md +164 -185
  2. package/defaults/checklists/ai.json +20 -1
  3. package/defaults/checklists/api.json +35 -1
  4. package/defaults/checklists/infra.json +34 -1
  5. package/defaults/checklists/mobile.json +23 -1
  6. package/defaults/checklists/payments.json +15 -1
  7. package/defaults/checklists/web.json +11 -1
  8. package/defaults/control-catalog.json +200 -0
  9. package/defaults/security-policy.json +2 -2
  10. package/dist/cli/index.js +82 -5
  11. package/dist/cli/install.js +36 -6
  12. package/dist/cli/onboarding.js +6 -0
  13. package/dist/gate/baseline.js +82 -7
  14. package/dist/gate/catalog.js +10 -2
  15. package/dist/gate/checks/ai.js +757 -39
  16. package/dist/gate/checks/auth-deep.js +935 -0
  17. package/dist/gate/checks/business-logic.js +751 -0
  18. package/dist/gate/checks/ci-pipeline.js +399 -4
  19. package/dist/gate/checks/crypto.js +423 -2
  20. package/dist/gate/checks/dependencies.js +571 -15
  21. package/dist/gate/checks/graphql.js +201 -19
  22. package/dist/gate/checks/infra.js +246 -1
  23. package/dist/gate/checks/injection-deep.js +848 -0
  24. package/dist/gate/checks/k8s.js +114 -1
  25. package/dist/gate/checks/mobile-android.js +917 -3
  26. package/dist/gate/checks/mobile-ios.js +797 -5
  27. package/dist/gate/checks/required-artifacts.js +194 -0
  28. package/dist/gate/checks/runtime.js +178 -0
  29. package/dist/gate/checks/secrets.js +244 -13
  30. package/dist/gate/checks/supply-chain-deep.js +787 -0
  31. package/dist/gate/checks/web-nextjs.js +572 -48
  32. package/dist/gate/diff.js +17 -5
  33. package/dist/gate/evidence.js +8 -1
  34. package/dist/gate/exceptions.js +131 -9
  35. package/dist/gate/policy.js +282 -129
  36. package/dist/mcp/audit-chain.js +122 -28
  37. package/dist/mcp/auth.js +169 -0
  38. package/dist/mcp/learning.js +129 -4
  39. package/dist/mcp/model-router.js +158 -21
  40. package/dist/mcp/orchestration.js +186 -51
  41. package/dist/mcp/server.js +608 -94
  42. package/dist/repo/fs.js +24 -1
  43. package/dist/repo/search.js +31 -6
  44. package/dist/review/store.js +52 -1
  45. package/package.json +7 -7
  46. package/prompts/SECURITY_PROMPT.md +73 -0
  47. package/skills/_TEMPLATE/SKILL.md +99 -0
  48. package/skills/advanced-dos-tester/SKILL.md +109 -0
  49. package/skills/agentic-loop-exploiter/SKILL.md +368 -0
  50. package/skills/ai-llm-redteam/SKILL.md +104 -0
  51. package/skills/ai-model-supply-chain-agent/SKILL.md +103 -0
  52. package/skills/algorithm-implementation-reviewer/SKILL.md +98 -0
  53. package/skills/android-penetration-tester/SKILL.md +455 -46
  54. package/skills/anti-replay-tester/SKILL.md +106 -0
  55. package/skills/appsec-code-auditor/SKILL.md +120 -0
  56. package/skills/artifact-integrity-analyst/SKILL.md +441 -0
  57. package/skills/attack-navigator/SKILL.md +467 -8
  58. package/skills/auth-session-hacker/SKILL.md +128 -0
  59. package/skills/aws-penetration-tester/SKILL.md +456 -0
  60. package/skills/azure-penetration-tester/SKILL.md +490 -3
  61. package/skills/binary-auth-validator/SKILL.md +111 -0
  62. package/skills/bot-detection-specialist/SKILL.md +109 -0
  63. package/skills/business-logic-attacker/SKILL.md +231 -0
  64. package/skills/capec-code-mapper/SKILL.md +84 -0
  65. package/skills/cert-pin-rotation-specialist/SKILL.md +112 -0
  66. package/skills/cicd-pipeline-hijacker/SKILL.md +405 -0
  67. package/skills/ciso-orchestrator/SKILL.md +454 -43
  68. package/skills/cloud-infra-specialist/SKILL.md +118 -0
  69. package/skills/compliance-gap-analyst/SKILL.md +422 -0
  70. package/skills/compliance-grc/SKILL.md +85 -0
  71. package/skills/compliance-lifecycle-tracker/SKILL.md +84 -0
  72. package/skills/credential-stuffing-specialist/SKILL.md +102 -0
  73. package/skills/crypto-pki-specialist/SKILL.md +87 -0
  74. package/skills/csa-ccm-mapper/SKILL.md +84 -0
  75. package/skills/csf2-governance-mapper/SKILL.md +84 -0
  76. package/skills/deep-link-fuzzer/SKILL.md +109 -0
  77. package/skills/dependency-confusion-attacker/SKILL.md +415 -0
  78. package/skills/device-integrity-aggregator/SKILL.md +108 -0
  79. package/skills/dos-resilience-tester/SKILL.md +97 -0
  80. package/skills/dread-scorer/SKILL.md +84 -0
  81. package/skills/egress-policy-enforcer/SKILL.md +99 -0
  82. package/skills/evidence-collector/SKILL.md +98 -0
  83. package/skills/file-upload-attacker/SKILL.md +109 -0
  84. package/skills/gcp-penetration-tester/SKILL.md +459 -2
  85. package/skills/git-history-secret-scanner/SKILL.md +106 -0
  86. package/skills/iam-privesc-graph-builder/SKILL.md +152 -0
  87. package/skills/incident-responder/SKILL.md +111 -0
  88. package/skills/injection-specialist/SKILL.md +131 -0
  89. package/skills/ios-security-auditor/SKILL.md +282 -0
  90. package/skills/json-ambiguity-tester/SKILL.md +0 -0
  91. package/skills/k8s-container-escaper/SKILL.md +384 -0
  92. package/skills/key-management-lifecycle-analyst/SKILL.md +98 -0
  93. package/skills/kill-switch-engineer/SKILL.md +102 -0
  94. package/skills/linddun-privacy-analyst/SKILL.md +102 -0
  95. package/skills/logic-race-fuzzer/SKILL.md +443 -0
  96. package/skills/mobile-api-network-attacker/SKILL.md +421 -0
  97. package/skills/mobile-binary-hardener/SKILL.md +102 -0
  98. package/skills/mobile-security-specialist/SKILL.md +85 -0
  99. package/skills/mobile-webview-auditor/SKILL.md +96 -0
  100. package/skills/model-extraction-attacker/SKILL.md +219 -0
  101. package/skills/multipart-abuse-tester/SKILL.md +84 -0
  102. package/skills/oauth-pkce-specialist/SKILL.md +104 -0
  103. package/skills/parser-exhaustion-tester/SKILL.md +142 -0
  104. package/skills/pentest-infra/SKILL.md +141 -0
  105. package/skills/pentest-social/SKILL.md +201 -0
  106. package/skills/pentest-team/SKILL.md +134 -0
  107. package/skills/pentest-web-api/SKILL.md +151 -0
  108. package/skills/privacy-flow-analyst/SKILL.md +234 -0
  109. package/skills/prompt-injection-specialist/SKILL.md +394 -0
  110. package/skills/quantum-migration-planner/SKILL.md +96 -0
  111. package/skills/rag-poisoning-specialist/SKILL.md +358 -0
  112. package/skills/registry-mirror-enforcer/SKILL.md +84 -0
  113. package/skills/rotation-validation-agent/SKILL.md +112 -0
  114. package/skills/samm-assessor/SKILL.md +85 -0
  115. package/skills/secrets-mask-bypass-tester/SKILL.md +100 -0
  116. package/skills/senior-security-engineer/SKILL.md +370 -2
  117. package/skills/serialization-memory-attacker/SKILL.md +332 -0
  118. package/skills/session-timeout-tester/SKILL.md +161 -0
  119. package/skills/slsa-level3-enforcer/SKILL.md +112 -0
  120. package/skills/slsa-provenance-enforcer/SKILL.md +102 -0
  121. package/skills/ssrf-detection-validator/SKILL.md +108 -0
  122. package/skills/step-up-auth-enforcer/SKILL.md +84 -0
  123. package/skills/stride-pasta-analyst/SKILL.md +420 -0
  124. package/skills/supply-chain-devsecops/SKILL.md +98 -0
  125. package/skills/threat-infrastructure-analyst/SKILL.md +84 -0
  126. package/skills/threat-modeler/SKILL.md +85 -0
  127. package/skills/tls-certificate-auditor/SKILL.md +573 -18
  128. package/skills/token-reuse-detector/SKILL.md +95 -0
  129. package/skills/trike-risk-modeler/SKILL.md +84 -0
  130. package/skills/unicode-homograph-tester/SKILL.md +84 -0
  131. package/skills/waf-rule-lifecycle-agent/SKILL.md +97 -0
  132. package/skills/webhook-security-tester/SKILL.md +102 -0
  133. package/skills/zero-trust-architect/SKILL.md +109 -0
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Business logic security checks — catches IDOR, mass assignment, race conditions,
3
+ * and other logic-layer vulnerabilities that injection and auth scanners miss.
4
+ * CWE references per MITRE CWE catalog; ATT&CK techniques per MITRE ATT&CK v15.
5
+ */
6
+ import { sanitizeErrorMessage } from "../result.js";
7
+ import { searchRepo } from "../../repo/search.js";
8
+ const NON_CODE_RE = /\.(?:md|json|yaml|yml|txt|rst|toml|lock)$/i;
9
+ function toEvidence(hits) {
10
+ return hits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`);
11
+ }
12
+ function toFiles(hits) {
13
+ return [...new Set(hits.slice(0, 10).map((m) => m.file))];
14
+ }
15
+ async function codeSearch(query) {
16
+ return (await searchRepo({ query, isRegex: true, maxMatches: 200 })).filter((h) => !NON_CODE_RE.test(h.file));
17
+ }
18
+ async function checkMassAssignment() {
19
+ const hits = await codeSearch(String.raw `(?:Object\.assign|spread)\s*\(\s*(?:user|account|profile|model|record|entity|document)\s*,\s*(?:req\.body|body\b)|(?:new\s+\w+\s*\(|create\s*\(|update\s*\()\s*(?:req\.body|body\b|\.\.\.\s*body\b)`);
20
+ const safeRe = /pick\s*\(|omit\s*\(|z\.|schema\.parse|validate\s*\(|allowedFields|whitelist/;
21
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
22
+ if (!unsafe.length)
23
+ return null;
24
+ return {
25
+ id: "MASS_ASSIGNMENT",
26
+ title: "Mass assignment — req.body spread directly into database model without field allowlist (CWE-915)",
27
+ severity: "HIGH",
28
+ evidence: toEvidence(unsafe),
29
+ files: toFiles(unsafe),
30
+ requiredActions: [
31
+ "Explicitly pick allowed fields from req.body before passing to the model.",
32
+ "CWE-915 — mass assignment allows attackers to set internal fields like isAdmin, role, or balance by including them in the request body.",
33
+ "Fix: const { name, email } = req.body; await User.update({ name, email }, { where: { id: userId } });"
34
+ ]
35
+ };
36
+ }
37
+ async function checkIdorDirect() {
38
+ const findings = [];
39
+ // Original single-line IDOR check
40
+ const hits = await codeSearch(String.raw `(?:findById|findOne|findByPk|findUnique|getById|where\s*:\s*\{\s*id)\s*\(\s*(?:req\.|params\.|query\.|body\.)(?:id|userId|accountId|recordId|documentId|resourceId)\b`);
41
+ // req\.user\?? covers both req.user.id and req.user?.id (optional chaining ownership pattern)
42
+ const safeRe = /userId\s*===|\.userId\s*==|currentUser|req\.user\??\.id|session\.userId|ownership|authorized|canAccess|hasPermission/;
43
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
44
+ if (unsafe.length > 0) {
45
+ findings.push({
46
+ id: "IDOR_DIRECT_ACCESS",
47
+ title: "Direct object lookup from user-supplied ID without ownership check — IDOR (CWE-639 / OWASP API1)",
48
+ severity: "HIGH",
49
+ evidence: toEvidence(unsafe),
50
+ files: toFiles(unsafe),
51
+ requiredActions: [
52
+ "Always verify that the authenticated user owns or has permission to access the requested resource.",
53
+ "CWE-639 — IDOR allows any authenticated user to access any other user's data by changing the ID in the request.",
54
+ "Fix: const record = await Model.findById(req.params.id); if (record.userId !== req.user.id) return res.status(403).end();"
55
+ ]
56
+ });
57
+ }
58
+ // Two-pass multi-line IDOR: find user-supplied ID, then check if DB lookup uses it without ownership
59
+ const idSourceHits = await codeSearch(String.raw `(?:req\.params\.\w+|req\.query\.\w+|args\.\w+)`);
60
+ const dbLookupHits = await codeSearch(String.raw `(?:findById|findOne|findByPk|findUnique|findFirst|getById)\s*\(`);
61
+ const idsByFile = new Map();
62
+ for (const h of idSourceHits) {
63
+ const varMatch = /(?:const|let|var)\s+(\w+)\s*=/.exec(h.preview);
64
+ const varName = varMatch?.[1] ?? "";
65
+ if (!idsByFile.has(h.file))
66
+ idsByFile.set(h.file, []);
67
+ idsByFile.get(h.file).push({ line: h.line, varName, preview: h.preview });
68
+ }
69
+ const toctouIdorHits = [];
70
+ for (const db of dbLookupHits) {
71
+ if (safeRe.test(db.preview))
72
+ continue;
73
+ const idSources = idsByFile.get(db.file) ?? [];
74
+ for (const src of idSources) {
75
+ const lineDiff = db.line - src.line;
76
+ if (lineDiff >= 0 && lineDiff <= 15 && src.varName && db.preview.includes(src.varName)) {
77
+ toctouIdorHits.push({ file: db.file, line: db.line, preview: `id@${src.line}→lookup@${db.line}: ${db.preview.trim()}` });
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ // GraphQL resolver IDOR: resolve functions using args.id without context.user.id check
83
+ const resolverHits = await codeSearch(String.raw `resolve\s*:\s*(?:async\s)?\([^)]*\)\s*=>\s*\{`);
84
+ const resolverIdorHits = resolverHits.filter((h) => {
85
+ return /args\.\w+/.test(h.preview) && !safeRe.test(h.preview) && !/context\.user\.id/.test(h.preview);
86
+ });
87
+ // Prisma findFirst without userId in where clause
88
+ const prismaHits = await codeSearch(String.raw `\.findFirst\s*\(\s*\{[^}]*where\s*:\s*\{[^}]*id\s*:`);
89
+ const prismaIdorHits = prismaHits.filter((h) => !/userId\s*:/.test(h.preview));
90
+ const multiLineIdorHits = [...toctouIdorHits, ...resolverIdorHits, ...prismaIdorHits];
91
+ if (multiLineIdorHits.length > 0) {
92
+ findings.push({
93
+ id: "IDOR_MULTI_LINE",
94
+ title: "User-supplied ID reaches DB lookup without ownership check across multiple lines — IDOR (CWE-639)",
95
+ severity: "HIGH",
96
+ evidence: multiLineIdorHits.slice(0, 10).map((h) => `${h.file}:${h.line}:${h.preview}`),
97
+ files: [...new Set(multiLineIdorHits.slice(0, 10).map((h) => h.file))],
98
+ requiredActions: [
99
+ "Verify ownership before returning any record fetched by a user-supplied ID.",
100
+ "CWE-639 — multi-line IDOR occurs when the ID is extracted early, then used in a DB call several lines later without an intermediate ownership check.",
101
+ "Fix: add userId to the where clause (Prisma: { where: { id: args.id, userId: ctx.user.id } }) or check after fetch."
102
+ ]
103
+ });
104
+ }
105
+ return findings;
106
+ }
107
+ async function checkNegativeAmountBypass() {
108
+ const hits = await codeSearch(String.raw `(?:amount|price|quantity|balance|credit|debit|total|cost)\s*[+\-]=\s*(?:req\.|body\.|params\.|query\.)\w+|(?:req\.|body\.|params\.)(?:amount|price|quantity|total)\b[^;]*(?:balance|transfer|charge|debit|credit)`);
109
+ const safeReA = />\s*0|>=\s*0\b|Math\.abs|isPositive|validate/;
110
+ const safeReB = /minimum\s*:\s*0|positive\(\)|min\s*\(\s*0/;
111
+ const unsafe = hits.filter((h) => !safeReA.test(h.preview) && !safeReB.test(h.preview));
112
+ if (!unsafe.length)
113
+ return null;
114
+ return {
115
+ id: "NEGATIVE_AMOUNT_BYPASS",
116
+ title: "Financial amount from user input not validated as positive — business logic bypass (CWE-20)",
117
+ severity: "HIGH",
118
+ evidence: toEvidence(unsafe),
119
+ files: toFiles(unsafe),
120
+ requiredActions: [
121
+ "Validate that financial amounts are strictly positive before processing any transaction.",
122
+ "CWE-20 — negative amounts can credit accounts, refund without a purchase, or subtract from balances in reverse.",
123
+ "Fix: const amount = z.number().positive().parse(req.body.amount); // reject <= 0"
124
+ ]
125
+ };
126
+ }
127
+ async function checkRaceConditionBalance() {
128
+ const findings = [];
129
+ const safeRe = /transaction|atomic|\$inc|increment.*atomic|select.*for\s+update|WITH\s+LOCK|optimisticLock|version/i;
130
+ // Single-line check (original)
131
+ const singleLineHits = await codeSearch(String.raw `(?:findOne|findById|findUnique)[^;]*(?:balance|quota|inventory|stock|seats|credits)[^;]*(?:update|save|increment|decrement)`);
132
+ const unsafeSingle = singleLineHits.filter((h) => !safeRe.test(h.preview));
133
+ if (unsafeSingle.length > 0) {
134
+ findings.push({
135
+ id: "RACE_CONDITION_BALANCE",
136
+ title: "Read-then-write on balance/quota without atomic operation — TOCTOU race condition (CWE-362)",
137
+ severity: "HIGH",
138
+ evidence: toEvidence(unsafeSingle),
139
+ files: toFiles(unsafeSingle),
140
+ requiredActions: [
141
+ "Use atomic database operations (SQL FOR UPDATE, MongoDB $inc, Prisma transactions) to prevent race conditions.",
142
+ "CWE-362 — concurrent requests reading the same balance and both decrementing can overdraft accounts or oversell inventory.",
143
+ "Fix: await db.$transaction([db.account.update({ where: { id }, data: { balance: { decrement: amount } } })]);"
144
+ ]
145
+ });
146
+ }
147
+ // Two-pass multi-line TOCTOU: find read operations, capture variable, find writes near them
148
+ const readHits = await codeSearch(String.raw `(?:findOne|findById|findUnique|getBalance|getAccount|fs\.access|fs\.stat|fs\.exists)\s*\(`);
149
+ const writeHits = await codeSearch(String.raw `(?:\.update\s*\(|\.save\s*\(|\.increment\s*\(|\.decrement\s*\(|fs\.unlink\s*\(|fs\.write\s*\(|fs\.rename\s*\()`);
150
+ const readByFile = new Map();
151
+ for (const rh of readHits) {
152
+ if (safeRe.test(rh.preview))
153
+ continue;
154
+ const varMatch = /(?:const|let|var)\s+(\w+)\s*=/.exec(rh.preview);
155
+ const varName = varMatch?.[1] ?? "";
156
+ if (!readByFile.has(rh.file))
157
+ readByFile.set(rh.file, []);
158
+ readByFile.get(rh.file).push({ line: rh.line, varName, preview: rh.preview });
159
+ }
160
+ const toctouHits = [];
161
+ for (const wh of writeHits) {
162
+ if (safeRe.test(wh.preview))
163
+ continue;
164
+ const reads = readByFile.get(wh.file) ?? [];
165
+ for (const r of reads) {
166
+ const lineDiff = wh.line - r.line;
167
+ if (lineDiff > 0 && lineDiff <= 15 && r.varName && wh.preview.includes(r.varName)) {
168
+ toctouHits.push({ file: wh.file, line: wh.line, preview: `read@${r.line}→write@${wh.line}: ${wh.preview.trim()}` });
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ if (toctouHits.length > 0) {
174
+ findings.push({
175
+ id: "RACE_CONDITION_TOCTOU",
176
+ title: "Multi-line read-then-write without SELECT FOR UPDATE, transaction, or mutex — TOCTOU race condition (CWE-362)",
177
+ severity: "HIGH",
178
+ evidence: toctouHits.slice(0, 10).map((h) => `${h.file}:${h.line}:${h.preview}`),
179
+ files: [...new Set(toctouHits.slice(0, 10).map((h) => h.file))],
180
+ requiredActions: [
181
+ "Wrap read-then-write sequences in a database transaction with SELECT FOR UPDATE to prevent concurrent modification.",
182
+ "CWE-362 — TOCTOU allows two concurrent requests to read the same state and both apply writes based on stale data.",
183
+ "Fix: await db.$transaction(async (tx) => { const r = await tx.account.findUnique(...); await tx.account.update(...); });"
184
+ ]
185
+ });
186
+ }
187
+ // File system TOCTOU: fs.access/stat followed by fs.unlink/open/write within 10 lines without locking
188
+ const fsReadHits = await codeSearch(String.raw `fs\.(?:access|stat|exists)\s*\(`);
189
+ const fsWriteHits = await codeSearch(String.raw `fs\.(?:unlink|open|rename|writeFile|writeFileSync)\s*\(`);
190
+ const fsReadByFile = new Map();
191
+ for (const rh of fsReadHits) {
192
+ if (!fsReadByFile.has(rh.file))
193
+ fsReadByFile.set(rh.file, []);
194
+ fsReadByFile.get(rh.file).push({ line: rh.line, preview: rh.preview });
195
+ }
196
+ const fsLockRe = /flock|lockFile|lock\s*\(|exclusive/i;
197
+ const fsToctouHits = [];
198
+ for (const wh of fsWriteHits) {
199
+ if (fsLockRe.test(wh.preview))
200
+ continue;
201
+ const reads = fsReadByFile.get(wh.file) ?? [];
202
+ for (const r of reads) {
203
+ const lineDiff = wh.line - r.line;
204
+ if (lineDiff > 0 && lineDiff <= 10) {
205
+ fsToctouHits.push({ file: wh.file, line: wh.line, preview: `fs.check@${r.line}→fs.write@${wh.line}: ${wh.preview.trim()}` });
206
+ break;
207
+ }
208
+ }
209
+ }
210
+ if (fsToctouHits.length > 0) {
211
+ findings.push({
212
+ id: "FILESYSTEM_TOCTOU",
213
+ title: "fs.access/fs.stat followed by fs.write/fs.unlink without file locking — filesystem TOCTOU (CWE-362)",
214
+ severity: "HIGH",
215
+ evidence: fsToctouHits.slice(0, 10).map((h) => `${h.file}:${h.line}:${h.preview}`),
216
+ files: [...new Set(fsToctouHits.slice(0, 10).map((h) => h.file))],
217
+ requiredActions: [
218
+ "Use atomic file operations (open with O_EXCL flag, or a file-locking library) instead of check-then-act patterns.",
219
+ "CWE-362 — between fs.access() and fs.unlink/fs.write, another process can modify or replace the file.",
220
+ "Fix: use fs.open(path, 'wx') which atomically creates-or-fails, or proper advisory locking via proper-lockfile."
221
+ ]
222
+ });
223
+ }
224
+ return findings;
225
+ }
226
+ async function checkHardcodedCredentials() {
227
+ const hits = await codeSearch(String.raw `(?:password|passwd|secret|apiKey|api_key|token|credential|auth)\s*[:=]\s*['"][^'"]{8,}['"](?!\s*\+|\s*process\.env)`);
228
+ const safeRe = /process\.env|config\.|getSecret|secretsManager|vault|PLACEHOLDER|CHANGE_ME|YOUR_SECRET|example|test/i;
229
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
230
+ if (!unsafe.length)
231
+ return null;
232
+ return {
233
+ id: "HARDCODED_CREDENTIALS",
234
+ title: "Hardcoded credential literal in source code — secret exposed in git history (CWE-798)",
235
+ severity: "CRITICAL",
236
+ evidence: toEvidence(unsafe),
237
+ files: toFiles(unsafe),
238
+ requiredActions: [
239
+ "Move all secrets to environment variables or a secrets manager. Rotate any exposed credentials immediately.",
240
+ "CWE-798 / ATT&CK T1552.001 — hardcoded credentials are extractable from git history even after removal.",
241
+ "Fix: const secret = process.env.MY_SECRET; // never: const secret = 'hardcoded-value';"
242
+ ]
243
+ };
244
+ }
245
+ async function checkMissingInputValidation() {
246
+ const hits = await codeSearch(String.raw `(?:router|app)\.(?:post|put|patch)\s*\([^,]+,\s*\([^)]*req[^)]*\)\s*=>\s*\{[^}]*(?:req\.body|body\.)\w+[^}]*(?:await|db\.|model\.|\.create|\.update|\.save)`);
247
+ const safeRe = /parse\s*\(|validate\s*\(|schema\.|Joi\.|Zod|yup\.|valibot|ajv|body\s*\(|check\s*\(/;
248
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
249
+ if (!unsafe.length)
250
+ return null;
251
+ return {
252
+ id: "MISSING_INPUT_VALIDATION",
253
+ title: "POST/PUT/PATCH handler writes req.body to database without schema validation (CWE-20)",
254
+ severity: "HIGH",
255
+ evidence: toEvidence(unsafe),
256
+ files: toFiles(unsafe),
257
+ requiredActions: [
258
+ "Validate all request bodies with a schema library (Zod, Joi, Valibot) before writing to the database.",
259
+ "CWE-20 — missing validation allows type confusion, unexpected field types, and business logic bypasses.",
260
+ "Fix: const data = CreateUserSchema.parse(req.body); await db.user.create({ data });"
261
+ ]
262
+ };
263
+ }
264
+ async function checkInsecureDirectUrlAccess() {
265
+ const hits = await codeSearch(String.raw `(?:router|app)\.(?:get|post|put|patch|delete)\s*\(\s*['"][^'"]*(?:\/export|\/download|\/report|\/backup|\/dump|\/list-all|\/all-users|\/admin-data)['"]\s*,(?![^)]*(?:requireAuth|isAuthenticated|passport|authenticate|verifyToken|checkAuth|session\.user))`);
266
+ if (!hits.length)
267
+ return null;
268
+ return {
269
+ id: "UNRESTRICTED_SENSITIVE_ENDPOINT",
270
+ title: "Sensitive data endpoint (export/download/backup) registered without authentication middleware (CWE-284)",
271
+ severity: "CRITICAL",
272
+ evidence: toEvidence(hits),
273
+ files: toFiles(hits),
274
+ requiredActions: [
275
+ "Apply authentication and authorization middleware to all data export, download, and backup endpoints.",
276
+ "CWE-284 — unauthenticated export endpoints allow any internet user to download entire user databases.",
277
+ "Fix: router.get('/export', requireAuth, requireRole('admin'), exportHandler);"
278
+ ]
279
+ };
280
+ }
281
+ async function checkIntegerOverflow() {
282
+ const hits = await codeSearch(String.raw `(?:parseInt|parseFloat|Number\s*\()\s*\(\s*(?:req\.|body\.|params\.|query\.)\w+\s*\)[^;]*(?:\*|\+\+|\+=|\*=|Math\.pow|<<)`);
283
+ const safeRe = /isNaN|isFinite|Number\.isInteger|Number\.isSafeInteger|MAX_SAFE_INTEGER|clamp|Math\.min.*Math\.max/;
284
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
285
+ if (!unsafe.length)
286
+ return null;
287
+ return {
288
+ id: "INTEGER_OVERFLOW_RISK",
289
+ title: "Parsed integer from user input used in arithmetic without bounds check — overflow risk (CWE-190)",
290
+ severity: "MEDIUM",
291
+ evidence: toEvidence(unsafe),
292
+ files: toFiles(unsafe),
293
+ requiredActions: [
294
+ "Validate that parsed integers are within safe bounds before using them in arithmetic.",
295
+ "CWE-190 — extremely large values can cause incorrect calculations, memory allocation failures, or logic bypasses.",
296
+ "Fix: const qty = z.number().int().min(1).max(10000).parse(Number(req.body.quantity));"
297
+ ]
298
+ };
299
+ }
300
+ async function checkMissingAdminAuth() {
301
+ // Matches route definitions whose path contains /admin, /internal, /debug, or /_/
302
+ // and whose immediate handler argument does not include an auth middleware reference.
303
+ const hits = await codeSearch(String.raw `(?:router|app)\.(?:get|post|put|patch|delete|use)\s*\(\s*['"][^'"]*(?:\/admin|\/internal|\/debug|\/_\/)[^'"]*['"]`);
304
+ const authRe = /requireAuth|isAuthenticated|authenticate|verifyToken|checkAuth|passport\.authenticate|session\.user|authMiddleware|ensureAuth|protect|authGuard|bearerAuth|jwtVerify/;
305
+ const unsafe = hits.filter((h) => !authRe.test(h.preview));
306
+ if (!unsafe.length)
307
+ return null;
308
+ return {
309
+ id: "MISSING_ADMIN_ROUTE_AUTH",
310
+ title: "Admin/internal/debug route registered without authentication middleware — missing authorization (CWE-862)",
311
+ severity: "CRITICAL",
312
+ evidence: toEvidence(unsafe),
313
+ files: toFiles(unsafe),
314
+ requiredActions: [
315
+ "Apply authentication and role-enforcement middleware to every /admin, /internal, /debug, and /_/ route.",
316
+ "CWE-862 / ATT&CK T1078 — unauthenticated admin endpoints expose privileged operations to any internet user.",
317
+ "Fix: router.use('/admin', requireAuth, requireRole('admin')); // applied before any sub-routes"
318
+ ]
319
+ };
320
+ }
321
+ async function checkTimingOracle() {
322
+ // Detects equality comparisons (=== or ==) applied directly to OTP, PIN, token,
323
+ // or API-key values without a constant-time comparison helper.
324
+ const hits = await codeSearch(String.raw `(?:otp|pin|token|apiKey|api_key|secret|code|passcode|verificationCode|resetToken|authCode)\s*(?:===|==)\s*(?:req\.|body\.|params\.|query\.|user\.|stored|expected|db\.)|(?:req\.|body\.|params\.)(?:otp|pin|token|code|passcode)\s*(?:===|==)`);
325
+ const safeRe = /timingSafeEqual|crypto\.timingSafeEqual|constantTimeCompare|safeCompare|timingAttack|tsscmp|slow-equal/;
326
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
327
+ if (!unsafe.length)
328
+ return null;
329
+ return {
330
+ id: "TIMING_ORACLE_COMPARISON",
331
+ title: "Security code (OTP/PIN/API key) compared with === — timing oracle leaks secret length/value (CWE-208)",
332
+ severity: "HIGH",
333
+ evidence: toEvidence(unsafe),
334
+ files: toFiles(unsafe),
335
+ requiredActions: [
336
+ "Use crypto.timingSafeEqual() for all equality checks involving OTPs, PINs, API keys, and session tokens.",
337
+ "CWE-208 / ATT&CK T1110 — string equality short-circuits on the first mismatch; timing differences allow offline brute-force recovery.",
338
+ "Fix: const a = Buffer.from(storedToken); const b = Buffer.from(userToken); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error('Invalid token');"
339
+ ]
340
+ };
341
+ }
342
+ async function checkHardcodedDbUrlPassword() {
343
+ // Catches database connection URLs with embedded credentials and API key literals inside config objects.
344
+ const hitsA = await codeSearch(String.raw `(?:mongodb|mongodb\+srv|postgresql|postgres|mysql|redis|amqp|jdbc):\/\/[^:'"]+:[^@'"]{4,}@`);
345
+ const hitsB = await codeSearch(String.raw `(?:DATABASE_URL|MONGO_URI|DB_PASSWORD|REDIS_URL|RABBITMQ_URL)\s*[:=]\s*['"][^'"]{8,}['"](?!\s*\+|\s*process\.env)`);
346
+ const hitsC = await codeSearch(String.raw `(?:api_key|access_key|secret_key|client_secret|apiKey)\s*[:=]\s*['"][A-Za-z0-9\-_]{16,}['"]`);
347
+ const hits = [...hitsA, ...hitsB, ...hitsC];
348
+ const safeRe = /process\.env|config\.|getSecret|secretsManager|vault|PLACEHOLDER|CHANGE_ME|example|test|localhost|127\.0\.0\.1/i;
349
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
350
+ if (!unsafe.length)
351
+ return null;
352
+ return {
353
+ id: "HARDCODED_DB_URL_OR_API_KEY",
354
+ title: "Database URL with embedded password or API key literal in config object — credential exposure (CWE-798)",
355
+ severity: "CRITICAL",
356
+ evidence: toEvidence(unsafe),
357
+ files: toFiles(unsafe),
358
+ requiredActions: [
359
+ "Remove all database URLs with embedded passwords and API key literals from source code. Rotate any exposed credentials immediately.",
360
+ "CWE-798 / ATT&CK T1552.001 — credentials in config files are captured in git history even after deletion and are frequently scraped by automated tools.",
361
+ "Fix: const dbUrl = process.env.DATABASE_URL; // store in .env, inject via secrets manager at runtime"
362
+ ]
363
+ };
364
+ }
365
+ async function checkFloatMonetaryArithmetic() {
366
+ const hitsA = await codeSearch(String.raw `(?:price|amount|total|balance|cost|fee|charge)\s*\*\s*(?:\d+\.\d+|[^;]*(?:rate|percent|factor))`);
367
+ const hitsB = await codeSearch(String.raw `parseFloat\s*\([^)]*(?:price|amount|total|balance|cost|fee)`);
368
+ // Unary + coercion: +req.body.price, +req.query.amount — same float risk as parseFloat()
369
+ const hitsD = await codeSearch(String.raw `(?<![+\w])\+\s*req\.(?:body|query|params)\.\w*(?:price|amount|total|balance|cost|fee)\w*`);
370
+ const hitsC = await codeSearch(String.raw `\.toFixed\s*\(\s*[02]\s*\)`);
371
+ const hits = [...hitsA, ...hitsB, ...hitsC, ...hitsD];
372
+ const safeRe = /BigInt|bigint|Decimal|decimal\.js|dinero|bignumber|integer.*cent|cent.*integer|\*\s*100|Math\.round.*100/i;
373
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
374
+ if (!unsafe.length)
375
+ return null;
376
+ return {
377
+ id: "MONETARY_FLOAT_ARITHMETIC",
378
+ title: "Floating-point arithmetic on monetary values — rounding errors in financial calculations (CWE-681)",
379
+ severity: "HIGH",
380
+ evidence: toEvidence(unsafe),
381
+ files: toFiles(unsafe),
382
+ requiredActions: [
383
+ "Floating-point arithmetic on monetary values can cause rounding errors. Use integer-cent representation (multiply by 100, work in integers, divide at display time) or a decimal library.",
384
+ "CWE-681 — float imprecision can cause under- or over-charging by fractional amounts that accumulate at scale.",
385
+ "Fix: const amountCents = Math.round(price * 100); // work in integers, or use: import Decimal from 'decimal.js'; new Decimal(price).times(rate)"
386
+ ]
387
+ };
388
+ }
389
+ async function checkHttpParamPollution() {
390
+ const hitsA = await codeSearch(String.raw `(?:parseInt|parseFloat|Number)\s*\(\s*req\.(?:query|body)\.\w+`);
391
+ const hitsB = await codeSearch(String.raw `if\s*\(\s*req\.(?:query|body)\.\w+\s*(?:===|!==|>|<|>=|<=)`);
392
+ const hits = [...hitsA, ...hitsB];
393
+ const safeRe = /Array\.isArray|isArray\s*\(|typeof.*string|schema\.parse|validate\s*\(/;
394
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
395
+ if (!unsafe.length)
396
+ return null;
397
+ return {
398
+ id: "HTTP_PARAM_POLLUTION_RISK",
399
+ title: "Request parameter used in arithmetic/comparison without Array.isArray guard — HTTP parameter pollution (CWE-20)",
400
+ severity: "MEDIUM",
401
+ evidence: toEvidence(unsafe),
402
+ files: toFiles(unsafe),
403
+ requiredActions: [
404
+ "Request parameters may be arrays when sent as duplicate query params (e.g., ?amount=10&amount=-500). Validate scalar vs array type before use in business logic.",
405
+ "CWE-20 — HTTP parameter pollution allows sending duplicate params that become arrays, bypassing single-value validations.",
406
+ "Fix: const raw = req.query.amount; if (Array.isArray(raw)) return res.status(400).end(); const amount = Number(raw);"
407
+ ]
408
+ };
409
+ }
410
+ async function checkVoucherReplay() {
411
+ const hits = await codeSearch(String.raw `(?:coupon|voucher|promo|gift.*card|redeem|discount.*code)`);
412
+ // Require the idempotency signal to be a READ/CHECK, not just an assignment.
413
+ // "voucher.usedAt = new Date()" set in-memory before persistence does NOT prevent replay —
414
+ // two concurrent requests both read usedAt=null, both pass the check, then both write.
415
+ // Safe patterns: conditional checks (if/throw/return on usedAt), DB-level unique constraints,
416
+ // or idempotency key lookups. Pure assignment lines are excluded via negative lookahead.
417
+ const idempotencyRe = /(?:if|throw|return|where|find|unique|create).*(?:usedAt|redeemed|usageCount|redemptionCount|idempotencyKey)|(?:usedAt|redeemed).*(?:===|!==|==|!=|throw|return)|unique.*code|code.*unique/i;
418
+ const unsafe = hits.filter((h) => !idempotencyRe.test(h.preview));
419
+ if (!unsafe.length)
420
+ return null;
421
+ return {
422
+ id: "VOUCHER_REPLAY_RISK",
423
+ title: "Voucher/coupon redemption without idempotency check — replay attack enables unlimited reuse (CWE-384)",
424
+ severity: "HIGH",
425
+ evidence: toEvidence(unsafe),
426
+ files: toFiles(unsafe),
427
+ requiredActions: [
428
+ "Voucher/coupon redemption without idempotency check enables replay. Track redemptions with a unique constraint on code+userId.",
429
+ "CWE-384 — a replayable redemption endpoint allows a single coupon to be used unlimited times.",
430
+ "Fix: await db.redemption.create({ data: { code, userId } }); // with UNIQUE(code, userId) constraint to reject duplicates"
431
+ ]
432
+ };
433
+ }
434
+ async function checkStateMachineBypass() {
435
+ const hits = await codeSearch(String.raw `(?:\/checkout\/confirm|\/checkout\/payment|\/verify\/complete|\/onboarding\/step\d|\/wizard\/step)`);
436
+ const prerequisiteRe = /req\.session\.\w+|req\.user\.\w+|await.*[Ss]tep.*[Cc]omplete|await.*verified|session\[|user\.step|completed/;
437
+ const unsafe = hits.filter((h) => !prerequisiteRe.test(h.preview));
438
+ if (!unsafe.length)
439
+ return null;
440
+ return {
441
+ id: "STATE_MACHINE_BYPASS_RISK",
442
+ title: "Multi-step flow endpoint does not verify prior-step completion — state machine bypass (CWE-841)",
443
+ severity: "MEDIUM",
444
+ evidence: toEvidence(unsafe),
445
+ files: toFiles(unsafe),
446
+ requiredActions: [
447
+ "Each step in a multi-step flow (checkout, onboarding, wizard) must verify that preceding steps were completed.",
448
+ "CWE-841 — skipping steps can bypass payment, identity verification, or terms acceptance.",
449
+ "Fix: if (!req.session.step1Complete) return res.status(400).json({ error: 'Complete step 1 first' });"
450
+ ]
451
+ };
452
+ }
453
+ async function checkCurrencyConfusion() {
454
+ const hits = await codeSearch(String.raw `(?:currency|currencyCode|currency_code)\s*[:=]\s*(?:req\.|body\.|params\.|query\.)`);
455
+ const safeRe = /allowedCurrencies|CURRENCY_ALLOWLIST|===.*'USD'/;
456
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
457
+ if (!unsafe.length)
458
+ return null;
459
+ return {
460
+ id: "BIZ_CURRENCY_CONFUSION",
461
+ title: "Payment currency sourced from client request — currency confusion enables 100 JPY instead of 100 USD payment (CWE-20)",
462
+ severity: "CRITICAL",
463
+ evidence: toEvidence(unsafe),
464
+ files: toFiles(unsafe),
465
+ requiredActions: [
466
+ "Never accept currency codes from client requests. Hard-code or allowlist acceptable currencies server-side.",
467
+ "CWE-20 — currency confusion allows an attacker to specify a low-value currency (JPY, CLP) to pay a fraction of the intended amount.",
468
+ "Fix: const currency = CURRENCY_ALLOWLIST.includes(req.body.currency) ? req.body.currency : 'USD';"
469
+ ]
470
+ };
471
+ }
472
+ async function checkDiscountStacking() {
473
+ const hits = await codeSearch(String.raw `(?:discount|coupon|promo)(?:s|List|Stack|Array|\[)`);
474
+ const safeRe = /maxDiscounts|MAX_COUPONS|singleDiscount|onlyOne/;
475
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
476
+ if (!unsafe.length)
477
+ return null;
478
+ return {
479
+ id: "BIZ_DISCOUNT_STACKING",
480
+ title: "Discount/coupon list without stacking limit — attacker applies N codes to reduce price to zero (CWE-20)",
481
+ severity: "HIGH",
482
+ evidence: toEvidence(unsafe),
483
+ files: toFiles(unsafe),
484
+ requiredActions: [
485
+ "Enforce a maximum number of stackable discounts/coupons per order server-side.",
486
+ "CWE-20 — unlimited coupon stacking allows an attacker to chain enough codes to reduce any order total to zero.",
487
+ "Fix: if (coupons.length > MAX_COUPONS) throw new Error('Too many coupons applied');"
488
+ ]
489
+ };
490
+ }
491
+ async function checkOrderFulfillmentBypass() {
492
+ const hits = await codeSearch(String.raw `(?:status|paymentStatus|orderStatus|fulfillmentStatus)\s*[:=]\s*(?:req\.|body\.|params\.|query\.)`);
493
+ const safeRe = /processor|stripe|braintree|paypal|PAYMENT_PROCESSOR/;
494
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
495
+ if (!unsafe.length)
496
+ return null;
497
+ return {
498
+ id: "BIZ_ORDER_FULFILLMENT_BYPASS",
499
+ title: "Order status sourced from client — attacker sets status=paid to bypass payment processor confirmation (CWE-602)",
500
+ severity: "CRITICAL",
501
+ evidence: toEvidence(unsafe),
502
+ files: toFiles(unsafe),
503
+ requiredActions: [
504
+ "Order and payment status must be set exclusively by your payment processor webhook or server-side logic, never from client input.",
505
+ "CWE-602 — accepting status from the client allows any user to set their order to 'paid' without completing payment.",
506
+ "Fix: const status = await stripe.paymentIntents.retrieve(paymentIntentId); // derive status from processor, not client"
507
+ ]
508
+ };
509
+ }
510
+ async function checkWebhookTimestamp() {
511
+ const hits = await codeSearch(String.raw `(?:stripe|webhook|payment).*(?:Signature|signature|sig)\s*[:=]`);
512
+ const safeRe = /tolerance|timestamp|maxAge|t=|Date\.now|\d+\s*\*\s*1000/;
513
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
514
+ if (!unsafe.length)
515
+ return null;
516
+ return {
517
+ id: "BIZ_WEBHOOK_NO_TIMESTAMP",
518
+ title: "Webhook signature verified but timestamp tolerance not enforced — unlimited replay window (CWE-294)",
519
+ severity: "HIGH",
520
+ evidence: toEvidence(unsafe),
521
+ files: toFiles(unsafe),
522
+ requiredActions: [
523
+ "Enforce a timestamp tolerance (e.g., 5 minutes) when verifying webhook signatures to prevent replay attacks.",
524
+ "CWE-294 — without a replay window check, a captured webhook payload can be replayed indefinitely to re-trigger payment events.",
525
+ "Fix: stripe.webhooks.constructEvent(body, sig, secret, 300); // 300s = 5 minute tolerance"
526
+ ]
527
+ };
528
+ }
529
+ async function checkTaxShippingParamTamper() {
530
+ const hits = await codeSearch(String.raw `(?:taxAmount|tax_amount|shippingCost|shipping_cost|shippingFee)\s*[:=]\s*(?:req\.|body\.|params\.|query\.)`);
531
+ if (!hits.length)
532
+ return null;
533
+ return {
534
+ id: "BIZ_TAX_SHIPPING_TAMPER",
535
+ title: "Tax or shipping amount sourced from client — tamper to zero bypasses fees server-side (CWE-602)",
536
+ severity: "HIGH",
537
+ evidence: toEvidence(hits),
538
+ files: toFiles(hits),
539
+ requiredActions: [
540
+ "Calculate tax and shipping amounts server-side using cart contents and customer location. Never trust client-supplied fee values.",
541
+ "CWE-602 — accepting tax/shipping from the client allows any user to set these to zero, bypassing all fees.",
542
+ "Fix: const tax = calculateTax(cart, shippingAddress); // server-computed, not req.body.taxAmount"
543
+ ]
544
+ };
545
+ }
546
+ async function checkClientTotalAmount() {
547
+ const hits = await codeSearch(String.raw `(?:charge|createPaymentIntent|capturePayment|processPayment)\s*\([^)]*(?:req\.|body\.|params\.)(?:total|amount|chargeAmount|finalAmount)`);
548
+ if (!hits.length)
549
+ return null;
550
+ return {
551
+ id: "BIZ_CLIENT_SUPPLIED_TOTAL",
552
+ title: "Final charge amount sourced from client request — attacker sets amount=1 to pay $0.01 for any cart (CWE-602)",
553
+ severity: "CRITICAL",
554
+ evidence: toEvidence(hits),
555
+ files: toFiles(hits),
556
+ requiredActions: [
557
+ "Always compute the final charge amount server-side from authoritative cart/order data. Never use a client-supplied total for payment.",
558
+ "CWE-602 — passing a client-supplied amount directly to your payment processor allows purchasing any item for any price.",
559
+ "Fix: const amount = await computeCartTotal(userId); await stripe.paymentIntents.create({ amount, currency: 'usd' });"
560
+ ]
561
+ };
562
+ }
563
+ async function checkReferralAbuse() {
564
+ const hits = await codeSearch(String.raw `(?:referral|referrer|referralBonus|inviteCode|referral_code)\s*[:=]\s*(?:req\.|body\.|params\.|query\.)`);
565
+ const safeRe = /deduplication|uniqueIP|deviceFingerprint|normalizeEmail/;
566
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
567
+ if (!unsafe.length)
568
+ return null;
569
+ return {
570
+ id: "BIZ_REFERRAL_ABUSE",
571
+ title: "Referral/signup bonus without multi-account deduplication — self-referral farming possible (CWE-20)",
572
+ severity: "HIGH",
573
+ evidence: toEvidence(unsafe),
574
+ files: toFiles(unsafe),
575
+ requiredActions: [
576
+ "Implement multi-account deduplication for referral bonuses using email normalization, IP velocity, and/or device fingerprinting.",
577
+ "CWE-20 — without deduplication, a single user can create unlimited accounts and self-refer to farm referral bonuses indefinitely.",
578
+ "Fix: const canonical = normalizeEmail(email); if (await db.user.findUnique({ where: { canonicalEmail: canonical } })) throw new Error('Duplicate account');"
579
+ ]
580
+ };
581
+ }
582
+ async function checkEmailNormalization() {
583
+ const hits = await codeSearch(String.raw `(?:email|emailAddress)\s*(?:===|==|LIKE)\s*(?:req\.|body\.|params\.|query\.)`);
584
+ const safeRe = /toLowerCase|normalize|replace.*@|canonicalize/;
585
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
586
+ if (!unsafe.length)
587
+ return null;
588
+ return {
589
+ id: "BIZ_EMAIL_NORMALIZATION",
590
+ title: "Email uniqueness compared without normalization — u.s.e.r@gmail.com creates duplicate account (CWE-20)",
591
+ severity: "HIGH",
592
+ evidence: toEvidence(unsafe),
593
+ files: toFiles(unsafe),
594
+ requiredActions: [
595
+ "Normalize email addresses (lowercase, strip dots from Gmail local-part, handle + aliases) before uniqueness checks.",
596
+ "CWE-20 — unnormalized email comparison allows creating duplicate accounts with minor variations of the same address.",
597
+ String.raw `Fix: const canonical = email.toLowerCase().replace(/\.(?=[^@]*@)/g, ''); // then check uniqueness on canonical form`
598
+ ]
599
+ };
600
+ }
601
+ async function checkFeatureFlagBypass() {
602
+ const hits = await codeSearch(String.raw `(?:isPremium|isEnterprise|planTier|featureFlag|tier|subscription)\s*[:=]\s*(?:req\.|body\.|params\.|query\.)`);
603
+ if (!hits.length)
604
+ return null;
605
+ return {
606
+ id: "BIZ_FEATURE_FLAG_CLIENT",
607
+ title: "Feature entitlement sourced from client — attacker sets isPremium=true to unlock paid features (CWE-602)",
608
+ severity: "HIGH",
609
+ evidence: toEvidence(hits),
610
+ files: toFiles(hits),
611
+ requiredActions: [
612
+ "Derive feature entitlements exclusively from your database/subscription records, never from client-supplied request parameters.",
613
+ "CWE-602 — accepting tier or entitlement flags from the client allows any user to self-elevate to premium/enterprise tier.",
614
+ "Fix: const { plan } = await db.subscription.findUnique({ where: { userId: req.user.id } }); // never: req.body.isPremium"
615
+ ]
616
+ };
617
+ }
618
+ async function checkApiVersionBypass() {
619
+ const hits = await codeSearch(String.raw `(?:router|app)\.[a-z]+\(['"]\/?(api\/)?v[0-9]+\/`);
620
+ if (!hits.length)
621
+ return null;
622
+ const versions = new Set();
623
+ for (const h of hits) {
624
+ const m = /v(\d+)\//.exec(h.preview);
625
+ if (m)
626
+ versions.add(m[1]);
627
+ }
628
+ if (versions.size < 2)
629
+ return null;
630
+ return {
631
+ id: "BIZ_API_VERSION_BYPASS",
632
+ title: "Multiple API versions detected — older versions may lack security controls added to current version (CWE-284)",
633
+ severity: "HIGH",
634
+ evidence: toEvidence(hits),
635
+ files: toFiles(hits),
636
+ requiredActions: [
637
+ "Audit all active API versions to ensure security controls (auth, rate limiting, validation) are consistently applied across every version.",
638
+ "CWE-284 — deprecated API versions that remain accessible may lack authentication or authorization controls added in newer versions.",
639
+ "Fix: retire old API versions or apply the same security middleware stack (auth, validation, rate-limiting) to all /vN routes."
640
+ ]
641
+ };
642
+ }
643
+ async function checkPaginationAbuse() {
644
+ const hits = await codeSearch(String.raw `(?:limit|offset|pageSize|perPage)\s*[:=]\s*(?:parseInt|Number)?\+?\s*(?:req\.|body\.|params\.|query\.)`);
645
+ const safeRe = /Math\.min|MAX_PAGE_SIZE|maxLimit|\|\|\s*\d{2,3}/;
646
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
647
+ if (!unsafe.length)
648
+ return null;
649
+ return {
650
+ id: "BIZ_PAGINATION_UNBOUNDED",
651
+ title: "Pagination limit/offset sourced from client without upper bound — DoS via limit=1000000 or data leak (CWE-400)",
652
+ severity: "MEDIUM",
653
+ evidence: toEvidence(unsafe),
654
+ files: toFiles(unsafe),
655
+ requiredActions: [
656
+ "Cap pagination parameters to a maximum page size server-side to prevent DoS and bulk data exfiltration.",
657
+ "CWE-400 — an unbounded limit parameter allows fetching millions of records in a single request, enabling DoS or mass data extraction.",
658
+ "Fix: const limit = Math.min(parseInt(req.query.limit) || 20, MAX_PAGE_SIZE);"
659
+ ]
660
+ };
661
+ }
662
+ async function checkFreeTrialAbuse() {
663
+ const hits = await codeSearch(String.raw `(?:trial|freeTrial|trialPeriod|trialActive)\s*[:=]`);
664
+ const safeRe = /velocity|fingerprint|BIN|paymentMethod|deduplication/;
665
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
666
+ if (!unsafe.length)
667
+ return null;
668
+ return {
669
+ id: "BIZ_FREE_TRIAL_ABUSE",
670
+ title: "Free trial creation without velocity/fingerprint check — unlimited trial acquisition with synthetic identities (CWE-20)",
671
+ severity: "HIGH",
672
+ evidence: toEvidence(unsafe),
673
+ files: toFiles(unsafe),
674
+ requiredActions: [
675
+ "Gate free trial creation with velocity limits, email normalization, and optionally payment method BIN checks or device fingerprinting.",
676
+ "CWE-20 — without controls, attackers use synthetic email addresses to acquire unlimited free trials at scale.",
677
+ "Fix: enforce max one trial per normalized email, per IP (velocity window), and optionally require a payment method for trial activation."
678
+ ]
679
+ };
680
+ }
681
+ async function checkDoubleSpendPayment() {
682
+ const hits = await codeSearch(String.raw `(?:confirmPayment|capturePayment|chargeCard|processCharge|paymentIntent\.confirm)\s*\(`);
683
+ const safeRe = /mutex|lock|transaction|serializable|FOR UPDATE|idempotency/;
684
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
685
+ if (!unsafe.length)
686
+ return null;
687
+ return {
688
+ id: "BIZ_DOUBLE_SPEND_CONCURRENT",
689
+ title: "Payment capture without distributed lock — concurrent requests double-charge or double-decrement gift cards (CWE-362)",
690
+ severity: "CRITICAL",
691
+ evidence: toEvidence(unsafe),
692
+ files: toFiles(unsafe),
693
+ requiredActions: [
694
+ "Use idempotency keys, database transactions with serializable isolation, or a distributed mutex around payment capture operations.",
695
+ "CWE-362 — concurrent payment capture requests can double-charge customers or double-decrement gift card balances.",
696
+ "Fix: await stripe.paymentIntents.confirm(id, {}, { idempotencyKey: orderId }); // or wrap in a serializable DB transaction"
697
+ ]
698
+ };
699
+ }
700
+ export async function checkBusinessLogic(_opts) {
701
+ try {
702
+ const [massAssignment, idorResults, negativeAmount, raceResults, hardcodedCreds, hardcodedDb, missingValidation, insecureUrl, intOverflow, missingAdminAuth, timingOracle, floatMonetary, httpParamPollution, voucherReplay, stateMachineBypass, currencyConfusion, discountStacking, orderFulfillmentBypass, webhookTimestamp, taxShippingParamTamper, clientTotalAmount, referralAbuse, emailNormalization, featureFlagBypass, apiVersionBypass, paginationAbuse, freeTrialAbuse, doubleSpendPayment,] = await Promise.all([
703
+ checkMassAssignment(),
704
+ checkIdorDirect(),
705
+ checkNegativeAmountBypass(),
706
+ checkRaceConditionBalance(),
707
+ checkHardcodedCredentials(),
708
+ checkHardcodedDbUrlPassword(),
709
+ checkMissingInputValidation(),
710
+ checkInsecureDirectUrlAccess(),
711
+ checkIntegerOverflow(),
712
+ checkMissingAdminAuth(),
713
+ checkTimingOracle(),
714
+ checkFloatMonetaryArithmetic(),
715
+ checkHttpParamPollution(),
716
+ checkVoucherReplay(),
717
+ checkStateMachineBypass(),
718
+ checkCurrencyConfusion(),
719
+ checkDiscountStacking(),
720
+ checkOrderFulfillmentBypass(),
721
+ checkWebhookTimestamp(),
722
+ checkTaxShippingParamTamper(),
723
+ checkClientTotalAmount(),
724
+ checkReferralAbuse(),
725
+ checkEmailNormalization(),
726
+ checkFeatureFlagBypass(),
727
+ checkApiVersionBypass(),
728
+ checkPaginationAbuse(),
729
+ checkFreeTrialAbuse(),
730
+ checkDoubleSpendPayment(),
731
+ ]);
732
+ const singles = [
733
+ massAssignment, negativeAmount, hardcodedCreds, hardcodedDb, missingValidation,
734
+ insecureUrl, intOverflow, missingAdminAuth, timingOracle, floatMonetary,
735
+ httpParamPollution, voucherReplay, stateMachineBypass,
736
+ currencyConfusion, discountStacking, orderFulfillmentBypass, webhookTimestamp,
737
+ taxShippingParamTamper, clientTotalAmount, referralAbuse, emailNormalization,
738
+ featureFlagBypass, apiVersionBypass, paginationAbuse,
739
+ freeTrialAbuse, doubleSpendPayment,
740
+ ];
741
+ return [
742
+ ...singles.filter((f) => f !== null),
743
+ ...idorResults,
744
+ ...raceResults,
745
+ ];
746
+ }
747
+ catch (err) {
748
+ console.warn("[checkBusinessLogic] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
749
+ return [];
750
+ }
751
+ }