shippingszn 0.1.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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/checks/dangerous.js +58 -0
- package/dist/checks/env.js +59 -0
- package/dist/checks/headers.js +60 -0
- package/dist/checks/helpers.js +155 -0
- package/dist/checks/index.js +34 -0
- package/dist/checks/language.js +275 -0
- package/dist/checks/public-assets.js +54 -0
- package/dist/checks/quality.js +45 -0
- package/dist/checks/secrets.js +271 -0
- package/dist/checks/types.js +1 -0
- package/dist/checks.js +8 -0
- package/dist/index.js +194 -0
- package/dist/items.js +46 -0
- package/dist/scan.js +171 -0
- package/package.json +56 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { isTextFile, readFileSafe } from "../scan.js";
|
|
3
|
+
import { findLine, relPosix } from "./helpers.js";
|
|
4
|
+
const PYTHON_PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
id: "py-pickle-loads",
|
|
7
|
+
regex: /\bpickle\s*\.\s*loads?\s*\(/,
|
|
8
|
+
itemId: "common-attacks",
|
|
9
|
+
severity: "high",
|
|
10
|
+
message: "Use of pickle.loads / pickle.load — deserializing pickle data from untrusted sources allows arbitrary code execution. Use json or another safe format.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "py-subprocess-shell-true",
|
|
14
|
+
regex: /\bshell\s*=\s*True\b/,
|
|
15
|
+
itemId: "common-attacks",
|
|
16
|
+
severity: "high",
|
|
17
|
+
message: "subprocess call with shell=True — if any argument is user-controlled this is a shell injection. Pass an argv list instead.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "py-flask-debug-true",
|
|
21
|
+
regex: /\.run\s*\([^)]*\bdebug\s*=\s*True/,
|
|
22
|
+
itemId: "dev-prod-data",
|
|
23
|
+
severity: "high",
|
|
24
|
+
message: "Flask app.run(debug=True) — Werkzeug's debugger exposes a remote Python shell. Never enable this in production; gate on an env var.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "py-django-debug-true",
|
|
28
|
+
regex: /^\s*DEBUG\s*=\s*True\b/m,
|
|
29
|
+
itemId: "dev-prod-data",
|
|
30
|
+
severity: "high",
|
|
31
|
+
message: "Django DEBUG = True at module scope — leaks stack traces, settings, and SQL to anyone who hits an error page in production. Read it from an env var.",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "py-hardcoded-secret-key",
|
|
35
|
+
regex: /^\s*SECRET_KEY\s*=\s*['"][^'"\n]{8,}['"]/m,
|
|
36
|
+
itemId: "secrets",
|
|
37
|
+
severity: "critical",
|
|
38
|
+
message: "Hardcoded SECRET_KEY in a Django/Flask settings file. Load it from os.environ / os.getenv instead and keep the real value out of source control.",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const RUBY_PATTERNS = [
|
|
42
|
+
{
|
|
43
|
+
id: "rb-eval",
|
|
44
|
+
regex: /(^|[^A-Za-z0-9_])eval\s*\(/,
|
|
45
|
+
itemId: "common-attacks",
|
|
46
|
+
severity: "high",
|
|
47
|
+
message: "Use of eval in Ruby — executes arbitrary code and is almost always avoidable. Replace with safer alternatives like send, public_send, or a hash lookup.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "rb-html-safe",
|
|
51
|
+
regex: /\.html_safe\b/,
|
|
52
|
+
itemId: "common-attacks",
|
|
53
|
+
severity: "medium",
|
|
54
|
+
message: "Call to .html_safe in Ruby/Rails — bypasses ERB's automatic HTML escaping. Make sure the string isn't user-controlled or you'll have an XSS hole.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "rb-hardcoded-secret-key-base",
|
|
58
|
+
regex: /^\s*secret_key_base\s*:\s*['"]?[A-Za-z0-9]{16,}['"]?/m,
|
|
59
|
+
itemId: "secrets",
|
|
60
|
+
severity: "critical",
|
|
61
|
+
message: "Hardcoded secret_key_base in a Rails config/credentials file. Move it to ENV['SECRET_KEY_BASE'] or Rails' encrypted credentials.",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
const GO_PATTERNS = [
|
|
65
|
+
{
|
|
66
|
+
id: "go-listen-and-serve-no-tls",
|
|
67
|
+
regex: /\bhttp\s*\.\s*ListenAndServe\s*\(/,
|
|
68
|
+
itemId: "https-headers",
|
|
69
|
+
severity: "high",
|
|
70
|
+
message: "http.ListenAndServe — serves plain HTTP with no TLS. Use http.ListenAndServeTLS, terminate TLS at a proxy, or run behind a managed host that does.",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "go-hardcoded-token-literal",
|
|
74
|
+
regex: /\b(?:token|apiKey|api_key|secret)\s*(?::=|=)\s*"[A-Za-z0-9_\-]{20,}"/i,
|
|
75
|
+
itemId: "secrets",
|
|
76
|
+
severity: "high",
|
|
77
|
+
message: "Hardcoded token/secret literal in Go source. Read it from os.Getenv or a secret manager instead of compiling it into the binary.",
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
const RAILS_YAML_PATTERNS = [
|
|
81
|
+
RUBY_PATTERNS.find((p) => p.id === "rb-hardcoded-secret-key-base"),
|
|
82
|
+
];
|
|
83
|
+
const LANG_PATTERN_SETS = [
|
|
84
|
+
{ exts: [".py"], patterns: PYTHON_PATTERNS },
|
|
85
|
+
{ exts: [".rb", ".erb"], patterns: RUBY_PATTERNS },
|
|
86
|
+
{ exts: [".yml", ".yaml"], patterns: RAILS_YAML_PATTERNS },
|
|
87
|
+
{ exts: [".go"], patterns: GO_PATTERNS },
|
|
88
|
+
];
|
|
89
|
+
export async function checkLanguagePatterns(ctx) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
for (const file of ctx.files) {
|
|
92
|
+
if (!isTextFile(file))
|
|
93
|
+
continue;
|
|
94
|
+
const ext = path.extname(file.relPath).toLowerCase();
|
|
95
|
+
const set = LANG_PATTERN_SETS.find((s) => s.exts.includes(ext));
|
|
96
|
+
if (!set)
|
|
97
|
+
continue;
|
|
98
|
+
const content = await readFileSafe(file);
|
|
99
|
+
if (!content)
|
|
100
|
+
continue;
|
|
101
|
+
for (const pat of set.patterns) {
|
|
102
|
+
const m = pat.regex.exec(content);
|
|
103
|
+
if (!m)
|
|
104
|
+
continue;
|
|
105
|
+
const line = findLine(content, m.index);
|
|
106
|
+
findings.push({
|
|
107
|
+
checkId: pat.id,
|
|
108
|
+
itemId: pat.itemId,
|
|
109
|
+
severity: pat.severity,
|
|
110
|
+
message: pat.message,
|
|
111
|
+
file: relPosix(file.relPath),
|
|
112
|
+
line,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
118
|
+
function isPythonSettingsLike(relPath, content) {
|
|
119
|
+
const base = path.basename(relPath).toLowerCase();
|
|
120
|
+
if (base === "settings.py" || base === "config.py" || base === "app.py" || base === "wsgi.py" || base === "asgi.py") {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (/\bfrom\s+django\b/.test(content) || /\bimport\s+django\b/.test(content))
|
|
124
|
+
return true;
|
|
125
|
+
if (/\bfrom\s+flask\s+import\b/.test(content) || /\bFlask\s*\(/.test(content))
|
|
126
|
+
return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
function pythonReadsSecretKeyFromEnv(content) {
|
|
130
|
+
const envRefs = [
|
|
131
|
+
/SECRET_KEY\s*=\s*os\.environ(?:\.get)?\b/,
|
|
132
|
+
/SECRET_KEY\s*=\s*os\.getenv\b/,
|
|
133
|
+
/SECRET_KEY\s*=\s*environ(?:\.get)?\b/,
|
|
134
|
+
/SECRET_KEY\s*=\s*getenv\b/,
|
|
135
|
+
/SECRET_KEY\s*=\s*config\s*\(/,
|
|
136
|
+
/SECRET_KEY\s*=\s*decouple\.config\s*\(/,
|
|
137
|
+
/SECRET_KEY\s*=\s*env\s*\(/,
|
|
138
|
+
/SECRET_KEY\s*=\s*env\.str\s*\(/,
|
|
139
|
+
/app\.config\[\s*['"]SECRET_KEY['"]\s*\]\s*=\s*os\.(?:environ|getenv)\b/,
|
|
140
|
+
];
|
|
141
|
+
return envRefs.some((r) => r.test(content));
|
|
142
|
+
}
|
|
143
|
+
export async function checkPythonSecretKeyEnv(ctx) {
|
|
144
|
+
const candidates = [];
|
|
145
|
+
let anyPython = false;
|
|
146
|
+
for (const file of ctx.files) {
|
|
147
|
+
if (path.extname(file.relPath).toLowerCase() !== ".py")
|
|
148
|
+
continue;
|
|
149
|
+
anyPython = true;
|
|
150
|
+
const content = await readFileSafe(file);
|
|
151
|
+
if (!content)
|
|
152
|
+
continue;
|
|
153
|
+
if (isPythonSettingsLike(file.relPath, content)) {
|
|
154
|
+
candidates.push({ file, content });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!anyPython || candidates.length === 0)
|
|
158
|
+
return [];
|
|
159
|
+
let mentionsSecretKey = false;
|
|
160
|
+
let envBacked = false;
|
|
161
|
+
let firstMention = null;
|
|
162
|
+
for (const { file, content } of candidates) {
|
|
163
|
+
const m = /\bSECRET_KEY\b/.exec(content);
|
|
164
|
+
if (m) {
|
|
165
|
+
mentionsSecretKey = true;
|
|
166
|
+
if (!firstMention)
|
|
167
|
+
firstMention = { file, line: findLine(content, m.index) };
|
|
168
|
+
}
|
|
169
|
+
if (pythonReadsSecretKeyFromEnv(content)) {
|
|
170
|
+
envBacked = true;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (envBacked)
|
|
175
|
+
return [];
|
|
176
|
+
if (!mentionsSecretKey) {
|
|
177
|
+
return [
|
|
178
|
+
{
|
|
179
|
+
checkId: "py-missing-secret-key-env",
|
|
180
|
+
itemId: "secrets",
|
|
181
|
+
severity: "high",
|
|
182
|
+
message: "Detected a Django/Flask project but couldn't find SECRET_KEY anywhere in your settings. Configure it from an env var (e.g. os.environ['SECRET_KEY']) before deploying.",
|
|
183
|
+
file: relPosix(candidates[0].file.relPath),
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
checkId: "py-secret-key-not-from-env",
|
|
190
|
+
itemId: "secrets",
|
|
191
|
+
severity: "high",
|
|
192
|
+
message: "Django/Flask SECRET_KEY is set in source but not read from an environment variable. Pull it from os.environ / os.getenv (or python-decouple / django-environ) so the real value stays out of the repo.",
|
|
193
|
+
file: firstMention ? relPosix(firstMention.file.relPath) : undefined,
|
|
194
|
+
line: firstMention?.line,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
function isRailsProject(_ctx, files) {
|
|
199
|
+
for (const f of files) {
|
|
200
|
+
const rel = f.relPath.split(path.sep).join("/");
|
|
201
|
+
if (/(^|\/)config\/application\.rb$/.test(rel))
|
|
202
|
+
return true;
|
|
203
|
+
if (/(^|\/)config\/environments\/[a-z]+\.rb$/.test(rel))
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
async function gemfileMentionsRails(ctx) {
|
|
209
|
+
const gemfile = ctx.files.find((f) => path.basename(f.relPath) === "Gemfile");
|
|
210
|
+
if (!gemfile)
|
|
211
|
+
return false;
|
|
212
|
+
const content = await readFileSafe(gemfile);
|
|
213
|
+
if (!content)
|
|
214
|
+
return false;
|
|
215
|
+
return /\bgem\s+['"]rails['"]/m.test(content);
|
|
216
|
+
}
|
|
217
|
+
export async function checkRubySecretKeyBaseEnv(ctx) {
|
|
218
|
+
const isRails = isRailsProject(ctx, ctx.files) || (await gemfileMentionsRails(ctx));
|
|
219
|
+
if (!isRails)
|
|
220
|
+
return [];
|
|
221
|
+
const configFiles = [];
|
|
222
|
+
for (const file of ctx.files) {
|
|
223
|
+
const rel = file.relPath.split(path.sep).join("/");
|
|
224
|
+
const ext = path.extname(rel).toLowerCase();
|
|
225
|
+
const inConfig = /(^|\/)config\//.test(rel);
|
|
226
|
+
if (!inConfig)
|
|
227
|
+
continue;
|
|
228
|
+
if (![".rb", ".yml", ".yaml"].includes(ext))
|
|
229
|
+
continue;
|
|
230
|
+
const content = await readFileSafe(file);
|
|
231
|
+
if (!content)
|
|
232
|
+
continue;
|
|
233
|
+
configFiles.push({ file, content });
|
|
234
|
+
}
|
|
235
|
+
if (configFiles.length === 0)
|
|
236
|
+
return [];
|
|
237
|
+
let mentionsSecret = false;
|
|
238
|
+
let envBacked = false;
|
|
239
|
+
let firstMention = null;
|
|
240
|
+
for (const { file, content } of configFiles) {
|
|
241
|
+
const m = /\bsecret_key_base\b/.exec(content);
|
|
242
|
+
if (m) {
|
|
243
|
+
mentionsSecret = true;
|
|
244
|
+
if (!firstMention)
|
|
245
|
+
firstMention = { file, line: findLine(content, m.index) };
|
|
246
|
+
}
|
|
247
|
+
if (/ENV\[\s*['"]SECRET_KEY_BASE['"]\s*\]/.test(content) ||
|
|
248
|
+
/ENV\.fetch\(\s*['"]SECRET_KEY_BASE['"]/.test(content) ||
|
|
249
|
+
/Rails\.application\.credentials/.test(content)) {
|
|
250
|
+
envBacked = true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (envBacked)
|
|
254
|
+
return [];
|
|
255
|
+
if (!mentionsSecret) {
|
|
256
|
+
return [
|
|
257
|
+
{
|
|
258
|
+
checkId: "rb-missing-secret-key-base-env",
|
|
259
|
+
itemId: "secrets",
|
|
260
|
+
severity: "high",
|
|
261
|
+
message: "Detected a Rails project but couldn't find secret_key_base wired up to ENV['SECRET_KEY_BASE'] or Rails.application.credentials anywhere in config/. Configure it before deploying.",
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
return [
|
|
266
|
+
{
|
|
267
|
+
checkId: "rb-secret-key-base-not-from-env",
|
|
268
|
+
itemId: "secrets",
|
|
269
|
+
severity: "high",
|
|
270
|
+
message: "Rails secret_key_base is referenced in config/ but not pulled from ENV['SECRET_KEY_BASE'] or Rails.application.credentials. Move the real value out of source.",
|
|
271
|
+
file: firstMention ? relPosix(firstMention.file.relPath) : undefined,
|
|
272
|
+
line: firstMention?.line,
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { findPublicDirs, isAssetEmittedDynamically } from "./helpers.js";
|
|
2
|
+
export async function checkRobotsTxt(ctx) {
|
|
3
|
+
const has = ctx.files.some((f) => /(^|\/)robots\.txt$/i.test(f.relPath));
|
|
4
|
+
if (has)
|
|
5
|
+
return [];
|
|
6
|
+
if (await isAssetEmittedDynamically(ctx, "robots.txt"))
|
|
7
|
+
return [];
|
|
8
|
+
const dirs = await findPublicDirs(ctx);
|
|
9
|
+
if (dirs.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
checkId: "missing-robots-txt",
|
|
14
|
+
itemId: "seo",
|
|
15
|
+
severity: "medium",
|
|
16
|
+
message: `No robots.txt found in any public directory (looked in: ${dirs.join(", ")}). Add one so search engines know what to crawl.`,
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
export async function checkSitemapXml(ctx) {
|
|
21
|
+
const has = ctx.files.some((f) => /(^|\/)sitemap\.xml$/i.test(f.relPath));
|
|
22
|
+
if (has)
|
|
23
|
+
return [];
|
|
24
|
+
if (await isAssetEmittedDynamically(ctx, "sitemap.xml"))
|
|
25
|
+
return [];
|
|
26
|
+
const dirs = await findPublicDirs(ctx);
|
|
27
|
+
if (dirs.length === 0)
|
|
28
|
+
return [];
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
checkId: "missing-sitemap-xml",
|
|
32
|
+
itemId: "seo",
|
|
33
|
+
severity: "medium",
|
|
34
|
+
message: `No sitemap.xml found in any public directory (looked in: ${dirs.join(", ")}). Add one to help search engines index your pages.`,
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
export async function checkFavicon(ctx) {
|
|
39
|
+
const dirs = await findPublicDirs(ctx);
|
|
40
|
+
if (dirs.length === 0)
|
|
41
|
+
return [];
|
|
42
|
+
const faviconRegex = /(^|\/)(favicon\.(ico|png|svg)|apple-touch-icon\.png|icon\.svg)$/i;
|
|
43
|
+
const has = ctx.files.some((f) => faviconRegex.test(f.relPath));
|
|
44
|
+
if (has)
|
|
45
|
+
return [];
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
checkId: "missing-favicon",
|
|
49
|
+
itemId: "launch-polish",
|
|
50
|
+
severity: "lower",
|
|
51
|
+
message: "No custom favicon found in your public directory. The default browser favicon (or the framework starter one) tells visitors this is a vibe-coded project.",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { isTextFile, readFileSafe } from "../scan.js";
|
|
3
|
+
import { findLine, isScanExempt, lineContainsIgnoreMarker, relPosix, } from "./helpers.js";
|
|
4
|
+
const TODO_REGEX = /\b(?:TODO|FIXME|XXX|HACK)\b/;
|
|
5
|
+
const PLACEHOLDER_REGEX = /\b(lorem ipsum|placeholder text|john doe|jane doe|test@example\.com)\b/i;
|
|
6
|
+
export async function checkPlaceholderContent(ctx) {
|
|
7
|
+
const findings = [];
|
|
8
|
+
let todoCount = 0;
|
|
9
|
+
const placeholderHits = [];
|
|
10
|
+
for (const file of ctx.files) {
|
|
11
|
+
if (!isTextFile(file))
|
|
12
|
+
continue;
|
|
13
|
+
if (isScanExempt(file.relPath))
|
|
14
|
+
continue;
|
|
15
|
+
const ext = path.extname(file.relPath).toLowerCase();
|
|
16
|
+
if (![".ts", ".tsx", ".js", ".jsx", ".html", ".md", ".mdx"].includes(ext))
|
|
17
|
+
continue;
|
|
18
|
+
const content = await readFileSafe(file);
|
|
19
|
+
if (!content)
|
|
20
|
+
continue;
|
|
21
|
+
const todoMatch = TODO_REGEX.exec(content);
|
|
22
|
+
if (todoMatch && !lineContainsIgnoreMarker(content, todoMatch.index))
|
|
23
|
+
todoCount++;
|
|
24
|
+
const phMatch = PLACEHOLDER_REGEX.exec(content);
|
|
25
|
+
if (phMatch && !lineContainsIgnoreMarker(content, phMatch.index)) {
|
|
26
|
+
placeholderHits.push({
|
|
27
|
+
checkId: "placeholder-content",
|
|
28
|
+
itemId: "ai-audit",
|
|
29
|
+
severity: "medium",
|
|
30
|
+
message: `Placeholder content "${phMatch[0]}" found — make sure it isn't shown to real users.`,
|
|
31
|
+
file: relPosix(file.relPath),
|
|
32
|
+
line: findLine(content, phMatch.index),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (todoCount > 0) {
|
|
37
|
+
findings.push({
|
|
38
|
+
checkId: "todo-comments",
|
|
39
|
+
itemId: "ai-audit",
|
|
40
|
+
severity: "lower",
|
|
41
|
+
message: `Found ${todoCount} file(s) with TODO/FIXME/XXX/HACK comments. Walk through them before launch and decide which are real work.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return findings.concat(placeholderHits.slice(0, 25));
|
|
45
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileExists, isTextFile, readFileSafe } from "../scan.js";
|
|
3
|
+
import { findLine, isScanExempt, relPosix } from "./helpers.js";
|
|
4
|
+
const SECRET_PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
id: "openai-key",
|
|
7
|
+
name: "OpenAI API key",
|
|
8
|
+
regex: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/,
|
|
9
|
+
severity: "critical",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "anthropic-key",
|
|
13
|
+
name: "Anthropic API key",
|
|
14
|
+
regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
|
|
15
|
+
severity: "critical",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "stripe-live-secret",
|
|
19
|
+
name: "Stripe live secret key",
|
|
20
|
+
regex: /\bsk_live_[A-Za-z0-9]{16,}\b/,
|
|
21
|
+
severity: "critical",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "stripe-live-publishable",
|
|
25
|
+
name: "Stripe live publishable key",
|
|
26
|
+
regex: /\bpk_live_[A-Za-z0-9]{16,}\b/,
|
|
27
|
+
severity: "high",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "aws-access-key",
|
|
31
|
+
name: "AWS access key id",
|
|
32
|
+
regex: /\bAKIA[0-9A-Z]{16}\b/,
|
|
33
|
+
severity: "critical",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "google-api-key",
|
|
37
|
+
name: "Google API key",
|
|
38
|
+
regex: /\bAIza[0-9A-Za-z_-]{35}\b/,
|
|
39
|
+
severity: "critical",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "github-token",
|
|
43
|
+
name: "GitHub personal access token",
|
|
44
|
+
regex: /\bghp_[A-Za-z0-9]{30,}\b/,
|
|
45
|
+
severity: "critical",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "slack-token",
|
|
49
|
+
name: "Slack token",
|
|
50
|
+
regex: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/,
|
|
51
|
+
severity: "high",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "private-key-block",
|
|
55
|
+
name: "Private key block",
|
|
56
|
+
regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
|
|
57
|
+
severity: "critical",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "notion-token",
|
|
61
|
+
name: "Notion integration token",
|
|
62
|
+
regex: /\bsecret_[A-Za-z0-9]{43}\b/,
|
|
63
|
+
severity: "critical",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "vercel-token",
|
|
67
|
+
name: "Vercel token",
|
|
68
|
+
regex: /\b(?:VERCEL_TOKEN|vercel_token|vercelToken)\b\s*[:=]\s*['"]?[A-Za-z0-9]{24}['"]?/,
|
|
69
|
+
severity: "critical",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "sendgrid-api-key",
|
|
73
|
+
name: "SendGrid API key",
|
|
74
|
+
regex: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/,
|
|
75
|
+
severity: "critical",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "twilio-account-sid",
|
|
79
|
+
name: "Twilio Account SID",
|
|
80
|
+
regex: /\bAC[a-f0-9]{32}\b/,
|
|
81
|
+
severity: "high",
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
const JWT_REGEX = /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
|
|
85
|
+
function tryDecodeJwtPayload(token) {
|
|
86
|
+
const parts = token.split(".");
|
|
87
|
+
if (parts.length !== 3)
|
|
88
|
+
return null;
|
|
89
|
+
let b = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
90
|
+
while (b.length % 4 !== 0)
|
|
91
|
+
b += "=";
|
|
92
|
+
try {
|
|
93
|
+
return Buffer.from(b, "base64").toString("utf8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const SECRET_SCAN_SKIP_NAMES = new Set([
|
|
100
|
+
"package-lock.json",
|
|
101
|
+
"pnpm-lock.yaml",
|
|
102
|
+
"yarn.lock",
|
|
103
|
+
"bun.lockb",
|
|
104
|
+
]);
|
|
105
|
+
export async function checkHardcodedSecrets(ctx) {
|
|
106
|
+
const findings = [];
|
|
107
|
+
for (const file of ctx.files) {
|
|
108
|
+
const base = path.basename(file.relPath);
|
|
109
|
+
if (SECRET_SCAN_SKIP_NAMES.has(base))
|
|
110
|
+
continue;
|
|
111
|
+
if (!isTextFile(file))
|
|
112
|
+
continue;
|
|
113
|
+
if (isScanExempt(file.relPath))
|
|
114
|
+
continue;
|
|
115
|
+
const content = await readFileSafe(file);
|
|
116
|
+
if (!content)
|
|
117
|
+
continue;
|
|
118
|
+
const isEnvExample = /\.example$|\.sample$|\.template$/i.test(base) || base === ".env.example";
|
|
119
|
+
if (isEnvExample)
|
|
120
|
+
continue;
|
|
121
|
+
const matchedRanges = [];
|
|
122
|
+
for (const pat of SECRET_PATTERNS) {
|
|
123
|
+
const m = pat.regex.exec(content);
|
|
124
|
+
if (!m)
|
|
125
|
+
continue;
|
|
126
|
+
const start = m.index;
|
|
127
|
+
const end = m.index + m[0].length;
|
|
128
|
+
if (matchedRanges.some(([s, e]) => start < e && end > s))
|
|
129
|
+
continue;
|
|
130
|
+
matchedRanges.push([start, end]);
|
|
131
|
+
const line = findLine(content, start);
|
|
132
|
+
findings.push({
|
|
133
|
+
checkId: `secret-${pat.id}`,
|
|
134
|
+
itemId: "secrets",
|
|
135
|
+
severity: pat.severity,
|
|
136
|
+
message: `Possible ${pat.name} hardcoded in source.`,
|
|
137
|
+
file: relPosix(file.relPath),
|
|
138
|
+
line,
|
|
139
|
+
evidence: `${m[0].slice(0, 6)}…${m[0].slice(-4)} (${m[0].length} chars)`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
JWT_REGEX.lastIndex = 0;
|
|
143
|
+
let jm;
|
|
144
|
+
while ((jm = JWT_REGEX.exec(content)) !== null) {
|
|
145
|
+
const start = jm.index;
|
|
146
|
+
const end = jm.index + jm[0].length;
|
|
147
|
+
if (matchedRanges.some(([s, e]) => start < e && end > s))
|
|
148
|
+
continue;
|
|
149
|
+
matchedRanges.push([start, end]);
|
|
150
|
+
const payload = tryDecodeJwtPayload(jm[0]);
|
|
151
|
+
const isServiceRole = !!payload && /"role"\s*:\s*"service_role"/.test(payload);
|
|
152
|
+
const line = findLine(content, start);
|
|
153
|
+
if (isServiceRole) {
|
|
154
|
+
findings.push({
|
|
155
|
+
checkId: "secret-supabase-service-role-jwt",
|
|
156
|
+
itemId: "secrets",
|
|
157
|
+
severity: "critical",
|
|
158
|
+
message: "Possible Supabase service-role JWT hardcoded in source.",
|
|
159
|
+
file: relPosix(file.relPath),
|
|
160
|
+
line,
|
|
161
|
+
evidence: `${jm[0].slice(0, 6)}…${jm[0].slice(-4)} (${jm[0].length} chars)`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
findings.push({
|
|
166
|
+
checkId: "secret-jwt",
|
|
167
|
+
itemId: "secrets",
|
|
168
|
+
severity: "high",
|
|
169
|
+
message: "Possible JWT hardcoded in source.",
|
|
170
|
+
file: relPosix(file.relPath),
|
|
171
|
+
line,
|
|
172
|
+
evidence: `${jm[0].slice(0, 6)}…${jm[0].slice(-4)} (${jm[0].length} chars)`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return findings;
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Config-file secret leaks
|
|
182
|
+
//
|
|
183
|
+
// Catches the class of bug where a credential-shaped value gets hardcoded into
|
|
184
|
+
// a tracked config file (.env, *.toml) under an env-var assignment, and the
|
|
185
|
+
// related anti-pattern of naming a browser-exposed VITE_ variable like a
|
|
186
|
+
// secret (VITE_*_SECRET / TOKEN / KEY / PASSWORD). Anything with the VITE_
|
|
187
|
+
// prefix is baked into the client bundle by Vite at build time and is
|
|
188
|
+
// readable by every visitor — naming it like a secret gives false comfort.
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
const CONFIG_FILE_BASENAMES = new Set([
|
|
191
|
+
".env",
|
|
192
|
+
".env.local",
|
|
193
|
+
".env.production",
|
|
194
|
+
]);
|
|
195
|
+
const CONFIG_FILE_EXTENSIONS = new Set([".toml"]);
|
|
196
|
+
function looksLikeConfigFile(relPath) {
|
|
197
|
+
const base = path.basename(relPath);
|
|
198
|
+
if (CONFIG_FILE_BASENAMES.has(base))
|
|
199
|
+
return true;
|
|
200
|
+
const ext = path.extname(base).toLowerCase();
|
|
201
|
+
if (CONFIG_FILE_EXTENSIONS.has(ext))
|
|
202
|
+
return true;
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const CONFIG_ASSIGNMENT_REGEX = /^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:"([^"\n]+)"|'([^'\n]+)'|([^\s#"'][^\s#]*))/gm;
|
|
206
|
+
const VITE_SECRET_KEY_REGEX = /^VITE_[A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL|PRIVATE)$/;
|
|
207
|
+
const HEX_SECRET_REGEX = /^[A-Fa-f0-9]{32,}$/;
|
|
208
|
+
const BASE64_SECRET_REGEX = /^[A-Za-z0-9+/_-]{40,}={0,2}$/;
|
|
209
|
+
const TEMPLATED_VALUE_REGEX = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/;
|
|
210
|
+
const CONFIG_KEY_ALLOWLIST = new Set([
|
|
211
|
+
"DATABASE_URL", // contains URL/host fragments, handled by other rules
|
|
212
|
+
]);
|
|
213
|
+
export async function checkConfigSecretLeaks(ctx) {
|
|
214
|
+
const findings = [];
|
|
215
|
+
for (const file of ctx.files) {
|
|
216
|
+
if (!looksLikeConfigFile(file.relPath))
|
|
217
|
+
continue;
|
|
218
|
+
if (!isTextFile(file))
|
|
219
|
+
continue;
|
|
220
|
+
const base = path.basename(file.relPath);
|
|
221
|
+
if (/\.example$|\.sample$|\.template$/i.test(base))
|
|
222
|
+
continue;
|
|
223
|
+
const content = await readFileSafe(file);
|
|
224
|
+
if (!content)
|
|
225
|
+
continue;
|
|
226
|
+
CONFIG_ASSIGNMENT_REGEX.lastIndex = 0;
|
|
227
|
+
let m;
|
|
228
|
+
while ((m = CONFIG_ASSIGNMENT_REGEX.exec(content)) !== null) {
|
|
229
|
+
const key = m[1];
|
|
230
|
+
const value = (m[2] ?? m[3] ?? m[4] ?? "").trim();
|
|
231
|
+
if (!value)
|
|
232
|
+
continue;
|
|
233
|
+
if (TEMPLATED_VALUE_REGEX.test(value))
|
|
234
|
+
continue;
|
|
235
|
+
const line = findLine(content, m.index);
|
|
236
|
+
// 1. Browser-exposed VITE_ variable named like a secret. Severity: high
|
|
237
|
+
// regardless of value, because the *name* itself is the bug.
|
|
238
|
+
if (VITE_SECRET_KEY_REGEX.test(key)) {
|
|
239
|
+
findings.push({
|
|
240
|
+
checkId: "config-vite-prefixed-secret",
|
|
241
|
+
itemId: "secrets",
|
|
242
|
+
severity: "high",
|
|
243
|
+
message: `${key} is set in ${relPosix(file.relPath)}. Variables prefixed with VITE_ are baked into the browser bundle by Vite at build time and are readable by every visitor — they are not secrets. Rename the variable (drop VITE_) and access it server-side only, or move the signing/auth flow behind a backend endpoint.`,
|
|
244
|
+
file: relPosix(file.relPath),
|
|
245
|
+
line,
|
|
246
|
+
evidence: `${key}=${value.slice(0, 4)}…${value.slice(-2)} (${value.length} chars)`,
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// 2. Anything else: high-entropy hardcoded credential value.
|
|
251
|
+
if (CONFIG_KEY_ALLOWLIST.has(key))
|
|
252
|
+
continue;
|
|
253
|
+
const isHex = HEX_SECRET_REGEX.test(value);
|
|
254
|
+
const isBase64 = BASE64_SECRET_REGEX.test(value);
|
|
255
|
+
if (!isHex && !isBase64)
|
|
256
|
+
continue;
|
|
257
|
+
findings.push({
|
|
258
|
+
checkId: "config-hardcoded-credential",
|
|
259
|
+
itemId: "secrets",
|
|
260
|
+
severity: "critical",
|
|
261
|
+
message: `${key} in ${relPosix(file.relPath)} looks like a hardcoded credential (${value.length}-char ${isHex ? "hex" : "base64-shaped"} value). Move it to your platform's secret store (Railway / Vercel / Fly env vars, or a dedicated vault) and reference it from there. Rotate the leaked value at the source.`,
|
|
262
|
+
file: relPosix(file.relPath),
|
|
263
|
+
line,
|
|
264
|
+
evidence: `${key}=${value.slice(0, 4)}…${value.slice(-2)} (${value.length} chars)`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return findings;
|
|
269
|
+
}
|
|
270
|
+
// fileExists is referenced indirectly only via env.ts; re-exported for backwards compat
|
|
271
|
+
export { fileExists };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backwards-compatible barrel for the scanner checks.
|
|
3
|
+
*
|
|
4
|
+
* The implementation lives in `./checks/`, split per domain (secrets, env,
|
|
5
|
+
* headers, dangerous patterns, language patterns, public assets, content
|
|
6
|
+
* quality). Importers should prefer `./checks/index` directly going forward.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./checks/index.js";
|