@uniqweb/vibe-it-securely 0.0.0-stage → 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 +92 -2
- package/bin/cli.js +86 -0
- package/package.json +34 -4
- package/src/check.js +503 -0
- package/src/deps.js +105 -0
- package/src/detect.js +41 -0
- package/src/init.js +55 -0
- package/src/report.js +59 -0
- package/src/safe-write.js +27 -0
- package/src/templates.js +244 -0
- package/src/vibe.js +142 -0
package/src/check.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { detectStack } from "./detect.js";
|
|
5
|
+
import { lineSecretRule, checkRoutes, analyzeSql, detectSupabase, PUBLIC_ENV } from "./vibe.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every rule carries its own plain-language explanation, so findings are
|
|
9
|
+
* understandable without looking anything up.
|
|
10
|
+
* severity: "high" = fix now, "medium" = should fix, "info" = suggestion
|
|
11
|
+
*/
|
|
12
|
+
export const RULES = {
|
|
13
|
+
// ---------- Secrets ----------
|
|
14
|
+
"secret-private-key": {
|
|
15
|
+
severity: "high",
|
|
16
|
+
title: "A private key is stored in a project file",
|
|
17
|
+
why: "Anyone who can read this code (or the git history) can impersonate you or your server.",
|
|
18
|
+
fix: "Remove the file from the project, add it to .gitignore, and generate a new key. Treat the old key as stolen.",
|
|
19
|
+
},
|
|
20
|
+
"secret-aws": {
|
|
21
|
+
severity: "high",
|
|
22
|
+
title: "Looks like an AWS access key",
|
|
23
|
+
why: "Leaked cloud keys are found by bots within minutes and used to run up bills or steal data.",
|
|
24
|
+
fix: "Revoke the key in your AWS console now, create a new one, and load it from an environment variable.",
|
|
25
|
+
},
|
|
26
|
+
"secret-github": {
|
|
27
|
+
severity: "high",
|
|
28
|
+
title: "Looks like a GitHub access token",
|
|
29
|
+
why: "Anyone with this token can read or change your repositories.",
|
|
30
|
+
fix: "Revoke the token in GitHub settings, create a new one, and load it from an environment variable.",
|
|
31
|
+
},
|
|
32
|
+
"secret-slack": {
|
|
33
|
+
severity: "high",
|
|
34
|
+
title: "Looks like a Slack token",
|
|
35
|
+
why: "Anyone with this token can read or post in your Slack workspace.",
|
|
36
|
+
fix: "Revoke the token in Slack, create a new one, and load it from an environment variable.",
|
|
37
|
+
},
|
|
38
|
+
"secret-ai-key": {
|
|
39
|
+
severity: "high",
|
|
40
|
+
title: "Looks like an AI provider API key (OpenAI, Anthropic, etc.)",
|
|
41
|
+
why: "Leaked AI keys get abused quickly and you pay for whatever others run with them.",
|
|
42
|
+
fix: "Revoke the key in the provider dashboard, create a new one, and load it from an environment variable.",
|
|
43
|
+
},
|
|
44
|
+
"secret-generic": {
|
|
45
|
+
severity: "medium",
|
|
46
|
+
title: "A password, token or key may be written directly in the code",
|
|
47
|
+
why: "Secrets in code end up in git history and are visible to anyone with access to the repository.",
|
|
48
|
+
fix: "Move the value to an environment variable (e.g. process.env.X or os.environ[\"X\"]). If it was real, change it. If it is a harmless example, add the comment vibe-it-securely-ignore on that line.",
|
|
49
|
+
},
|
|
50
|
+
"env-committed": {
|
|
51
|
+
severity: "high",
|
|
52
|
+
title: "A .env file is tracked by git",
|
|
53
|
+
why: ".env files usually hold real passwords and keys. Once committed, they stay in history even if you delete the file later.",
|
|
54
|
+
fix: "Run: git rm --cached .env, add .env to .gitignore, and change every secret that was in the file.",
|
|
55
|
+
},
|
|
56
|
+
"env-not-ignored": {
|
|
57
|
+
severity: "medium",
|
|
58
|
+
title: ".gitignore does not exclude .env files",
|
|
59
|
+
why: "The next git add . could publish your secrets.",
|
|
60
|
+
fix: "Add these lines to .gitignore: .env and .env.* (and !.env.example to keep the template).",
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// ---------- Risky code: JavaScript / TypeScript ----------
|
|
64
|
+
"js-eval": {
|
|
65
|
+
severity: "medium",
|
|
66
|
+
title: "eval() runs text as code",
|
|
67
|
+
why: "If any part of that text comes from a user, they can run their own code on your app.",
|
|
68
|
+
fix: "Replace eval with JSON.parse for data, or a lookup table / function for logic.",
|
|
69
|
+
},
|
|
70
|
+
"js-new-function": {
|
|
71
|
+
severity: "medium",
|
|
72
|
+
title: "new Function() builds code from text",
|
|
73
|
+
why: "It has the same risk as eval: user-controlled text becomes running code.",
|
|
74
|
+
fix: "Use normal functions or a lookup table instead.",
|
|
75
|
+
},
|
|
76
|
+
"js-inner-html": {
|
|
77
|
+
severity: "medium",
|
|
78
|
+
title: "Raw HTML is inserted into the page",
|
|
79
|
+
why: "If the HTML contains user input, attackers can inject scripts (cross-site scripting) and steal sessions.",
|
|
80
|
+
fix: "Use textContent or normal React rendering. If you truly need HTML, sanitize it first (e.g. DOMPurify).",
|
|
81
|
+
},
|
|
82
|
+
"js-exec-template": {
|
|
83
|
+
severity: "medium",
|
|
84
|
+
title: "A shell command is built from a text template",
|
|
85
|
+
why: "If user input reaches the command, an attacker can run any command on your server.",
|
|
86
|
+
fix: "Use execFile or spawn with an argument array instead of one command string.",
|
|
87
|
+
},
|
|
88
|
+
"js-tls-off": {
|
|
89
|
+
severity: "high",
|
|
90
|
+
title: "HTTPS certificate checking is turned off",
|
|
91
|
+
why: "Without certificate checks, anyone on the network path can read or change traffic.",
|
|
92
|
+
fix: "Remove this setting. If you hit a certificate error, fix the certificate rather than disabling the check.",
|
|
93
|
+
},
|
|
94
|
+
"js-cors-wildcard": {
|
|
95
|
+
severity: "medium",
|
|
96
|
+
title: "CORS allows every website (origin: \"*\")",
|
|
97
|
+
why: "Any website can call your API from a visitor's browser, which is dangerous if the API uses cookies or private data.",
|
|
98
|
+
fix: "List your real front-end addresses explicitly, e.g. origin: [\"https://myapp.com\"].",
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// ---------- Risky code: both ----------
|
|
102
|
+
"sql-concat": {
|
|
103
|
+
severity: "medium",
|
|
104
|
+
title: "A database query is built by joining text",
|
|
105
|
+
why: "If user input ends up in the query, attackers can read or delete your data (SQL injection).",
|
|
106
|
+
fix: "Use parameterized queries (placeholders like ? or $1 / %s) or an ORM, and pass values separately.",
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// ---------- Risky code: Python ----------
|
|
110
|
+
"py-eval-exec": {
|
|
111
|
+
severity: "medium",
|
|
112
|
+
title: "eval() / exec() runs text as code",
|
|
113
|
+
why: "If any part of that text comes from a user, they can run their own code on your app.",
|
|
114
|
+
fix: "Use ast.literal_eval for simple data, json.loads for JSON, or explicit logic.",
|
|
115
|
+
},
|
|
116
|
+
"py-shell-true": {
|
|
117
|
+
severity: "medium",
|
|
118
|
+
title: "subprocess is run through the shell (shell=True)",
|
|
119
|
+
why: "If user input reaches the command, an attacker can run any command on your server.",
|
|
120
|
+
fix: "Pass the command as a list, e.g. subprocess.run([\"ls\", path]), without shell=True.",
|
|
121
|
+
},
|
|
122
|
+
"py-yaml-load": {
|
|
123
|
+
severity: "medium",
|
|
124
|
+
title: "yaml.load() can execute code from the YAML file",
|
|
125
|
+
why: "A crafted YAML file can run arbitrary code when loaded this way.",
|
|
126
|
+
fix: "Use yaml.safe_load instead.",
|
|
127
|
+
},
|
|
128
|
+
"py-pickle": {
|
|
129
|
+
severity: "medium",
|
|
130
|
+
title: "pickle can execute code when loading data",
|
|
131
|
+
why: "Loading a pickle from an untrusted source lets the sender run code on your machine.",
|
|
132
|
+
fix: "Use JSON (or another plain data format) for anything that comes from outside your app.",
|
|
133
|
+
},
|
|
134
|
+
"py-verify-false": {
|
|
135
|
+
severity: "high",
|
|
136
|
+
title: "HTTPS certificate checking is turned off (verify=False)",
|
|
137
|
+
why: "Without certificate checks, anyone on the network path can read or change traffic.",
|
|
138
|
+
fix: "Remove verify=False. If you hit a certificate error, fix the certificate rather than disabling the check.",
|
|
139
|
+
},
|
|
140
|
+
"py-debug-true": {
|
|
141
|
+
severity: "medium",
|
|
142
|
+
title: "Debug mode is switched on",
|
|
143
|
+
why: "Debug mode can expose source code, settings and an interactive console to anyone who triggers an error.",
|
|
144
|
+
fix: "Read it from an environment variable and keep it False in production.",
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
"public-env-secret": {
|
|
148
|
+
severity: "high",
|
|
149
|
+
title: "A secret is exposed to the browser through a public environment variable",
|
|
150
|
+
why: "Variables starting with NEXT_PUBLIC_, VITE_, REACT_APP_ and similar are bundled into the website code that every visitor can read. A secret placed there is public.",
|
|
151
|
+
fix: "Remove the public prefix and use the value only on the server. Keep only values that are meant to be public (like a publishable key) in public variables, and replace the leaked secret.",
|
|
152
|
+
},
|
|
153
|
+
"supabase-service-key": {
|
|
154
|
+
severity: "high",
|
|
155
|
+
title: "A Supabase service_role key is in the code",
|
|
156
|
+
why: "The service_role key bypasses every database security rule. Anyone who finds it can read, change or delete all of your data.",
|
|
157
|
+
fix: "Rotate the key in your Supabase dashboard now, then use it only on the server through an environment variable. Browser code should use the anon key together with row level security.",
|
|
158
|
+
},
|
|
159
|
+
"js-route-no-auth": {
|
|
160
|
+
severity: "medium",
|
|
161
|
+
title: "A route that changes data shows no sign of a login check",
|
|
162
|
+
why: "Without a check, anyone on the internet who finds the address can create, change or delete data. AI tools often generate routes like this and forget the login check.",
|
|
163
|
+
fix: "Check who the user is before doing the work, for example with auth middleware or a session check, and return 401 if they are not logged in. If the route is meant to be public, add the comment vibe-it-securely-ignore on its line.",
|
|
164
|
+
},
|
|
165
|
+
"sql-rls-disabled": {
|
|
166
|
+
severity: "medium",
|
|
167
|
+
title: "Row level security is switched off for a table",
|
|
168
|
+
why: "In Supabase, tables in the public schema are reachable from the browser with the public key. Without row level security, any visitor can read or modify every row.",
|
|
169
|
+
fix: "Remove the DISABLE statement, run ALTER TABLE <name> ENABLE ROW LEVEL SECURITY, and add policies that say who may read and write which rows.",
|
|
170
|
+
},
|
|
171
|
+
"sql-rls-missing": {
|
|
172
|
+
severity: "medium",
|
|
173
|
+
title: "A table is created but row level security is never enabled for it",
|
|
174
|
+
why: "In Supabase, a table in the public schema without row level security can be read and changed by anyone who has your public key.",
|
|
175
|
+
fix: "Add ALTER TABLE <name> ENABLE ROW LEVEL SECURITY and policies for who may read and write which rows.",
|
|
176
|
+
},
|
|
177
|
+
"dep-not-found": {
|
|
178
|
+
severity: "high",
|
|
179
|
+
title: "A dependency does not exist on the public package registry",
|
|
180
|
+
why: "AI tools sometimes invent package names. If an attacker registers that name, installing your project would run their code. A misspelled name is just as dangerous.",
|
|
181
|
+
fix: "Check the spelling. If the AI made the name up, remove it and find the real package that does the job.",
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// ---------- Docker ----------
|
|
185
|
+
"docker-root": {
|
|
186
|
+
severity: "medium",
|
|
187
|
+
title: "Container runs as root",
|
|
188
|
+
why: "If an attacker gets into the app, they start with full control inside the container.",
|
|
189
|
+
fix: "Add a USER line near the end of the Dockerfile that switches to a non-root user.",
|
|
190
|
+
},
|
|
191
|
+
"docker-latest": {
|
|
192
|
+
severity: "medium",
|
|
193
|
+
title: "Base image is not pinned to a version",
|
|
194
|
+
why: "Using :latest (or no tag) means builds can silently change and pick up breaking or vulnerable versions.",
|
|
195
|
+
fix: "Pin a specific version, e.g. node:22-slim, and ideally its digest.",
|
|
196
|
+
},
|
|
197
|
+
"docker-copy-env": {
|
|
198
|
+
severity: "high",
|
|
199
|
+
title: "The Dockerfile copies a .env file into the image",
|
|
200
|
+
why: "Anyone who can pull the image can read the secrets inside it.",
|
|
201
|
+
fix: "Remove that line and pass settings as environment variables when the container starts.",
|
|
202
|
+
},
|
|
203
|
+
"docker-add-url": {
|
|
204
|
+
severity: "medium",
|
|
205
|
+
title: "ADD downloads a file from the internet during the build",
|
|
206
|
+
why: "The download is not verified, so a tampered file would end up in your image.",
|
|
207
|
+
fix: "Use curl or wget in a RUN step and verify a checksum, or copy a file you already trust.",
|
|
208
|
+
},
|
|
209
|
+
"docker-secret-env": {
|
|
210
|
+
severity: "high",
|
|
211
|
+
title: "A secret is set inside the Dockerfile (ENV/ARG)",
|
|
212
|
+
why: "Values set in a Dockerfile are saved in the image history where anyone with the image can read them.",
|
|
213
|
+
fix: "Remove the value and supply it at runtime as an environment variable or a secret.",
|
|
214
|
+
},
|
|
215
|
+
"dockerignore-missing": {
|
|
216
|
+
severity: "medium",
|
|
217
|
+
title: "No .dockerignore file",
|
|
218
|
+
why: "Without it, files like .env and .git can be copied into your image by COPY . .",
|
|
219
|
+
fix: "Create a .dockerignore listing .git, .env, node_modules and similar. Running vibe-it-securely init generates one.",
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
// ---------- Project hygiene ----------
|
|
223
|
+
"precommit-missing": {
|
|
224
|
+
severity: "medium",
|
|
225
|
+
title: "No pre-commit hooks set up",
|
|
226
|
+
why: "Nothing stops a secret from being committed before it is too late.",
|
|
227
|
+
fix: "Run vibe-it-securely init, then: pip install pre-commit && pre-commit install",
|
|
228
|
+
},
|
|
229
|
+
"hook-not-installed": {
|
|
230
|
+
severity: "medium",
|
|
231
|
+
title: "Pre-commit config exists but the hook is not installed",
|
|
232
|
+
why: "The checks are configured but never run when you commit.",
|
|
233
|
+
fix: "Run: pre-commit install",
|
|
234
|
+
},
|
|
235
|
+
"ci-missing": {
|
|
236
|
+
severity: "medium",
|
|
237
|
+
title: "No automated checks on your repository (CI)",
|
|
238
|
+
why: "Local hooks can be skipped with --no-verify. CI is the safety net that cannot be skipped.",
|
|
239
|
+
fix: "Run vibe-it-securely init to generate a GitHub Actions workflow.",
|
|
240
|
+
},
|
|
241
|
+
"lockfile-missing": {
|
|
242
|
+
severity: "medium",
|
|
243
|
+
title: "No lockfile for your JavaScript dependencies",
|
|
244
|
+
why: "Without a lockfile, installs can pull different versions each time, and dependency audits are less accurate.",
|
|
245
|
+
fix: "Run npm install (or pnpm/yarn install) once and commit the generated lockfile.",
|
|
246
|
+
},
|
|
247
|
+
"agent-rules-missing": {
|
|
248
|
+
severity: "info",
|
|
249
|
+
title: "No security rules for your AI coding assistant",
|
|
250
|
+
why: "AI assistants follow written project rules. Without them they may write insecure code by default.",
|
|
251
|
+
fix: "Run vibe-it-securely init to generate AGENTS.md (copy it to CLAUDE.md or .cursorrules if your tool needs that name).",
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const IGNORE_MARK = "vibe-it-securely-ignore";
|
|
256
|
+
const SKIP_DIRS = new Set([
|
|
257
|
+
".git", "node_modules", ".venv", "venv", "env", "__pycache__", "dist", "build",
|
|
258
|
+
".next", ".nuxt", "coverage", ".tox", ".mypy_cache", ".pytest_cache", "vendor", ".idea", ".vscode",
|
|
259
|
+
]);
|
|
260
|
+
const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|poetry\.lock|Pipfile\.lock|uv\.lock)$/;
|
|
261
|
+
const MAX_FILES = 5000;
|
|
262
|
+
const MAX_BYTES = 512 * 1024;
|
|
263
|
+
|
|
264
|
+
const SECRET_PATTERNS = [
|
|
265
|
+
["secret-private-key", /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/],
|
|
266
|
+
["secret-aws", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
267
|
+
["secret-github", /\bgh[pousr]_[A-Za-z0-9]{36,}\b/],
|
|
268
|
+
["secret-slack", /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/],
|
|
269
|
+
["secret-ai-key", /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{32,}\b/],
|
|
270
|
+
];
|
|
271
|
+
const GENERIC_SECRET = /\b(?:api[_-]?key|secret[_-]?key|secret|password|passwd|token|auth[_-]?token)\b["']?\s*[:=]\s*["'`]([^"'`\s]{12,})["'`]/i;
|
|
272
|
+
const PLACEHOLDER = /(your|example|change.?me|placeholder|dummy|sample|xxxx|\*\*\*\*|test|todo|<.*>)/i;
|
|
273
|
+
|
|
274
|
+
const SQL_JS = /\b(?:SELECT|INSERT INTO|UPDATE|DELETE FROM)\b[^`\n]*(?:\$\{|["']\s*\+)/;
|
|
275
|
+
const SQL_PY = /\b(?:SELECT|INSERT INTO|UPDATE|DELETE FROM)\b[^\n]*(?:\{[^}]+\}|["']\s*(?:\+|%))/;
|
|
276
|
+
|
|
277
|
+
const JS_PATTERNS = [
|
|
278
|
+
["js-eval", /(?<![\w.])eval\s*\(/],
|
|
279
|
+
["js-new-function", /\bnew Function\s*\(/],
|
|
280
|
+
["js-inner-html", /dangerouslySetInnerHTML|\.innerHTML\s*=(?!=)/],
|
|
281
|
+
["js-exec-template", /\bexec(?:Sync)?\s*\(\s*`[^`]*\$\{/],
|
|
282
|
+
["js-tls-off", /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*["']?0/],
|
|
283
|
+
["js-cors-wildcard", /origin\s*:\s*["']\*["']/],
|
|
284
|
+
["sql-concat", SQL_JS],
|
|
285
|
+
];
|
|
286
|
+
const PY_PATTERNS = [
|
|
287
|
+
["py-eval-exec", /(?<![\w.])(?:eval|exec)\s*\(/],
|
|
288
|
+
["py-shell-true", /shell\s*=\s*True/],
|
|
289
|
+
["py-yaml-load", /\byaml\.load\s*\((?![^)]*Safe)/],
|
|
290
|
+
["py-pickle", /\bpickle\.loads?\s*\(/],
|
|
291
|
+
["py-verify-false", /verify\s*=\s*False/],
|
|
292
|
+
["py-debug-true", /^\s*DEBUG\s*=\s*True\b|\bdebug\s*=\s*True\b/],
|
|
293
|
+
["sql-concat", SQL_PY],
|
|
294
|
+
];
|
|
295
|
+
const JS_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
|
|
296
|
+
const PY_EXT = new Set([".py"]);
|
|
297
|
+
|
|
298
|
+
function walk(dir) {
|
|
299
|
+
const out = [];
|
|
300
|
+
const stack = [""];
|
|
301
|
+
while (stack.length && out.length < MAX_FILES) {
|
|
302
|
+
const rel = stack.pop();
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true });
|
|
306
|
+
} catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
for (const e of entries) {
|
|
310
|
+
const r = rel ? rel + "/" + e.name : e.name;
|
|
311
|
+
if (e.isDirectory()) {
|
|
312
|
+
if (!SKIP_DIRS.has(e.name)) stack.push(r);
|
|
313
|
+
} else if (e.isFile()) {
|
|
314
|
+
out.push(r);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return out.sort();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function git(dir, args) {
|
|
322
|
+
try {
|
|
323
|
+
return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const isEnvFile = (rel) => /(^|\/)\.env(\..+)?$/.test(rel) && !/\.(example|sample|template)$/.test(rel);
|
|
330
|
+
const isCommentLine = (line, ext) => {
|
|
331
|
+
const t = line.trim();
|
|
332
|
+
if (PY_EXT.has(ext)) return t.startsWith("#");
|
|
333
|
+
return t.startsWith("//") || t.startsWith("/*") || t.startsWith("*");
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
function scanFile(dir, rel, add) {
|
|
337
|
+
const full = path.join(dir, rel);
|
|
338
|
+
let stat;
|
|
339
|
+
try {
|
|
340
|
+
stat = fs.statSync(full);
|
|
341
|
+
} catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (stat.size > MAX_BYTES || SKIP_FILES.test(rel)) return;
|
|
345
|
+
let text;
|
|
346
|
+
try {
|
|
347
|
+
text = fs.readFileSync(full, "utf8");
|
|
348
|
+
} catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (text.includes("\u0000")) return;
|
|
352
|
+
|
|
353
|
+
// Real .env files are meant to hold secrets: only check they do not leak to the browser
|
|
354
|
+
if (isEnvFile(rel)) {
|
|
355
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
356
|
+
if (PUBLIC_ENV.test(line) && !line.includes(IGNORE_MARK)) add("public-env-secret", rel, i + 1);
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const ext = path.extname(rel).toLowerCase();
|
|
362
|
+
const codePatterns = JS_EXT.has(ext) ? JS_PATTERNS : PY_EXT.has(ext) ? PY_PATTERNS : [];
|
|
363
|
+
const lines = text.split(/\r?\n/);
|
|
364
|
+
|
|
365
|
+
lines.forEach((line, i) => {
|
|
366
|
+
if (line.length > 2000 || line.includes(IGNORE_MARK)) return;
|
|
367
|
+
const n = i + 1;
|
|
368
|
+
|
|
369
|
+
const vibeRule = lineSecretRule(line);
|
|
370
|
+
if (vibeRule) add(vibeRule, rel, n);
|
|
371
|
+
|
|
372
|
+
let secretHit = false;
|
|
373
|
+
for (const [id, re] of SECRET_PATTERNS) {
|
|
374
|
+
if (re.test(line)) {
|
|
375
|
+
add(id, rel, n);
|
|
376
|
+
secretHit = true;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!secretHit) {
|
|
380
|
+
const m = line.match(GENERIC_SECRET);
|
|
381
|
+
if (m && !PLACEHOLDER.test(m[1]) && !/^(.)\1+$/.test(m[1])) add("secret-generic", rel, n);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (codePatterns.length && !isCommentLine(line, ext)) {
|
|
385
|
+
for (const [id, re] of codePatterns) if (re.test(line)) add(id, rel, n);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (JS_EXT.has(ext)) checkRoutes(rel, lines, add);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function checkDockerfile(rel, text, add) {
|
|
393
|
+
const lines = text.split(/\r?\n/);
|
|
394
|
+
const aliases = new Set();
|
|
395
|
+
let lastFrom = -1;
|
|
396
|
+
|
|
397
|
+
lines.forEach((l, i) => {
|
|
398
|
+
const m = l.match(/^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?/i);
|
|
399
|
+
if (!m || l.includes(IGNORE_MARK)) return;
|
|
400
|
+
lastFrom = i;
|
|
401
|
+
const img = m[1];
|
|
402
|
+
const pinned = img.includes("@sha256:");
|
|
403
|
+
const hasTag = /:[^/]+$/.test(img) && !img.endsWith(":latest");
|
|
404
|
+
const isAlias = aliases.has(img);
|
|
405
|
+
if (m[2]) aliases.add(m[2]);
|
|
406
|
+
if (img.toLowerCase() !== "scratch" && !isAlias && !/\$\{?\w+/.test(img) && !pinned && !hasTag) {
|
|
407
|
+
add("docker-latest", rel, i + 1);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
if (lastFrom >= 0) {
|
|
412
|
+
let user = null;
|
|
413
|
+
for (let i = lastFrom; i < lines.length; i++) {
|
|
414
|
+
const m = lines[i].match(/^\s*USER\s+(\S+)/i);
|
|
415
|
+
if (m) user = m[1];
|
|
416
|
+
}
|
|
417
|
+
if (user === null || /^(root|0)(:|$)/.test(user)) add("docker-root", rel, lastFrom + 1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
lines.forEach((l, i) => {
|
|
421
|
+
if (l.includes(IGNORE_MARK)) return;
|
|
422
|
+
const n = i + 1;
|
|
423
|
+
if (/^\s*(?:COPY|ADD)\s+.*\.env(?!\.example|\.sample|\.template)(?=\s|$)/i.test(l)) add("docker-copy-env", rel, n);
|
|
424
|
+
if (/^\s*ADD\s+https?:\/\//i.test(l)) add("docker-add-url", rel, n);
|
|
425
|
+
if (/^\s*(?:ENV|ARG)\s+[A-Za-z0-9_]*(?:SECRET|PASSWORD|PASSWD|TOKEN|API_?KEY|PRIVATE_?KEY)[A-Za-z0-9_]*(?:\s*=\s*|\s+)(?!\$)\S+/i.test(l)) {
|
|
426
|
+
add("docker-secret-env", rel, n);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Reads .vibe-it-securely-ignore: one path or folder prefix per line, # for comments. */
|
|
432
|
+
function loadIgnore(dir) {
|
|
433
|
+
try {
|
|
434
|
+
return fs
|
|
435
|
+
.readFileSync(path.join(dir, ".vibe-it-securely-ignore"), "utf8")
|
|
436
|
+
.split(/\r?\n/)
|
|
437
|
+
.map((l) => l.trim().replace(/^\.?\//, ""))
|
|
438
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
439
|
+
} catch {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const isIgnored = (rel, patterns) =>
|
|
444
|
+
patterns.some((p) => (p.endsWith("/") ? rel.startsWith(p) : rel === p || rel.startsWith(p + "/")));
|
|
445
|
+
|
|
446
|
+
export function check(dir) {
|
|
447
|
+
const stack = detectStack(dir);
|
|
448
|
+
const findings = [];
|
|
449
|
+
const add = (ruleId, file, line, detail) => findings.push({ ruleId, severity: RULES[ruleId].severity, file, line, detail });
|
|
450
|
+
|
|
451
|
+
const ignored = loadIgnore(dir);
|
|
452
|
+
const files = walk(dir).filter((f) => !isIgnored(f, ignored));
|
|
453
|
+
const has = (f) => fs.existsSync(path.join(dir, f));
|
|
454
|
+
|
|
455
|
+
for (const rel of files) scanFile(dir, rel, add);
|
|
456
|
+
analyzeSql(dir, files.filter((f) => f.endsWith(".sql")), detectSupabase(dir, files), add);
|
|
457
|
+
|
|
458
|
+
const dockerfiles = files.filter((f) => /^(Dockerfile(\.[\w.-]+)?|[\w.-]+\.dockerfile)$/i.test(path.basename(f)) && !f.endsWith(".suggested"));
|
|
459
|
+
for (const rel of dockerfiles) {
|
|
460
|
+
try {
|
|
461
|
+
checkDockerfile(rel, fs.readFileSync(path.join(dir, rel), "utf8"), add);
|
|
462
|
+
} catch {
|
|
463
|
+
/* unreadable file: skip */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (dockerfiles.length && !has(".dockerignore")) add("dockerignore-missing");
|
|
467
|
+
|
|
468
|
+
// .env handling
|
|
469
|
+
const tracked = git(dir, ["ls-files"]);
|
|
470
|
+
if (tracked !== null) {
|
|
471
|
+
for (const f of tracked.split("\n").filter(Boolean)) if (isEnvFile(f)) add("env-committed", f);
|
|
472
|
+
}
|
|
473
|
+
const hasEnv = files.some((f) => isEnvFile(f)) || has(".env.example");
|
|
474
|
+
if (hasEnv) {
|
|
475
|
+
let ignored = false;
|
|
476
|
+
try {
|
|
477
|
+
ignored = fs.readFileSync(path.join(dir, ".gitignore"), "utf8").split(/\r?\n/).some((l) => /^\/?\*?\.env(\*|\.\*)?$/.test(l.trim()) || l.trim() === "*.env");
|
|
478
|
+
} catch {
|
|
479
|
+
/* no .gitignore */
|
|
480
|
+
}
|
|
481
|
+
if (!ignored) add("env-not-ignored");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// hooks, CI, lockfile, agent rules
|
|
485
|
+
const hookTool = has(".pre-commit-config.yaml") || has(".husky") || has("lefthook.yml");
|
|
486
|
+
if (!hookTool) add("precommit-missing");
|
|
487
|
+
else if (has(".pre-commit-config.yaml") && fs.existsSync(path.join(dir, ".git")) && fs.statSync(path.join(dir, ".git")).isDirectory() && !has(".git/hooks/pre-commit")) {
|
|
488
|
+
add("hook-not-installed");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let hasCI = has(".gitlab-ci.yml");
|
|
492
|
+
try {
|
|
493
|
+
hasCI = hasCI || fs.readdirSync(path.join(dir, ".github/workflows")).some((f) => /\.ya?ml$/.test(f));
|
|
494
|
+
} catch {
|
|
495
|
+
/* no workflows dir */
|
|
496
|
+
}
|
|
497
|
+
if (!hasCI) add("ci-missing");
|
|
498
|
+
|
|
499
|
+
if (stack.node && !["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"].some(has)) add("lockfile-missing");
|
|
500
|
+
if (!["AGENTS.md", "CLAUDE.md", ".cursorrules", ".github/copilot-instructions.md", ".cursor/rules"].some(has)) add("agent-rules-missing");
|
|
501
|
+
|
|
502
|
+
return { stack, findings, filesScanned: files.length };
|
|
503
|
+
}
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RULES } from "./check.js";
|
|
4
|
+
|
|
5
|
+
const NON_REGISTRY_SPEC = /^(?:file:|link:|git\+|git:|github:|gitlab:|bitbucket:|https?:|workspace:|npm:|portal:|patch:)/i;
|
|
6
|
+
|
|
7
|
+
function npmNames(dir) {
|
|
8
|
+
let pkg;
|
|
9
|
+
try {
|
|
10
|
+
pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const all = { ...pkg.optionalDependencies, ...pkg.devDependencies, ...pkg.dependencies };
|
|
15
|
+
return Object.entries(all)
|
|
16
|
+
.filter(([, spec]) => typeof spec === "string" && !NON_REGISTRY_SPEC.test(spec.trim()) && !/^[./~]/.test(spec.trim()))
|
|
17
|
+
.map(([name]) => name);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pythonNames(dir) {
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = fs.readFileSync(path.join(dir, "requirements.txt"), "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const names = [];
|
|
28
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
29
|
+
const line = raw.split("#")[0].trim();
|
|
30
|
+
if (!line || line.startsWith("-") || /^(?:git\+|https?:|file:|\.|\/)/i.test(line) || line.includes("@")) continue;
|
|
31
|
+
const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/);
|
|
32
|
+
if (m) names.push(m[1].toLowerCase().replace(/[_.]+/g, "-"));
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Scopes (or everything) that the project resolves from a private registry: these cannot be checked publicly. */
|
|
38
|
+
function privateRegistry(dir) {
|
|
39
|
+
const scopes = new Set();
|
|
40
|
+
let all = false;
|
|
41
|
+
try {
|
|
42
|
+
for (const l of fs.readFileSync(path.join(dir, ".npmrc"), "utf8").split(/\r?\n/)) {
|
|
43
|
+
const scoped = l.match(/^\s*(@[\w.-]+):registry\s*=/);
|
|
44
|
+
if (scoped) scopes.add(scoped[1]);
|
|
45
|
+
const global = l.match(/^\s*registry\s*=\s*(\S+)/);
|
|
46
|
+
if (global && !/registry\.npmjs\.org/.test(global[1])) all = true;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* no .npmrc */
|
|
50
|
+
}
|
|
51
|
+
return { scopes, all };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function exists(url, fetchImpl, timeoutMs, headers) {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
57
|
+
if (res.status === 404) return false;
|
|
58
|
+
if (res.ok) return true;
|
|
59
|
+
return null; // rate limit, server error, etc.: cannot tell
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Checks that every dependency name actually exists on npm / PyPI.
|
|
67
|
+
* AI tools sometimes invent package names; an attacker can register such a name.
|
|
68
|
+
* Network failures never produce findings, they are counted as "unverified".
|
|
69
|
+
*/
|
|
70
|
+
export async function checkDependencies(dir, { fetchImpl = globalThis.fetch, concurrency = 8, timeoutMs = 8000 } = {}) {
|
|
71
|
+
const priv = privateRegistry(dir);
|
|
72
|
+
const jobs = [];
|
|
73
|
+
let unverified = 0;
|
|
74
|
+
|
|
75
|
+
for (const name of npmNames(dir)) {
|
|
76
|
+
const scope = name.startsWith("@") ? name.split("/")[0] : null;
|
|
77
|
+
if (priv.all || (scope && priv.scopes.has(scope))) {
|
|
78
|
+
unverified++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
jobs.push({ eco: "npm", name, file: "package.json", url: "https://registry.npmjs.org/" + encodeURIComponent(name), headers: { Accept: "application/vnd.npm.install-v1+json" } });
|
|
82
|
+
}
|
|
83
|
+
for (const name of pythonNames(dir)) {
|
|
84
|
+
jobs.push({ eco: "PyPI", name, file: "requirements.txt", url: "https://pypi.org/pypi/" + encodeURIComponent(name) + "/json", headers: {} });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const findings = [];
|
|
88
|
+
let checked = 0;
|
|
89
|
+
let next = 0;
|
|
90
|
+
async function worker() {
|
|
91
|
+
while (next < jobs.length) {
|
|
92
|
+
const job = jobs[next++];
|
|
93
|
+
const result = await exists(job.url, fetchImpl, timeoutMs, job.headers);
|
|
94
|
+
if (result === null) unverified++;
|
|
95
|
+
else {
|
|
96
|
+
checked++;
|
|
97
|
+
if (result === false) {
|
|
98
|
+
findings.push({ ruleId: "dep-not-found", severity: RULES["dep-not-found"].severity, file: job.file, line: undefined, detail: `${job.name} on ${job.eco}` });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, worker));
|
|
104
|
+
return { findings, checked, unverified };
|
|
105
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const has = (dir, f) => fs.existsSync(path.join(dir, f));
|
|
5
|
+
|
|
6
|
+
export function detectStack(dir) {
|
|
7
|
+
const stack = {
|
|
8
|
+
node: false,
|
|
9
|
+
typescript: false,
|
|
10
|
+
python: false,
|
|
11
|
+
packageManager: null,
|
|
12
|
+
hasNodeLock: false,
|
|
13
|
+
pythonDeps: null, // requirements.txt | pyproject | pipfile | setup
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (has(dir, "package.json")) {
|
|
17
|
+
stack.node = true;
|
|
18
|
+
let pkg = {};
|
|
19
|
+
try {
|
|
20
|
+
pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
21
|
+
} catch {
|
|
22
|
+
/* malformed package.json: still treat as a Node project */
|
|
23
|
+
}
|
|
24
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
25
|
+
stack.typescript = has(dir, "tsconfig.json") || "typescript" in deps;
|
|
26
|
+
stack.hasNodeLock = has(dir, "package-lock.json");
|
|
27
|
+
stack.packageManager = has(dir, "pnpm-lock.yaml")
|
|
28
|
+
? "pnpm"
|
|
29
|
+
: has(dir, "yarn.lock")
|
|
30
|
+
? "yarn"
|
|
31
|
+
: "npm";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (has(dir, "requirements.txt")) stack.pythonDeps = "requirements.txt";
|
|
35
|
+
else if (has(dir, "pyproject.toml")) stack.pythonDeps = "pyproject";
|
|
36
|
+
else if (has(dir, "Pipfile")) stack.pythonDeps = "pipfile";
|
|
37
|
+
else if (has(dir, "setup.py")) stack.pythonDeps = "setup";
|
|
38
|
+
stack.python = stack.pythonDeps !== null;
|
|
39
|
+
|
|
40
|
+
return stack;
|
|
41
|
+
}
|