@shipsafe/cli 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/shipsafe.js +2 -0
- package/dist/bin/shipsafe.js.map +1 -1
- package/dist/src/claude-md/manager.d.ts.map +1 -1
- package/dist/src/claude-md/manager.js +2 -34
- package/dist/src/claude-md/manager.js.map +1 -1
- package/dist/src/cli/baseline.d.ts +3 -0
- package/dist/src/cli/baseline.d.ts.map +1 -0
- package/dist/src/cli/baseline.js +67 -0
- package/dist/src/cli/baseline.js.map +1 -0
- package/dist/src/cli/init.d.ts.map +1 -1
- package/dist/src/cli/init.js +1 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/scan.d.ts.map +1 -1
- package/dist/src/cli/scan.js +16 -1
- package/dist/src/cli/scan.js.map +1 -1
- package/dist/src/engines/builtin/baseline.d.ts +41 -0
- package/dist/src/engines/builtin/baseline.d.ts.map +1 -0
- package/dist/src/engines/builtin/baseline.js +83 -0
- package/dist/src/engines/builtin/baseline.js.map +1 -0
- package/dist/src/engines/builtin/gitignore.d.ts +33 -0
- package/dist/src/engines/builtin/gitignore.d.ts.map +1 -0
- package/dist/src/engines/builtin/gitignore.js +83 -0
- package/dist/src/engines/builtin/gitignore.js.map +1 -0
- package/dist/src/engines/builtin/ignore.d.ts +14 -0
- package/dist/src/engines/builtin/ignore.d.ts.map +1 -0
- package/dist/src/engines/builtin/ignore.js +114 -0
- package/dist/src/engines/builtin/ignore.js.map +1 -0
- package/dist/src/engines/builtin/patterns.d.ts.map +1 -1
- package/dist/src/engines/builtin/patterns.js +990 -49
- package/dist/src/engines/builtin/patterns.js.map +1 -1
- package/dist/src/engines/builtin/secrets.d.ts.map +1 -1
- package/dist/src/engines/builtin/secrets.js +50 -7
- package/dist/src/engines/builtin/secrets.js.map +1 -1
- package/dist/src/engines/pattern/index.d.ts.map +1 -1
- package/dist/src/engines/pattern/index.js +26 -9
- package/dist/src/engines/pattern/index.js.map +1 -1
- package/dist/src/mcp/tools/scan.d.ts.map +1 -1
- package/dist/src/mcp/tools/scan.js +11 -0
- package/dist/src/mcp/tools/scan.js.map +1 -1
- package/dist/src/scripts/postinstall.d.ts +10 -0
- package/dist/src/scripts/postinstall.d.ts.map +1 -0
- package/dist/src/scripts/postinstall.js +109 -0
- package/dist/src/scripts/postinstall.js.map +1 -0
- package/dist/src/types.d.ts +6 -0
- package/dist/src/types.d.ts.map +1 -1
- 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
|
|
93
|
-
|
|
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
|
|
108
|
-
|
|
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*
|
|
125
|
-
/\b(?:execute|executemany)\s*\(\s*
|
|
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*
|
|
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
|
-
//
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
|
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;
|