@shipsafe/cli 0.2.5 → 0.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 (52) hide show
  1. package/README.md +125 -87
  2. package/dist/bin/shipsafe.js +2 -0
  3. package/dist/bin/shipsafe.js.map +1 -1
  4. package/dist/src/claude-md/manager.d.ts.map +1 -1
  5. package/dist/src/claude-md/manager.js +2 -34
  6. package/dist/src/claude-md/manager.js.map +1 -1
  7. package/dist/src/cli/baseline.d.ts +3 -0
  8. package/dist/src/cli/baseline.d.ts.map +1 -0
  9. package/dist/src/cli/baseline.js +67 -0
  10. package/dist/src/cli/baseline.js.map +1 -0
  11. package/dist/src/cli/init.d.ts.map +1 -1
  12. package/dist/src/cli/init.js +1 -7
  13. package/dist/src/cli/init.js.map +1 -1
  14. package/dist/src/cli/scan.d.ts.map +1 -1
  15. package/dist/src/cli/scan.js +21 -3
  16. package/dist/src/cli/scan.js.map +1 -1
  17. package/dist/src/engines/builtin/baseline.d.ts +41 -0
  18. package/dist/src/engines/builtin/baseline.d.ts.map +1 -0
  19. package/dist/src/engines/builtin/baseline.js +83 -0
  20. package/dist/src/engines/builtin/baseline.js.map +1 -0
  21. package/dist/src/engines/builtin/dependencies.d.ts.map +1 -1
  22. package/dist/src/engines/builtin/dependencies.js +7 -1
  23. package/dist/src/engines/builtin/dependencies.js.map +1 -1
  24. package/dist/src/engines/builtin/gitignore.d.ts +33 -0
  25. package/dist/src/engines/builtin/gitignore.d.ts.map +1 -0
  26. package/dist/src/engines/builtin/gitignore.js +83 -0
  27. package/dist/src/engines/builtin/gitignore.js.map +1 -0
  28. package/dist/src/engines/builtin/ignore.d.ts +14 -0
  29. package/dist/src/engines/builtin/ignore.d.ts.map +1 -0
  30. package/dist/src/engines/builtin/ignore.js +114 -0
  31. package/dist/src/engines/builtin/ignore.js.map +1 -0
  32. package/dist/src/engines/builtin/patterns.d.ts.map +1 -1
  33. package/dist/src/engines/builtin/patterns.js +990 -49
  34. package/dist/src/engines/builtin/patterns.js.map +1 -1
  35. package/dist/src/engines/builtin/secrets.d.ts.map +1 -1
  36. package/dist/src/engines/builtin/secrets.js +50 -7
  37. package/dist/src/engines/builtin/secrets.js.map +1 -1
  38. package/dist/src/engines/pattern/gitleaks.js +1 -1
  39. package/dist/src/engines/pattern/gitleaks.js.map +1 -1
  40. package/dist/src/engines/pattern/index.d.ts.map +1 -1
  41. package/dist/src/engines/pattern/index.js +26 -9
  42. package/dist/src/engines/pattern/index.js.map +1 -1
  43. package/dist/src/mcp/tools/scan.d.ts.map +1 -1
  44. package/dist/src/mcp/tools/scan.js +11 -0
  45. package/dist/src/mcp/tools/scan.js.map +1 -1
  46. package/dist/src/scripts/postinstall.d.ts +10 -0
  47. package/dist/src/scripts/postinstall.d.ts.map +1 -0
  48. package/dist/src/scripts/postinstall.js +109 -0
  49. package/dist/src/scripts/postinstall.js.map +1 -0
  50. package/dist/src/types.d.ts +6 -0
  51. package/dist/src/types.d.ts.map +1 -1
  52. package/package.json +2 -1
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { readdir, readFile, stat } from 'node:fs/promises';
8
8
  import { extname, join, resolve } from 'node:path';
9
+ import { loadIgnoreFilter } from './ignore.js';
10
+ import { loadGitIgnoreFilter } from './gitignore.js';
9
11
  // ── Ignored directories and file patterns ──
10
12
  const IGNORED_DIRS = new Set([
11
13
  'node_modules',
@@ -89,8 +91,11 @@ const RULES = [
89
91
  skipCommentsAndStrings: false,
90
92
  skipTestFiles: true,
91
93
  detect: (line) => {
92
- // Match patterns like query("SELECT ... " + variable) or execute("INSERT ... " + variable)
93
- return /\b(?:query|execute|raw|prepare)\s*\(\s*(?:"|')(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^"']*(?:"|')\s*\+/i.test(line);
94
+ // Match patterns like query("SELECT ... " + variable) or db.run("INSERT ... " + variable)
95
+ // Covers: query, execute, raw, prepare, run, get, all, each, exec (SQLite), plus pool/client/connection.query
96
+ // Use separate patterns for double and single quotes to handle embedded opposite quotes
97
+ return /\b(?:query|execute|raw|prepare|run|get|all|each|exec)\s*\(\s*"(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^"]*"\s*\+/i.test(line) ||
98
+ /\b(?:query|execute|raw|prepare|run|get|all|each|exec)\s*\(\s*'(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^']*'\s*\+/i.test(line);
94
99
  },
95
100
  },
96
101
  {
@@ -104,8 +109,51 @@ const RULES = [
104
109
  skipCommentsAndStrings: false,
105
110
  skipTestFiles: true,
106
111
  detect: (line) => {
107
- // Match query(`SELECT ... ${...}`) patterns, but NOT tagged template literals like sql`...`
108
- return /\b(?:query|execute|raw|prepare)\s*\(\s*`(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^`]*\$\{/i.test(line);
112
+ // Match db.run(`SELECT ... ${...}`) patterns, but NOT tagged template literals like sql`...`
113
+ // Covers: query, execute, raw, prepare, run, get, all, each, exec
114
+ return /\b(?:query|execute|raw|prepare|run|get|all|each|exec)\s*\(\s*`(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^`]*\$\{/i.test(line);
115
+ },
116
+ },
117
+ {
118
+ id: 'SQL_INJECTION_INLINE_VAR',
119
+ category: 'SQL Injection',
120
+ description: 'SQL query string built with embedded variable via concatenation — vulnerable to SQL injection.',
121
+ severity: 'critical',
122
+ fix_suggestion: 'Use parameterized queries with placeholders (?, $1, :param) instead of string concatenation in SQL statements.',
123
+ auto_fixable: false,
124
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
125
+ skipCommentsAndStrings: false,
126
+ skipTestFiles: true,
127
+ detect: (line) => {
128
+ // Catch cases where SQL keyword appears in a string concatenated with +, even without a db method on the same line
129
+ // e.g., const sql = "SELECT * FROM users WHERE id = " + userId;
130
+ const hasSqlKeyword = /"(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^"]*"\s*\+\s*[a-zA-Z_$]/i.test(line) ||
131
+ /'(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^']*'\s*\+\s*[a-zA-Z_$]/i.test(line);
132
+ if (!hasSqlKeyword)
133
+ return false;
134
+ // Exclude string concatenation that's clearly not SQL (check for SQL structural words)
135
+ return /\b(?:FROM|INTO|SET|VALUES|WHERE|TABLE|JOIN|ORDER BY|GROUP BY)\b/i.test(line);
136
+ },
137
+ },
138
+ {
139
+ id: 'SQL_INJECTION_TEMPLATE_STRING',
140
+ category: 'SQL Injection',
141
+ description: 'SQL query built as a template literal with interpolated values — vulnerable to SQL injection.',
142
+ severity: 'critical',
143
+ fix_suggestion: 'Use parameterized queries with placeholders instead of embedding variables directly in SQL template strings.',
144
+ auto_fixable: false,
145
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
146
+ skipCommentsAndStrings: false,
147
+ skipTestFiles: true,
148
+ detect: (line) => {
149
+ // Catch template literals containing SQL keywords + interpolation, even outside a db method call
150
+ // e.g., const sql = `SELECT * FROM users WHERE id = ${userId}`;
151
+ // But NOT tagged templates like sql`...` or Prisma.$queryRaw`...`
152
+ if (/\b(?:sql|html|css|gql|graphql)\s*`/.test(line))
153
+ return false;
154
+ if (/\$queryRaw\s*`/.test(line))
155
+ return false;
156
+ return /`(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^`]*\$\{[^}]+\}[^`]*`/i.test(line);
109
157
  },
110
158
  },
111
159
  {
@@ -121,8 +169,10 @@ const RULES = [
121
169
  detect: (line) => {
122
170
  // Python: cursor.execute("SELECT ... " + var) or cursor.execute("SELECT ... %s" % var)
123
171
  // or cursor.execute(f"SELECT ...")
124
- return (/\b(?:execute|executemany)\s*\(\s*(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)\b[^"']*(?:"|')\s*%/i.test(line) ||
125
- /\b(?:execute|executemany)\s*\(\s*(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)\b[^"']*(?:"|')\s*\+/i.test(line) ||
172
+ return (/\b(?:execute|executemany)\s*\(\s*"(?:SELECT|INSERT|UPDATE|DELETE)\b[^"]*"\s*%/i.test(line) ||
173
+ /\b(?:execute|executemany)\s*\(\s*'(?:SELECT|INSERT|UPDATE|DELETE)\b[^']*'\s*%/i.test(line) ||
174
+ /\b(?:execute|executemany)\s*\(\s*"(?:SELECT|INSERT|UPDATE|DELETE)\b[^"]*"\s*\+/i.test(line) ||
175
+ /\b(?:execute|executemany)\s*\(\s*'(?:SELECT|INSERT|UPDATE|DELETE)\b[^']*'\s*\+/i.test(line) ||
126
176
  /\b(?:execute|executemany)\s*\(\s*f(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)\b/i.test(line));
127
177
  },
128
178
  },
@@ -137,11 +187,13 @@ const RULES = [
137
187
  skipCommentsAndStrings: false,
138
188
  skipTestFiles: true,
139
189
  detect: (line) => {
140
- // Sequelize, TypeORM, Knex, Django, SQLAlchemy raw queries with interpolation
141
- return (/\b(?:sequelize|connection|entityManager|manager|knex)\s*\.\s*(?:query|raw)\s*\(\s*`[^`]*\$\{/i.test(line) ||
142
- /\b(?:sequelize|connection|entityManager|manager|knex)\s*\.\s*(?:query|raw)\s*\(\s*(?:"|')[^"']*(?:"|')\s*\+/i.test(line) ||
190
+ // Sequelize, TypeORM, Knex, Prisma, Django, SQLAlchemy raw queries with interpolation
191
+ return (/\b(?:sequelize|connection|entityManager|manager|knex|pool|client|db|database)\s*\.\s*(?:query|raw|run|get|all|each|exec)\s*\(\s*`[^`]*\$\{/i.test(line) ||
192
+ /\b(?:sequelize|connection|entityManager|manager|knex|pool|client|db|database)\s*\.\s*(?:query|raw|run|get|all|each|exec)\s*\(\s*"[^"]*"\s*\+/i.test(line) ||
193
+ /\b(?:sequelize|connection|entityManager|manager|knex|pool|client|db|database)\s*\.\s*(?:query|raw|run|get|all|each|exec)\s*\(\s*'[^']*'\s*\+/i.test(line) ||
143
194
  /\bRawSQL\s*\(\s*f(?:"|')/i.test(line) ||
144
- /\.raw\s*\(\s*f(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)/i.test(line));
195
+ /\.raw\s*\(\s*f(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)/i.test(line) ||
196
+ /\$queryRawUnsafe\s*\(/.test(line));
145
197
  },
146
198
  },
147
199
  // ════════════════════════════════════════════
@@ -860,44 +912,928 @@ const RULES = [
860
912
  return !/\bhelmet\b/.test(ctx.fileContent);
861
913
  },
862
914
  },
863
- ];
864
- // ── File Discovery ──
865
- async function discoverFiles(targetPath) {
866
- const files = [];
867
- const resolvedTarget = resolve(targetPath);
868
- async function walk(dir) {
869
- let entries;
870
- try {
871
- entries = await readdir(dir, { withFileTypes: true });
872
- }
873
- catch {
874
- return; // skip unreadable directories
875
- }
876
- for (const entry of entries) {
877
- if (entry.name.startsWith('.') && entry.name !== '.')
878
- continue;
879
- if (entry.isDirectory()) {
880
- if (IGNORED_DIRS.has(entry.name))
881
- continue;
882
- await walk(join(dir, entry.name));
883
- }
884
- else if (entry.isFile()) {
885
- const ext = extname(entry.name);
886
- if (SCANNABLE_EXTENSIONS.has(ext)) {
887
- files.push(join(dir, entry.name));
888
- }
889
- }
890
- }
891
- }
892
- // Check if targetPath is a file or directory
893
- const targetStat = await stat(resolvedTarget);
894
- if (targetStat.isFile()) {
895
- const ext = extname(resolvedTarget);
896
- if (SCANNABLE_EXTENSIONS.has(ext)) {
897
- files.push(resolvedTarget);
898
- }
899
- }
900
- else {
915
+ // ════════════════════════════════════════════
916
+ // SSRF (Server-Side Request Forgery)
917
+ // ════════════════════════════════════════════
918
+ {
919
+ id: 'SSRF_USER_URL',
920
+ category: 'Server-Side Request Forgery',
921
+ description: 'HTTP request made with a URL from user input (req.query, req.body, req.params) — vulnerable to SSRF.',
922
+ severity: 'high',
923
+ fix_suggestion: 'Validate and sanitize user-supplied URLs. Use an allowlist of permitted domains/IPs. Block internal/private IP ranges (127.0.0.1, 10.x, 192.168.x, 169.254.x).',
924
+ auto_fixable: false,
925
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
926
+ skipCommentsAndStrings: true,
927
+ skipTestFiles: true,
928
+ detect: (line) => {
929
+ return /\b(?:fetch|axios\s*\.\s*(?:get|post|put|patch|delete)|http\s*\.\s*(?:get|request)|got\s*\.\s*(?:get|post)|request\s*\.\s*(?:get|post))\s*\(\s*req\s*\.\s*(?:query|body|params|headers)\b/.test(line) ||
930
+ /\b(?:fetch|axios\s*\.\s*(?:get|post|put|patch|delete)|http\s*\.\s*(?:get|request))\s*\(\s*(?:url|uri|href|link|target|redirect|callback)\b/.test(line) &&
931
+ /\breq\s*\.\s*(?:query|body|params)\b/.test(line);
932
+ },
933
+ },
934
+ // ════════════════════════════════════════════
935
+ // Insecure Cookie Configuration
936
+ // ════════════════════════════════════════════
937
+ {
938
+ id: 'COOKIE_NO_HTTPONLY',
939
+ category: 'Insecure Cookie',
940
+ description: 'Cookie set without httpOnly flag — accessible to JavaScript, enabling theft via XSS.',
941
+ severity: 'medium',
942
+ fix_suggestion: 'Set httpOnly: true on cookies containing session tokens or sensitive data to prevent JavaScript access.',
943
+ auto_fixable: true,
944
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
945
+ skipCommentsAndStrings: true,
946
+ skipTestFiles: true,
947
+ detect: (line, ctx) => {
948
+ // Match res.cookie(...) calls
949
+ if (!/\bres\s*\.\s*cookie\s*\(/.test(line))
950
+ return false;
951
+ // Check for httpOnly in a 5-line window
952
+ const lineIdx = ctx.lineNumber - 1;
953
+ const window = ctx.allLines
954
+ .slice(Math.max(0, lineIdx), Math.min(ctx.allLines.length, lineIdx + 5))
955
+ .join(' ');
956
+ return !/httpOnly\s*:\s*true/.test(window);
957
+ },
958
+ },
959
+ {
960
+ id: 'COOKIE_NO_SECURE',
961
+ category: 'Insecure Cookie',
962
+ description: 'Cookie set without secure flag — will be sent over unencrypted HTTP connections.',
963
+ severity: 'medium',
964
+ fix_suggestion: 'Set secure: true on cookies to ensure they are only sent over HTTPS.',
965
+ auto_fixable: true,
966
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
967
+ skipCommentsAndStrings: true,
968
+ skipTestFiles: true,
969
+ detect: (line, ctx) => {
970
+ if (!/\bres\s*\.\s*cookie\s*\(/.test(line))
971
+ return false;
972
+ const lineIdx = ctx.lineNumber - 1;
973
+ const window = ctx.allLines
974
+ .slice(Math.max(0, lineIdx), Math.min(ctx.allLines.length, lineIdx + 5))
975
+ .join(' ');
976
+ return !/secure\s*:\s*true/.test(window);
977
+ },
978
+ },
979
+ // ════════════════════════════════════════════
980
+ // NoSQL Injection
981
+ // ════════════════════════════════════════════
982
+ {
983
+ id: 'NOSQL_INJECTION',
984
+ category: 'NoSQL Injection',
985
+ description: 'MongoDB query uses user input directly — vulnerable to NoSQL injection via operator injection ($gt, $ne, etc.).',
986
+ severity: 'high',
987
+ fix_suggestion: 'Validate and sanitize user input before using in MongoDB queries. Ensure input is the expected type (string, not object). Use mongo-sanitize or explicitly cast values.',
988
+ auto_fixable: false,
989
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
990
+ skipCommentsAndStrings: true,
991
+ skipTestFiles: true,
992
+ detect: (line) => {
993
+ // Match patterns like .find({ email: req.body.email }) or .findOne(req.body)
994
+ return /\.\s*(?:find|findOne|findOneAndUpdate|findOneAndDelete|updateOne|updateMany|deleteOne|deleteMany|aggregate|countDocuments)\s*\(\s*req\s*\.\s*(?:body|query|params)\b/.test(line) ||
995
+ /\.\s*(?:find|findOne|findOneAndUpdate|findOneAndDelete|updateOne|updateMany)\s*\(\s*\{[^}]*:\s*req\s*\.\s*(?:body|query|params)\b/.test(line);
996
+ },
997
+ },
998
+ // ════════════════════════════════════════════
999
+ // Mass Assignment
1000
+ // ════════════════════════════════════════════
1001
+ {
1002
+ id: 'MASS_ASSIGNMENT',
1003
+ category: 'Mass Assignment',
1004
+ description: 'Passing req.body directly to database create/update may allow attackers to set fields they shouldn\'t (e.g., isAdmin, role).',
1005
+ severity: 'medium',
1006
+ fix_suggestion: 'Destructure and explicitly pick allowed fields from req.body before passing to database operations. Never pass raw req.body to create/update.',
1007
+ auto_fixable: false,
1008
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1009
+ skipCommentsAndStrings: true,
1010
+ skipTestFiles: true,
1011
+ detect: (line) => {
1012
+ return /\.\s*(?:create|insertOne|insertMany|update|updateOne|findOneAndUpdate|save)\s*\(\s*req\s*\.\s*body\s*[,)]/.test(line) ||
1013
+ /\.\s*(?:create|insert)\s*\(\s*\{\s*\.\.\.\s*req\s*\.\s*body\b/.test(line);
1014
+ },
1015
+ },
1016
+ // ════════════════════════════════════════════
1017
+ // Timing-Unsafe Comparison
1018
+ // ════════════════════════════════════════════
1019
+ {
1020
+ id: 'TIMING_UNSAFE_COMPARISON',
1021
+ category: 'Timing Attack',
1022
+ description: 'Token or secret compared with === which is vulnerable to timing attacks — comparison time reveals information about the value.',
1023
+ severity: 'medium',
1024
+ fix_suggestion: 'Use crypto.timingSafeEqual() (Node.js) for comparing secrets, tokens, HMAC digests, or API keys.',
1025
+ auto_fixable: false,
1026
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1027
+ skipCommentsAndStrings: true,
1028
+ skipTestFiles: true,
1029
+ detect: (line) => {
1030
+ const lower = line.toLowerCase();
1031
+ if (!/===/.test(line))
1032
+ return false;
1033
+ return ((lower.includes('token') || lower.includes('hmac') || lower.includes('digest') || lower.includes('signature')) &&
1034
+ !/timingSafeEqual/.test(line));
1035
+ },
1036
+ },
1037
+ // ════════════════════════════════════════════
1038
+ // Insecure Randomness (non-crypto contexts)
1039
+ // ════════════════════════════════════════════
1040
+ {
1041
+ id: 'CRYPTO_MATH_RANDOM_ID',
1042
+ category: 'Insecure Cryptography',
1043
+ description: 'Math.random() used to generate an ID or token — IDs generated this way are predictable.',
1044
+ severity: 'high',
1045
+ fix_suggestion: 'Use crypto.randomUUID() or crypto.randomBytes() for generating unpredictable IDs and tokens.',
1046
+ auto_fixable: true,
1047
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1048
+ skipCommentsAndStrings: true,
1049
+ skipTestFiles: true,
1050
+ detect: (line) => {
1051
+ if (!/\bMath\s*\.\s*random\s*\(\s*\)/.test(line))
1052
+ return false;
1053
+ const lower = line.toLowerCase();
1054
+ return lower.includes('id') || lower.includes('slug') || lower.includes('hash');
1055
+ },
1056
+ },
1057
+ // ════════════════════════════════════════════
1058
+ // Insecure Password Storage
1059
+ // ════════════════════════════════════════════
1060
+ {
1061
+ id: 'AUTH_PLAINTEXT_PASSWORD_STORAGE',
1062
+ category: 'Authentication Issues',
1063
+ description: 'Password appears to be stored or inserted without hashing — passwords must always be hashed before storage.',
1064
+ severity: 'critical',
1065
+ fix_suggestion: 'Hash passwords with bcrypt, argon2, or scrypt before storing. Never store plaintext passwords.',
1066
+ auto_fixable: false,
1067
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1068
+ skipCommentsAndStrings: false,
1069
+ skipTestFiles: true,
1070
+ detect: (line) => {
1071
+ // Detect patterns where password is directly inserted into a database
1072
+ // e.g., INSERT INTO users ... VALUES ... password or db.create({ password: req.body.password })
1073
+ const lower = line.toLowerCase();
1074
+ if (!lower.includes('password'))
1075
+ return false;
1076
+ // Skip lines that mention hashing
1077
+ if (/\b(?:hash|bcrypt|argon2|scrypt|pbkdf2|crypto)\b/i.test(line))
1078
+ return false;
1079
+ // Skip process.env references
1080
+ if (/process\s*\.\s*env\b/.test(line))
1081
+ return false;
1082
+ // Catch INSERT statements with password directly
1083
+ if (/\b(?:INSERT|VALUES)\b/i.test(line) && /\$\{[^}]*password[^}]*\}/i.test(line))
1084
+ return true;
1085
+ if (/\b(?:INSERT|VALUES)\b/i.test(line) && /(?:"|')\s*\+\s*[a-zA-Z_$]*password/i.test(line))
1086
+ return true;
1087
+ return false;
1088
+ },
1089
+ },
1090
+ // ════════════════════════════════════════════
1091
+ // Exposed Error Details
1092
+ // ════════════════════════════════════════════
1093
+ {
1094
+ id: 'DATA_ERROR_DETAILS_LEAK',
1095
+ category: 'Sensitive Data Exposure',
1096
+ description: 'Error message or object sent directly in HTTP response — may leak internal details to attackers.',
1097
+ severity: 'medium',
1098
+ fix_suggestion: 'Return a generic error message to clients (e.g., "Internal server error"). Log the full error server-side only.',
1099
+ auto_fixable: false,
1100
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1101
+ skipCommentsAndStrings: true,
1102
+ skipTestFiles: true,
1103
+ detect: (line) => {
1104
+ // Match patterns like res.status(500).json({ error: err }) or res.json({ error: error.message })
1105
+ return /\bres\s*\.\s*(?:status\s*\(\s*5\d{2}\s*\)\s*\.\s*)?(?:json|send)\s*\(\s*\{?\s*(?:error|message|err)\s*:\s*(?:err|error|e)\b/.test(line) ||
1106
+ /\bres\s*\.\s*(?:status\s*\(\s*5\d{2}\s*\)\s*\.\s*)?(?:json|send)\s*\(\s*(?:err|error|e)\s*[,)]/.test(line);
1107
+ },
1108
+ },
1109
+ // ════════════════════════════════════════════
1110
+ // Hardcoded Database Credentials
1111
+ // ════════════════════════════════════════════
1112
+ {
1113
+ id: 'SECRET_DB_CREDENTIALS',
1114
+ category: 'Hardcoded Secrets',
1115
+ description: 'Database connection string or credentials appear to be hardcoded — should use environment variables.',
1116
+ severity: 'critical',
1117
+ fix_suggestion: 'Store database credentials in environment variables (e.g., process.env.DATABASE_URL). Never hardcode connection strings.',
1118
+ auto_fixable: false,
1119
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
1120
+ skipCommentsAndStrings: false,
1121
+ skipTestFiles: true,
1122
+ detect: (line) => {
1123
+ if (/process\s*\.\s*env\b/.test(line) || /os\s*\.\s*(?:environ|getenv)\b/.test(line))
1124
+ return false;
1125
+ // Match hardcoded connection strings: mongodb://, postgres://, mysql://, redis:// with credentials
1126
+ return /['"](?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp|mssql):\/\/[^'"]*:[^'"]*@[^'"]+['"]/.test(line);
1127
+ },
1128
+ },
1129
+ // ════════════════════════════════════════════
1130
+ // Unvalidated File Upload
1131
+ // ════════════════════════════════════════════
1132
+ {
1133
+ id: 'UPLOAD_NO_VALIDATION',
1134
+ category: 'Insecure File Upload',
1135
+ description: 'File upload handler without apparent file type or size validation — may allow malicious file uploads.',
1136
+ severity: 'medium',
1137
+ fix_suggestion: 'Validate file type (MIME type and extension), enforce file size limits, and store uploads outside the web root.',
1138
+ auto_fixable: false,
1139
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1140
+ skipCommentsAndStrings: true,
1141
+ skipTestFiles: true,
1142
+ detect: (line, ctx) => {
1143
+ // Match multer upload without file filter
1144
+ if (!/\bmulter\s*\(/.test(line))
1145
+ return false;
1146
+ const lineIdx = ctx.lineNumber - 1;
1147
+ const window = ctx.allLines
1148
+ .slice(Math.max(0, lineIdx), Math.min(ctx.allLines.length, lineIdx + 8))
1149
+ .join(' ');
1150
+ return !/fileFilter|limits|maxFileSize/.test(window);
1151
+ },
1152
+ },
1153
+ // ════════════════════════════════════════════
1154
+ // Prompt Injection — LLM / AI Security
1155
+ // ════════════════════════════════════════════
1156
+ {
1157
+ id: 'PROMPT_INJECTION_CONCAT',
1158
+ category: 'Prompt Injection',
1159
+ description: 'User input concatenated directly into an LLM prompt string — vulnerable to prompt injection attacks.',
1160
+ severity: 'high',
1161
+ fix_suggestion: 'Never concatenate user input into prompts. Use structured message arrays with separate system/user roles, and sanitize user input before including it in any prompt context.',
1162
+ auto_fixable: false,
1163
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
1164
+ skipCommentsAndStrings: false,
1165
+ skipTestFiles: true,
1166
+ detect: (line) => {
1167
+ // Detect prompt/system message strings concatenated with user input
1168
+ // e.g., const prompt = "You are a helpful assistant. " + userInput
1169
+ // e.g., const prompt = `You are an assistant. ${req.body.message}`
1170
+ const hasPromptContext = /\b(?:prompt|system_prompt|systemPrompt|system_message|systemMessage|instruction|instructions)\s*[:=]\s*/.test(line);
1171
+ if (!hasPromptContext)
1172
+ return false;
1173
+ // Check for string concatenation or template literal interpolation with likely user input
1174
+ return /(?:"|')\s*\+\s*(?:req\s*\.\s*(?:body|query|params)|user[Ii]nput|input|message|query|question|userMessage|userQuery)\b/.test(line) ||
1175
+ /`[^`]*\$\{[^}]*(?:req\s*\.\s*(?:body|query|params)|user[Ii]nput|input|message|query|question|userMessage|userQuery)\b[^}]*\}/.test(line);
1176
+ },
1177
+ },
1178
+ {
1179
+ id: 'PROMPT_INJECTION_TEMPLATE',
1180
+ category: 'Prompt Injection',
1181
+ description: 'User input interpolated into an LLM prompt template — vulnerable to prompt injection.',
1182
+ severity: 'high',
1183
+ fix_suggestion: 'Separate system instructions from user content using the API\'s message role system (system/user/assistant). Never embed raw user input into system prompts.',
1184
+ auto_fixable: false,
1185
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
1186
+ skipCommentsAndStrings: false,
1187
+ skipTestFiles: true,
1188
+ detect: (line) => {
1189
+ // Match patterns where user input is embedded in strings that look like LLM prompts
1190
+ // e.g., `You are a helpful assistant. The user says: ${userInput}`
1191
+ // Look for prompt-like language + interpolation with user-input-like variables
1192
+ const isPromptString = /(?:`|"|')(?:You are|Act as|Respond as|Your (?:role|task|job) is|SYSTEM PROMPT)/i.test(line);
1193
+ if (!isPromptString)
1194
+ return false;
1195
+ // Require interpolation with user-input-like variable names (not generic variables)
1196
+ return /\$\{[^}]*(?:req\s*\.\s*(?:body|query|params)|user[Ii]nput|input|message|query|question|userMessage|userQuery|prompt|text|content)\b/.test(line) ||
1197
+ /(?:"|')\s*\+\s*(?:req\s*\.\s*(?:body|query|params)|user[Ii]nput|input|message|query|question|userMessage|userQuery|prompt|text|content)\b/.test(line) ||
1198
+ /\.\s*(?:format|replace)\s*\(/.test(line);
1199
+ },
1200
+ },
1201
+ {
1202
+ id: 'PROMPT_INJECTION_API_UNSANITIZED',
1203
+ category: 'Prompt Injection',
1204
+ description: 'User input passed directly to an LLM API call without sanitization — enables prompt injection.',
1205
+ severity: 'high',
1206
+ fix_suggestion: 'Sanitize and validate user input before passing to LLM APIs. Strip or escape control sequences, enforce input length limits, and use structured message roles to separate instructions from user content.',
1207
+ auto_fixable: false,
1208
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1209
+ skipCommentsAndStrings: false,
1210
+ skipTestFiles: true,
1211
+ detect: (line, ctx) => {
1212
+ // Detect direct req.body/query passed to AI API content fields
1213
+ // e.g., { role: "user", content: req.body.message }
1214
+ // e.g., messages: [{ role: "user", content: userInput }]
1215
+ if (!/\bcontent\s*:\s*req\s*\.\s*(?:body|query|params)\b/.test(line))
1216
+ return false;
1217
+ // Verify this is in an AI API context by checking surrounding lines
1218
+ const lineIdx = ctx.lineNumber - 1;
1219
+ const window = ctx.allLines
1220
+ .slice(Math.max(0, lineIdx - 5), Math.min(ctx.allLines.length, lineIdx + 5))
1221
+ .join(' ');
1222
+ return /\b(?:role|messages|model|openai|anthropic|claude|gpt|chat\.completions|createMessage)\b/i.test(window);
1223
+ },
1224
+ },
1225
+ {
1226
+ id: 'PROMPT_INJECTION_SYSTEM_ROLE_USER_INPUT',
1227
+ category: 'Prompt Injection',
1228
+ description: 'User input appears to be included in a system-role message — attackers can override system instructions.',
1229
+ severity: 'critical',
1230
+ fix_suggestion: 'Never include user input in system messages. Keep system prompts static. Pass user content only in user-role messages, and consider adding an input validation layer.',
1231
+ auto_fixable: false,
1232
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
1233
+ skipCommentsAndStrings: false,
1234
+ skipTestFiles: true,
1235
+ detect: (line, ctx) => {
1236
+ // Detect system role messages with interpolated user input
1237
+ // e.g., { role: "system", content: `You are... ${req.body.context}` }
1238
+ if (!/role\s*:\s*['"]system['"]/.test(line))
1239
+ return false;
1240
+ // Check if this line or nearby lines have user input interpolation in content
1241
+ const lineIdx = ctx.lineNumber - 1;
1242
+ const window = ctx.allLines
1243
+ .slice(Math.max(0, lineIdx), Math.min(ctx.allLines.length, lineIdx + 3))
1244
+ .join(' ');
1245
+ return /content\s*:.*\$\{/.test(window) ||
1246
+ /content\s*:.*\breq\s*\.\s*(?:body|query|params)\b/.test(window) ||
1247
+ /content\s*:.*(?:"|')\s*\+/.test(window);
1248
+ },
1249
+ },
1250
+ {
1251
+ id: 'PROMPT_INJECTION_NO_INPUT_LIMIT',
1252
+ category: 'Prompt Injection',
1253
+ description: 'User input sent to LLM API without apparent length validation — enables token exhaustion and increases prompt injection surface.',
1254
+ severity: 'medium',
1255
+ fix_suggestion: 'Enforce a maximum length on user input before passing to LLM APIs. Truncate or reject inputs exceeding the limit. This reduces costs and limits prompt injection surface area.',
1256
+ auto_fixable: false,
1257
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1258
+ skipCommentsAndStrings: true,
1259
+ skipTestFiles: true,
1260
+ detect: (line, ctx) => {
1261
+ // Look for AI API calls (chat.completions.create, messages.create, etc.) and check
1262
+ // if there's input length validation nearby
1263
+ if (!/\b(?:completions|messages|chat|generate)\s*\.\s*create\s*\(/.test(line))
1264
+ return false;
1265
+ // Check a wide window for length validation
1266
+ const lineIdx = ctx.lineNumber - 1;
1267
+ const window = ctx.allLines
1268
+ .slice(Math.max(0, lineIdx - 15), Math.min(ctx.allLines.length, lineIdx + 3))
1269
+ .join(' ');
1270
+ // If there's length checking, trim, slice, or validation, skip
1271
+ if (/\b(?:\.length|\.slice|\.substring|\.trim|maxLength|max_length|MAX_LENGTH|truncate|validate|maxTokens|max_tokens)\b/.test(window))
1272
+ return false;
1273
+ // Check if user input is being passed in
1274
+ return /\breq\s*\.\s*(?:body|query|params)\b/.test(window) ||
1275
+ /\b(?:user[Ii]nput|userMessage|userQuery|input|message)\b/.test(window);
1276
+ },
1277
+ },
1278
+ {
1279
+ id: 'PROMPT_INJECTION_PYTHON_FSTRING',
1280
+ category: 'Prompt Injection',
1281
+ description: 'User input embedded in a Python f-string prompt — vulnerable to prompt injection.',
1282
+ severity: 'high',
1283
+ fix_suggestion: 'Use structured message roles instead of f-string prompts. Separate system instructions from user content. Sanitize user input before inclusion.',
1284
+ auto_fixable: false,
1285
+ fileTypes: ['.py'],
1286
+ skipCommentsAndStrings: false,
1287
+ skipTestFiles: true,
1288
+ detect: (line) => {
1289
+ // Match Python f-strings that look like prompts with user variables
1290
+ // e.g., prompt = f"You are a helpful assistant. The user asks: {user_input}"
1291
+ const isPromptAssignment = /\b(?:prompt|system_prompt|system_message|instruction|messages?)\s*=\s*f(?:"|')/.test(line);
1292
+ if (!isPromptAssignment)
1293
+ return false;
1294
+ // Check for variable interpolation (curly braces in f-string)
1295
+ return /\{[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_]+)*\}/.test(line);
1296
+ },
1297
+ },
1298
+ {
1299
+ id: 'PROMPT_INJECTION_RAG_UNSANITIZED',
1300
+ category: 'Prompt Injection',
1301
+ description: 'Retrieved document content injected into LLM prompt without sanitization — indirect prompt injection risk via poisoned documents.',
1302
+ severity: 'medium',
1303
+ fix_suggestion: 'Sanitize and delimit retrieved content before injecting into prompts. Use clear boundary markers (e.g., XML tags) between instructions and retrieved content. Consider content filtering for suspicious patterns.',
1304
+ auto_fixable: false,
1305
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
1306
+ skipCommentsAndStrings: false,
1307
+ skipTestFiles: true,
1308
+ detect: (line, ctx) => {
1309
+ // Detect patterns where retrieved/fetched content is embedded in prompts
1310
+ // e.g., content: `Based on these documents: ${documents.map(d => d.content).join('\n')}`
1311
+ // e.g., prompt = f"Context: {retrieved_docs}\n\nQuestion: {query}"
1312
+ const hasContextPattern = /\b(?:context|documents?|chunks?|results?|passages?|retrieved|search_results|embeddings?)\b/i.test(line);
1313
+ if (!hasContextPattern)
1314
+ return false;
1315
+ // Check if this is in a prompt/message context
1316
+ const lineIdx = ctx.lineNumber - 1;
1317
+ const window = ctx.allLines
1318
+ .slice(Math.max(0, lineIdx - 3), Math.min(ctx.allLines.length, lineIdx + 3))
1319
+ .join(' ');
1320
+ const isLLMContext = /\b(?:prompt|content|messages?|role|system|openai|anthropic|completion)\b/i.test(window);
1321
+ if (!isLLMContext)
1322
+ return false;
1323
+ // Check for interpolation
1324
+ return /\$\{[^}]*(?:document|chunk|result|passage|retrieved|context|search)\b/i.test(line) ||
1325
+ /\{[a-zA-Z_]*(?:document|chunk|result|passage|retrieved|context|search)[a-zA-Z_]*\}/i.test(line) ||
1326
+ /(?:"|')\s*\+\s*[a-zA-Z_]*(?:document|chunk|result|passage|retrieved|context|search)\b/i.test(line);
1327
+ },
1328
+ },
1329
+ // ════════════════════════════════════════════
1330
+ // Open Redirect (client-side)
1331
+ // ════════════════════════════════════════════
1332
+ {
1333
+ id: 'OPEN_REDIRECT_WINDOW',
1334
+ category: 'Open Redirect',
1335
+ description: 'window.location assigned from user-controlled input — allows open redirect attacks for phishing.',
1336
+ severity: 'high',
1337
+ fix_suggestion: 'Validate redirect URLs against an allowlist of trusted domains. Never assign user-controlled values directly to window.location.',
1338
+ auto_fixable: false,
1339
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1340
+ skipCommentsAndStrings: true,
1341
+ skipTestFiles: true,
1342
+ detect: (line) => {
1343
+ // Match window.location = userInput, window.location.href = req.query.redirect, etc.
1344
+ if (!/\bwindow\s*\.\s*location\s*(?:\.href)?\s*=/.test(line))
1345
+ return false;
1346
+ // Must be assigned from a variable, not a string literal
1347
+ if (/\bwindow\s*\.\s*location\s*(?:\.href)?\s*=\s*['"`]/.test(line))
1348
+ return false;
1349
+ return true;
1350
+ },
1351
+ },
1352
+ // ════════════════════════════════════════════
1353
+ // Insecure Deserialization (JSON.parse from request)
1354
+ // ════════════════════════════════════════════
1355
+ {
1356
+ id: 'INSECURE_DESERIALIZE_JSON',
1357
+ category: 'Insecure Deserialization',
1358
+ description: 'JSON.parse() on raw request body/query without validation — parsed objects may contain unexpected properties or trigger prototype pollution.',
1359
+ severity: 'medium',
1360
+ fix_suggestion: 'Validate the parsed result with a schema validator (e.g., Zod, Joi, ajv) before using. Never trust the shape of user-supplied JSON.',
1361
+ auto_fixable: false,
1362
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1363
+ skipCommentsAndStrings: true,
1364
+ skipTestFiles: true,
1365
+ detect: (line) => {
1366
+ return /\bJSON\s*\.\s*parse\s*\(\s*req\s*\.\s*(?:body|query)/.test(line);
1367
+ },
1368
+ },
1369
+ // ════════════════════════════════════════════
1370
+ // OAuth State Parameter Missing
1371
+ // ════════════════════════════════════════════
1372
+ {
1373
+ id: 'OAUTH_STATE_MISSING',
1374
+ category: 'Authentication Issues',
1375
+ description: 'OAuth authorize URL constructed without a state parameter — vulnerable to CSRF attacks on the OAuth flow.',
1376
+ severity: 'high',
1377
+ fix_suggestion: 'Always include a cryptographically random state parameter in OAuth authorization requests and verify it on callback.',
1378
+ auto_fixable: false,
1379
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
1380
+ skipCommentsAndStrings: true,
1381
+ skipTestFiles: true,
1382
+ detect: (line, ctx) => {
1383
+ // Detect /authorize URL construction
1384
+ if (!/\/authorize/.test(line))
1385
+ return false;
1386
+ if (!/\b(?:oauth|auth|client_id|response_type|redirect_uri)\b/i.test(line))
1387
+ return false;
1388
+ // Check a window for state parameter
1389
+ const lineIdx = ctx.lineNumber - 1;
1390
+ const window = ctx.allLines
1391
+ .slice(Math.max(0, lineIdx - 2), Math.min(ctx.allLines.length, lineIdx + 5))
1392
+ .join(' ');
1393
+ return !/\bstate\s*[=:]/.test(window);
1394
+ },
1395
+ },
1396
+ // ════════════════════════════════════════════
1397
+ // CORS Credentials with Wildcard Origin
1398
+ // ════════════════════════════════════════════
1399
+ {
1400
+ id: 'CORS_CREDENTIALS_WILDCARD',
1401
+ category: 'Insecure Configuration',
1402
+ description: 'CORS configured with credentials: true and a wildcard or permissive origin — this misconfiguration can expose authenticated endpoints to any origin.',
1403
+ severity: 'critical',
1404
+ fix_suggestion: 'When using credentials: true, specify exact allowed origins instead of "*" or origin: true. Use an allowlist of trusted domains.',
1405
+ auto_fixable: false,
1406
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1407
+ skipCommentsAndStrings: false,
1408
+ skipTestFiles: true,
1409
+ detect: (line, ctx) => {
1410
+ // Check for credentials: true near origin: '*' or origin: true
1411
+ const lineIdx = ctx.lineNumber - 1;
1412
+ const windowLines = ctx.allLines
1413
+ .slice(Math.max(0, lineIdx - 5), Math.min(ctx.allLines.length, lineIdx + 5))
1414
+ .join(' ');
1415
+ const hasCreds = /credentials\s*:\s*true/.test(windowLines);
1416
+ const hasWildcardOrigin = /origin\s*:\s*(?:['"]\*['"]|true)/.test(windowLines);
1417
+ // Only fire on the line that contains credentials: true
1418
+ if (!/credentials\s*:\s*true/.test(line))
1419
+ return false;
1420
+ return hasCreds && hasWildcardOrigin;
1421
+ },
1422
+ },
1423
+ // ════════════════════════════════════════════
1424
+ // Helmet CSP Disabled
1425
+ // ════════════════════════════════════════════
1426
+ {
1427
+ id: 'HELMET_CSP_DISABLED',
1428
+ category: 'Insecure Configuration',
1429
+ description: 'helmet() used with contentSecurityPolicy: false — disables Content-Security-Policy, a critical XSS defense.',
1430
+ severity: 'medium',
1431
+ fix_suggestion: 'Configure a proper Content-Security-Policy instead of disabling it. Use helmet.contentSecurityPolicy({ directives: { ... } }).',
1432
+ auto_fixable: false,
1433
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1434
+ skipCommentsAndStrings: false,
1435
+ skipTestFiles: true,
1436
+ detect: (line, ctx) => {
1437
+ if (!/\bhelmet\s*\(/.test(line))
1438
+ return false;
1439
+ const lineIdx = ctx.lineNumber - 1;
1440
+ const windowLines = ctx.allLines
1441
+ .slice(Math.max(0, lineIdx), Math.min(ctx.allLines.length, lineIdx + 8))
1442
+ .join(' ');
1443
+ return /contentSecurityPolicy\s*:\s*false/.test(windowLines);
1444
+ },
1445
+ },
1446
+ // ════════════════════════════════════════════
1447
+ // HMAC / Signature Unsafe Comparison
1448
+ // ════════════════════════════════════════════
1449
+ {
1450
+ id: 'HMAC_COMPARISON_UNSAFE',
1451
+ category: 'Timing Attack',
1452
+ description: 'HMAC, signature, or digest compared with === instead of crypto.timingSafeEqual() — enables timing attacks to forge signatures.',
1453
+ severity: 'high',
1454
+ fix_suggestion: 'Use crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) to compare HMAC digests, signatures, and other secret values.',
1455
+ auto_fixable: false,
1456
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1457
+ skipCommentsAndStrings: true,
1458
+ skipTestFiles: true,
1459
+ detect: (line) => {
1460
+ if (!/===/.test(line))
1461
+ return false;
1462
+ if (/timingSafeEqual/.test(line))
1463
+ return false;
1464
+ // Specifically target HMAC/signature/digest comparisons
1465
+ return /\b(?:hmac|signature|digest|mac)\b/i.test(line) &&
1466
+ /===/.test(line);
1467
+ },
1468
+ },
1469
+ // ════════════════════════════════════════════
1470
+ // Express Session Insecure
1471
+ // ════════════════════════════════════════════
1472
+ {
1473
+ id: 'EXPRESS_SESSION_INSECURE',
1474
+ category: 'Insecure Configuration',
1475
+ description: 'express-session configured with insecure defaults — missing secure cookie flag or using a hardcoded secret string.',
1476
+ severity: 'high',
1477
+ fix_suggestion: 'Set cookie.secure: true in production, use a strong secret from environment variables (process.env.SESSION_SECRET), and use a persistent session store (not MemoryStore).',
1478
+ auto_fixable: false,
1479
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1480
+ skipCommentsAndStrings: false,
1481
+ skipTestFiles: true,
1482
+ detect: (line, ctx) => {
1483
+ if (!/\bsession\s*\(\s*\{/.test(line))
1484
+ return false;
1485
+ const lineIdx = ctx.lineNumber - 1;
1486
+ const windowLines = ctx.allLines
1487
+ .slice(Math.max(0, lineIdx), Math.min(ctx.allLines.length, lineIdx + 10))
1488
+ .join(' ');
1489
+ // Flag if secret is a hardcoded string literal
1490
+ const hasHardcodedSecret = /secret\s*:\s*['"][^'"]+['"]/.test(windowLines) &&
1491
+ !/process\s*\.\s*env\b/.test(windowLines);
1492
+ // Flag if secure: true is missing from cookie config
1493
+ const missingSecure = !/secure\s*:\s*true/.test(windowLines);
1494
+ return hasHardcodedSecret || missingSecure;
1495
+ },
1496
+ },
1497
+ // ════════════════════════════════════════════
1498
+ // Unvalidated File Type
1499
+ // ════════════════════════════════════════════
1500
+ {
1501
+ id: 'UNVALIDATED_FILE_TYPE',
1502
+ category: 'Insecure File Upload',
1503
+ description: 'User-supplied file metadata (mimetype, originalname) used in path construction without validation — may allow path traversal or arbitrary file overwrites.',
1504
+ severity: 'medium',
1505
+ fix_suggestion: 'Validate file extensions against an allowlist. Never use the original filename directly — generate a safe filename (e.g., UUID) and validate the MIME type.',
1506
+ auto_fixable: false,
1507
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1508
+ skipCommentsAndStrings: true,
1509
+ skipTestFiles: true,
1510
+ detect: (line) => {
1511
+ // Detect req.file.mimetype or req.file.originalname used in path operations
1512
+ return (/\breq\s*\.\s*file\s*\.\s*(?:mimetype|originalname)\b/.test(line) &&
1513
+ /\b(?:join|resolve|writeFile|rename|move|createWriteStream|path|extname)\b/.test(line));
1514
+ },
1515
+ },
1516
+ // ════════════════════════════════════════════
1517
+ // Response Header Injection
1518
+ // ════════════════════════════════════════════
1519
+ {
1520
+ id: 'RESPONSE_HEADER_INJECTION',
1521
+ category: 'Header Injection',
1522
+ description: 'User input placed directly in HTTP response headers — can enable header injection, response splitting, or cache poisoning.',
1523
+ severity: 'high',
1524
+ fix_suggestion: 'Sanitize and validate user input before setting response headers. Strip newlines (\\r\\n) and validate against expected values.',
1525
+ auto_fixable: false,
1526
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1527
+ skipCommentsAndStrings: true,
1528
+ skipTestFiles: true,
1529
+ detect: (line) => {
1530
+ return /\bres\s*\.\s*(?:setHeader|header|set)\s*\([^,]+,\s*req\s*\.\s*(?:body|query|params|headers)\b/.test(line);
1531
+ },
1532
+ },
1533
+ // ════════════════════════════════════════════
1534
+ // Prototype Pollution via Deep Merge
1535
+ // ════════════════════════════════════════════
1536
+ {
1537
+ id: 'PROTOTYPE_POLLUTION_MERGE',
1538
+ category: 'Prototype Pollution',
1539
+ description: 'Deep merge/extend with user input can lead to prototype pollution — attackers can inject __proto__ or constructor properties.',
1540
+ severity: 'high',
1541
+ fix_suggestion: 'Use a merge library that filters prototype keys (e.g., lodash >= 4.17.21 with safeguards), or validate/strip __proto__ and constructor from user input before merging.',
1542
+ auto_fixable: false,
1543
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1544
+ skipCommentsAndStrings: true,
1545
+ skipTestFiles: true,
1546
+ detect: (line) => {
1547
+ return /\b(?:merge|extend|deepMerge|deepExtend|defaultsDeep)\s*\([^)]*req\s*\.\s*(?:body|query|params)\b/.test(line);
1548
+ },
1549
+ },
1550
+ // ════════════════════════════════════════════
1551
+ // Insecure Random Token Generation
1552
+ // ════════════════════════════════════════════
1553
+ {
1554
+ id: 'INSECURE_RANDOM_TOKEN',
1555
+ category: 'Insecure Cryptography',
1556
+ description: 'Using Date.now(), Math.random(), or UUID v1 (timestamp-based) for security tokens — these are predictable and not cryptographically secure.',
1557
+ severity: 'high',
1558
+ fix_suggestion: 'Use crypto.randomBytes() or crypto.randomUUID() for generating security tokens, session IDs, and nonces.',
1559
+ auto_fixable: false,
1560
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1561
+ skipCommentsAndStrings: true,
1562
+ skipTestFiles: true,
1563
+ detect: (line) => {
1564
+ const lower = line.toLowerCase();
1565
+ const isSecurityContext = lower.includes('token') || lower.includes('session') ||
1566
+ lower.includes('nonce') || lower.includes('csrf') || lower.includes('secret') ||
1567
+ lower.includes('apikey') || lower.includes('api_key');
1568
+ if (!isSecurityContext)
1569
+ return false;
1570
+ return /\bDate\s*\.\s*now\s*\(\s*\)/.test(line) ||
1571
+ /\buuid\s*\.\s*v1\s*\(\s*\)/.test(line) ||
1572
+ /\buuidv1\s*\(\s*\)/.test(line);
1573
+ },
1574
+ },
1575
+ // ════════════════════════════════════════════
1576
+ // Missing CSRF Protection
1577
+ // ════════════════════════════════════════════
1578
+ {
1579
+ id: 'MISSING_CSRF_PROTECTION',
1580
+ category: 'CSRF',
1581
+ description: 'Express app has POST/PUT/DELETE routes but no CSRF protection middleware detected in the file — vulnerable to cross-site request forgery.',
1582
+ severity: 'medium',
1583
+ fix_suggestion: 'Add CSRF protection middleware (e.g., csurf, csrf-csrf, or lusca) to state-changing routes. For APIs using tokens (not cookies), CSRF may not be needed.',
1584
+ auto_fixable: false,
1585
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1586
+ skipCommentsAndStrings: true,
1587
+ skipTestFiles: true,
1588
+ detect: (line, ctx) => {
1589
+ // Only fire on the first POST/PUT/DELETE route definition in the file
1590
+ if (!/\b(?:app|router)\s*\.\s*(?:post|put|delete)\s*\(/.test(line))
1591
+ return false;
1592
+ // Check if any CSRF middleware exists in the file
1593
+ if (/\b(?:csrf|csurf|csrfProtection|lusca|xsrf)\b/i.test(ctx.fileContent))
1594
+ return false;
1595
+ // Don't flag API-only routes that use bearer token auth (not cookie-based)
1596
+ if (/\b(?:bearer|authorization|jwt|api[_-]?key)\b/i.test(ctx.fileContent))
1597
+ return false;
1598
+ return true;
1599
+ },
1600
+ },
1601
+ // ════════════════════════════════════════════
1602
+ // SSRF with Variable URL
1603
+ // ════════════════════════════════════════════
1604
+ {
1605
+ id: 'SSRF_FETCH_VARIABLE',
1606
+ category: 'Server-Side Request Forgery',
1607
+ description: 'HTTP request made with a variable URL that may originate from user input — potential SSRF vector.',
1608
+ severity: 'high',
1609
+ fix_suggestion: 'Validate URLs against an allowlist of permitted domains. Block requests to internal/private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16).',
1610
+ auto_fixable: false,
1611
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1612
+ skipCommentsAndStrings: true,
1613
+ skipTestFiles: true,
1614
+ detect: (line, ctx) => {
1615
+ // Match fetch(url), axios.get(url), got(url) where url is a variable
1616
+ const match = /\b(?:fetch|axios\s*(?:\.\s*(?:get|post|put|patch|delete))?|got(?:\.\s*(?:get|post))?|request)\s*\(\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[,)]/.exec(line);
1617
+ if (!match)
1618
+ return false;
1619
+ const varName = match[1];
1620
+ // Skip if the argument is a string literal, static import, or known-safe
1621
+ if (/^['"`]/.test(varName))
1622
+ return false;
1623
+ // Check if user input feeds this variable in nearby lines
1624
+ const lineIdx = ctx.lineNumber - 1;
1625
+ const windowLines = ctx.allLines
1626
+ .slice(Math.max(0, lineIdx - 10), lineIdx + 1)
1627
+ .join(' ');
1628
+ return /\breq\s*\.\s*(?:body|query|params)\b/.test(windowLines);
1629
+ },
1630
+ },
1631
+ // ════════════════════════════════════════════
1632
+ // Log Injection
1633
+ // ════════════════════════════════════════════
1634
+ {
1635
+ id: 'LOG_INJECTION',
1636
+ category: 'Log Injection',
1637
+ description: 'User input logged directly without sanitization — enables log forging, log injection, and can corrupt log analysis.',
1638
+ severity: 'medium',
1639
+ fix_suggestion: 'Sanitize user input before logging by stripping newlines and control characters, or use structured logging (JSON) to prevent log forging.',
1640
+ auto_fixable: false,
1641
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
1642
+ skipCommentsAndStrings: true,
1643
+ skipTestFiles: true,
1644
+ detect: (line) => {
1645
+ return /\b(?:console\s*\.\s*(?:log|info|warn|error|debug)|logger\s*\.\s*(?:info|warn|error|debug|log))\s*\([^)]*req\s*\.\s*(?:body|query|params|headers)\b/.test(line);
1646
+ },
1647
+ },
1648
+ // ════════════════════════════════════════════
1649
+ // Weak Password Hashing
1650
+ // ════════════════════════════════════════════
1651
+ {
1652
+ id: 'WEAK_PASSWORD_HASH',
1653
+ category: 'Insecure Cryptography',
1654
+ description: 'SHA-256/SHA-512 used for password hashing — fast hash algorithms allow rapid brute-force attacks. Use a slow, salted algorithm instead.',
1655
+ severity: 'critical',
1656
+ fix_suggestion: 'Use bcrypt, argon2, or scrypt for password hashing. These algorithms are intentionally slow and include salting, making brute-force attacks impractical.',
1657
+ auto_fixable: false,
1658
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
1659
+ skipCommentsAndStrings: true,
1660
+ skipTestFiles: true,
1661
+ detect: (line) => {
1662
+ const lower = line.toLowerCase();
1663
+ if (!lower.includes('password') && !lower.includes('passwd'))
1664
+ return false;
1665
+ // Skip if bcrypt/argon2/scrypt is mentioned
1666
+ if (/\b(?:bcrypt|argon2|scrypt|pbkdf2)\b/i.test(line))
1667
+ return false;
1668
+ // JS: createHash('sha256').update(password)
1669
+ // Python: hashlib.sha256(password.encode())
1670
+ return /createHash\s*\(\s*['"]sha(?:256|512)['"]\s*\)/.test(line) ||
1671
+ /\bhashlib\s*\.\s*sha(?:256|512)\s*\(/.test(line);
1672
+ },
1673
+ },
1674
+ // ════════════════════════════════════════════
1675
+ // Python: Insecure Deserialization (marshal, shelve)
1676
+ // ════════════════════════════════════════════
1677
+ {
1678
+ id: 'PYTHON_DESERIALIZE_UNSAFE',
1679
+ category: 'Insecure Deserialization',
1680
+ description: 'marshal.loads() or shelve.open() with untrusted data can execute arbitrary code — similar to pickle.',
1681
+ severity: 'critical',
1682
+ fix_suggestion: 'Avoid marshal and shelve for untrusted data. Use JSON or another safe serialization format.',
1683
+ auto_fixable: false,
1684
+ fileTypes: ['.py'],
1685
+ skipCommentsAndStrings: true,
1686
+ skipTestFiles: true,
1687
+ detect: (line) => {
1688
+ return /\bmarshal\s*\.\s*loads?\s*\(/.test(line) ||
1689
+ /\bshelve\s*\.\s*open\s*\(/.test(line);
1690
+ },
1691
+ },
1692
+ // ════════════════════════════════════════════
1693
+ // Python: exec() / compile() with user input
1694
+ // ════════════════════════════════════════════
1695
+ {
1696
+ id: 'PYTHON_EXEC',
1697
+ category: 'Code Injection',
1698
+ description: 'exec() or compile() executes arbitrary Python code — critical code injection risk if user input reaches these functions.',
1699
+ severity: 'critical',
1700
+ fix_suggestion: 'Avoid exec() and compile() with dynamic input. Use a safe expression evaluator or AST-based approach if dynamic evaluation is truly needed.',
1701
+ auto_fixable: false,
1702
+ fileTypes: ['.py'],
1703
+ skipCommentsAndStrings: true,
1704
+ skipTestFiles: true,
1705
+ detect: (line) => {
1706
+ return /\bexec\s*\(/.test(line) || /\bcompile\s*\([^)]+,\s*[^)]+,\s*['"]exec['"]/.test(line);
1707
+ },
1708
+ },
1709
+ // ════════════════════════════════════════════
1710
+ // Python: SSRF
1711
+ // ════════════════════════════════════════════
1712
+ {
1713
+ id: 'PYTHON_SSRF',
1714
+ category: 'Server-Side Request Forgery',
1715
+ description: 'HTTP request made with a variable URL in Python — potential SSRF if the URL originates from user input.',
1716
+ severity: 'high',
1717
+ fix_suggestion: 'Validate URLs against an allowlist of permitted domains. Block requests to internal/private IP ranges.',
1718
+ auto_fixable: false,
1719
+ fileTypes: ['.py'],
1720
+ skipCommentsAndStrings: true,
1721
+ skipTestFiles: true,
1722
+ detect: (line) => {
1723
+ // requests.get(url), urllib.request.urlopen(url), httpx.get(url)
1724
+ return (/\brequests\s*\.\s*(?:get|post|put|patch|delete|head|options)\s*\(\s*[a-zA-Z_][a-zA-Z0-9_]*\s*[,)]/.test(line) ||
1725
+ /\burlopen\s*\(\s*[a-zA-Z_][a-zA-Z0-9_]*\s*[,)]/.test(line) ||
1726
+ /\bhttpx\s*\.\s*(?:get|post|put|patch|delete)\s*\(\s*[a-zA-Z_][a-zA-Z0-9_]*\s*[,)]/.test(line)) && !/\(\s*['"]/.test(line);
1727
+ },
1728
+ },
1729
+ // ════════════════════════════════════════════
1730
+ // Python: Server-Side Template Injection (SSTI)
1731
+ // ════════════════════════════════════════════
1732
+ {
1733
+ id: 'PYTHON_TEMPLATE_INJECTION',
1734
+ category: 'Template Injection',
1735
+ description: 'render_template_string() or Template() with user input enables server-side template injection — attackers can execute arbitrary code.',
1736
+ severity: 'critical',
1737
+ fix_suggestion: 'Never pass user input to render_template_string() or Template(). Use render_template() with separate template files and pass user data as context variables.',
1738
+ auto_fixable: false,
1739
+ fileTypes: ['.py'],
1740
+ skipCommentsAndStrings: true,
1741
+ skipTestFiles: true,
1742
+ detect: (line) => {
1743
+ // render_template_string(user_input) or Template(user_input)
1744
+ return /\brender_template_string\s*\(\s*[a-zA-Z_]/.test(line) ||
1745
+ /\bTemplate\s*\(\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\)/.test(line) &&
1746
+ !/\bTemplate\s*\(\s*['"]/.test(line);
1747
+ },
1748
+ },
1749
+ // ════════════════════════════════════════════
1750
+ // Python: os.popen() Shell Injection
1751
+ // ════════════════════════════════════════════
1752
+ {
1753
+ id: 'PYTHON_SHELL_INJECTION',
1754
+ category: 'Command Injection',
1755
+ description: 'os.popen() passes commands through the shell — vulnerable to command injection.',
1756
+ severity: 'critical',
1757
+ fix_suggestion: 'Use subprocess.run() with a list of arguments (shell=False) instead of os.popen().',
1758
+ auto_fixable: false,
1759
+ fileTypes: ['.py'],
1760
+ skipCommentsAndStrings: true,
1761
+ skipTestFiles: true,
1762
+ detect: (line) => {
1763
+ return /\bos\s*\.\s*popen\s*\(/.test(line);
1764
+ },
1765
+ },
1766
+ ];
1767
+ // ── File Discovery ──
1768
+ const MAX_FILES = 5_000;
1769
+ /** Detect whether a path looks like a project root (has package.json, .git, etc.) */
1770
+ async function isProjectDirectory(dir) {
1771
+ const markers = ['package.json', '.git', 'Cargo.toml', 'pyproject.toml', 'go.mod', 'Gemfile', 'pom.xml'];
1772
+ for (const marker of markers) {
1773
+ try {
1774
+ await stat(join(dir, marker));
1775
+ return true;
1776
+ }
1777
+ catch {
1778
+ // marker not found
1779
+ }
1780
+ }
1781
+ return false;
1782
+ }
1783
+ async function discoverFiles(targetPath) {
1784
+ const files = [];
1785
+ const resolvedTarget = resolve(targetPath);
1786
+ let hitLimit = false;
1787
+ async function walk(dir) {
1788
+ if (hitLimit)
1789
+ return;
1790
+ let entries;
1791
+ try {
1792
+ entries = await readdir(dir, { withFileTypes: true });
1793
+ }
1794
+ catch {
1795
+ return; // skip unreadable directories
1796
+ }
1797
+ for (const entry of entries) {
1798
+ if (hitLimit)
1799
+ return;
1800
+ if (entry.name.startsWith('.') && entry.name !== '.')
1801
+ continue;
1802
+ if (entry.isDirectory()) {
1803
+ if (IGNORED_DIRS.has(entry.name))
1804
+ continue;
1805
+ await walk(join(dir, entry.name));
1806
+ }
1807
+ else if (entry.isFile()) {
1808
+ const ext = extname(entry.name);
1809
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
1810
+ files.push(join(dir, entry.name));
1811
+ if (files.length >= MAX_FILES) {
1812
+ hitLimit = true;
1813
+ return;
1814
+ }
1815
+ }
1816
+ }
1817
+ }
1818
+ }
1819
+ // Check if targetPath is a file or directory
1820
+ const targetStat = await stat(resolvedTarget);
1821
+ if (targetStat.isFile()) {
1822
+ const ext = extname(resolvedTarget);
1823
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
1824
+ files.push(resolvedTarget);
1825
+ }
1826
+ }
1827
+ else {
1828
+ // Refuse to scan non-project directories (e.g., home directory)
1829
+ const isProject = await isProjectDirectory(resolvedTarget);
1830
+ if (!isProject) {
1831
+ // Check if it's a known non-project path (home dir, root, etc.)
1832
+ const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
1833
+ if (resolvedTarget === homedir || resolvedTarget === '/' || resolvedTarget === '/tmp') {
1834
+ return files; // Return empty — don't scan home/root directories
1835
+ }
1836
+ }
901
1837
  await walk(resolvedTarget);
902
1838
  }
903
1839
  return files;
@@ -982,7 +1918,12 @@ function scanFileContent(filePath, content) {
982
1918
  * @returns Array of findings sorted by severity (critical first).
983
1919
  */
984
1920
  export async function scanPatterns(targetPath, files) {
985
- const filesToScan = files ?? (await discoverFiles(targetPath));
1921
+ const discovered = files ?? (await discoverFiles(targetPath));
1922
+ // Apply .shipsafeignore filter
1923
+ const ignoreFilter = await loadIgnoreFilter(resolve(targetPath));
1924
+ // Apply .gitignore filter — silently skips gitignored files (e.g., .env.local)
1925
+ const gitIgnoreFilter = await loadGitIgnoreFilter(resolve(targetPath));
1926
+ const filesToScan = discovered.filter((f) => !ignoreFilter.isIgnored(f) && !gitIgnoreFilter.isGitIgnored(f));
986
1927
  const allFindings = [];
987
1928
  // Process files in parallel batches for performance
988
1929
  const BATCH_SIZE = 50;