@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/src/init.js ADDED
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { detectStack } from "./detect.js";
4
+ import { safeWrite } from "./safe-write.js";
5
+ import * as t from "./templates.js";
6
+
7
+ export function init({ dir, dryRun = false, docker = true }) {
8
+ const stack = detectStack(dir);
9
+ if (!stack.node && !stack.python) {
10
+ return { stack, results: [], notes: [], error: "No supported project found (looked for package.json, requirements.txt, pyproject.toml, Pipfile, setup.py)." };
11
+ }
12
+
13
+ const files = [
14
+ { path: ".pre-commit-config.yaml", content: t.preCommit() },
15
+ { path: ".github/workflows/security.yml", content: t.ciWorkflow(stack) },
16
+ { path: "AGENTS.md", content: t.agentRules(stack) },
17
+ ];
18
+
19
+ if (!docker) {
20
+ // libraries, CLIs and scripts do not need container files
21
+ } else if (stack.node && stack.python) {
22
+ files.push(
23
+ { path: ".dockerignore", content: t.dockerignore() },
24
+ );
25
+ files.push({ path: "Dockerfile.node", content: t.dockerfileNode(stack) });
26
+ files.push({ path: "Dockerfile.python", content: t.dockerfilePython(stack) });
27
+ } else if (stack.node) {
28
+ files.push({ path: ".dockerignore", content: t.dockerignore() });
29
+ files.push({ path: "Dockerfile", content: t.dockerfileNode(stack) });
30
+ } else {
31
+ files.push({ path: ".dockerignore", content: t.dockerignore() });
32
+ files.push({ path: "Dockerfile", content: t.dockerfilePython(stack) });
33
+ }
34
+
35
+ const results = files.map((f) => safeWrite(dir, f.path, f.content, { dryRun }));
36
+ // Quiet extras: only create if missing, never write .suggested copies
37
+ results.push(safeWrite(dir, ".env.example", t.envExample(), { dryRun, onExists: "skip" }));
38
+ results.push(safeWrite(dir, ".gitleaksignore", t.gitleaksIgnore(), { dryRun, onExists: "skip" }));
39
+
40
+ const notes = [];
41
+ const giPath = path.join(dir, ".gitignore");
42
+ if (!fs.existsSync(giPath)) {
43
+ results.push(safeWrite(dir, ".gitignore", t.gitignore(), { dryRun }));
44
+ } else {
45
+ const lines = fs.readFileSync(giPath, "utf8").split(/\r?\n/).map((l) => l.trim());
46
+ if (!lines.some((l) => l === ".env" || l === ".env*" || l === "*.env")) {
47
+ notes.push("Your .gitignore does not ignore .env files. Anyone who creates a .env could accidentally commit their secrets. Add a line containing: .env");
48
+ }
49
+ }
50
+ if (stack.node && !stack.hasNodeLock && stack.packageManager === "npm") {
51
+ notes.push("No package-lock.json found. Commit one so installs are reproducible and dependency audits are accurate.");
52
+ }
53
+
54
+ return { stack, results, notes };
55
+ }
package/src/report.js ADDED
@@ -0,0 +1,59 @@
1
+ import { RULES } from "./check.js";
2
+
3
+ const ORDER = ["high", "medium", "info"];
4
+ const LABEL = { high: "HIGH ", medium: "MEDIUM", info: "INFO " };
5
+ const HEADING = {
6
+ high: "Fix these first",
7
+ medium: "Should fix",
8
+ info: "Suggestions",
9
+ };
10
+
11
+ /** Plain-text report. Never prints matched secret values, only locations. */
12
+ export function formatReport(result, { maxLocations = 5 } = {}) {
13
+ const { findings, filesScanned } = result;
14
+ const out = [];
15
+
16
+ const groups = new Map();
17
+ for (const f of findings) {
18
+ if (!groups.has(f.ruleId)) groups.set(f.ruleId, []);
19
+ groups.get(f.ruleId).push(f);
20
+ }
21
+
22
+ const counts = { high: 0, medium: 0, info: 0 };
23
+ for (const [ruleId, list] of groups) counts[RULES[ruleId].severity] += list.length;
24
+
25
+ for (const sev of ORDER) {
26
+ const ruleIds = [...groups.keys()].filter((id) => RULES[id].severity === sev);
27
+ if (!ruleIds.length) continue;
28
+ out.push(`== ${HEADING[sev]} ==`, "");
29
+ for (const id of ruleIds) {
30
+ const rule = RULES[id];
31
+ const list = groups.get(id);
32
+ out.push(`[${LABEL[sev]}] ${rule.title}`);
33
+ const located = list.filter((f) => f.file);
34
+ if (located.length) {
35
+ const shown = located.slice(0, maxLocations).map((f) => (f.line ? `${f.file}:${f.line}` : f.file) + (f.detail ? ` (${f.detail})` : ""));
36
+ const more = located.length - shown.length;
37
+ out.push(` Where: ${shown.join(", ")}${more > 0 ? `, and ${more} more` : ""}`);
38
+ }
39
+ out.push(` Why: ${rule.why}`, ` Fix: ${rule.fix}`, "");
40
+ }
41
+ }
42
+
43
+ for (const n of result.notes || []) out.push(n);
44
+ if ((result.notes || []).length) out.push("");
45
+ const total = counts.high + counts.medium + counts.info;
46
+ out.push(`Scanned ${filesScanned} files. Found ${counts.high} high, ${counts.medium} medium, ${counts.info} info.`);
47
+ if (total === 0) out.push("Nothing found by these checks.");
48
+ out.push(
49
+ "This is a baseline check, not a security audit. A clean result does not mean your project is secure.",
50
+ "False alarm? Add the comment vibe-it-securely-ignore to that line.",
51
+ );
52
+ return out.join("\n");
53
+ }
54
+
55
+ export function countBySeverity(findings) {
56
+ const c = { high: 0, medium: 0, info: 0 };
57
+ for (const f of findings) c[f.severity]++;
58
+ return c;
59
+ }
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Write a file without ever overwriting an existing one.
6
+ * If the target exists, write "<name>.suggested" next to it
7
+ * (or skip entirely when onExists === "skip").
8
+ */
9
+ export function safeWrite(dir, relPath, content, { dryRun = false, onExists = "suggest" } = {}) {
10
+ const exists = fs.existsSync(path.join(dir, relPath));
11
+ let outRel = relPath;
12
+ let status = "created";
13
+
14
+ if (exists) {
15
+ if (onExists === "skip") return { path: relPath, status: "skipped-exists" };
16
+ outRel = relPath + ".suggested";
17
+ if (fs.existsSync(path.join(dir, outRel))) return { path: outRel, status: "skipped-exists" };
18
+ status = "suggested";
19
+ }
20
+
21
+ if (!dryRun) {
22
+ const out = path.join(dir, outRel);
23
+ fs.mkdirSync(path.dirname(out), { recursive: true });
24
+ fs.writeFileSync(out, content, { flag: "wx" }); // wx = fail rather than overwrite
25
+ }
26
+ return { path: outRel, status: dryRun ? "would-be-" + status : status };
27
+ }
@@ -0,0 +1,244 @@
1
+ const L = (...lines) => lines.flat().join("\n") + "\n";
2
+
3
+ export function preCommit() {
4
+ return L(
5
+ "# Generated by vibe-it-securely.",
6
+ "# FAST local checks. CI re-runs them, because hooks can be bypassed (git commit --no-verify).",
7
+ "# Install once: pip install pre-commit && pre-commit install",
8
+ "repos:",
9
+ " - repo: https://github.com/pre-commit/pre-commit-hooks",
10
+ " rev: v4.6.0",
11
+ " hooks:",
12
+ " - id: detect-private-key",
13
+ " - id: check-added-large-files",
14
+ " - repo: https://github.com/gitleaks/gitleaks",
15
+ " rev: v8.18.4",
16
+ " hooks:",
17
+ " - id: gitleaks",
18
+ );
19
+ }
20
+
21
+ export function ciWorkflow(stack) {
22
+ const steps = [
23
+ " - uses: actions/checkout@v4",
24
+ " with:",
25
+ " fetch-depth: 0",
26
+ " - uses: actions/setup-python@v5",
27
+ " with:",
28
+ " python-version: \"3.12\"",
29
+ "",
30
+ " # False alarm on a fake value? Add the finding Fingerprint to .gitleaksignore",
31
+ " - name: Secret scan (blocking)",
32
+ " run: |",
33
+ " curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz | tar -xz gitleaks",
34
+ " ./gitleaks detect --source . --redact --verbose",
35
+ " - name: Hygiene hooks (blocking)",
36
+ " run: |",
37
+ " pip install pre-commit",
38
+ " SKIP=gitleaks pre-commit run --all-files",
39
+ ];
40
+
41
+ if (stack.node) {
42
+ steps.push(
43
+ "",
44
+ " - uses: actions/setup-node@v4",
45
+ " with:",
46
+ " node-version: \"22\"",
47
+ );
48
+ if (stack.packageManager === "pnpm") {
49
+ steps.push(
50
+ " - name: Dependency audit, critical only (blocking)",
51
+ " run: |",
52
+ " corepack enable",
53
+ " pnpm audit --audit-level critical",
54
+ );
55
+ } else if (stack.packageManager === "yarn") {
56
+ steps.push(
57
+ " - name: Dependency audit, critical only (blocking)",
58
+ " # TODO: Yarn Berry uses \"yarn npm audit\" instead.",
59
+ " run: yarn audit --level critical",
60
+ );
61
+ } else {
62
+ steps.push(" - name: Dependency audit, critical only (blocking)", " run: |");
63
+ if (!stack.hasNodeLock) steps.push(" npm install --package-lock-only --ignore-scripts");
64
+ steps.push(" npm audit --audit-level=critical");
65
+ }
66
+ }
67
+
68
+ if (stack.python) {
69
+ const target =
70
+ stack.pythonDeps === "requirements.txt" ? "-r requirements.txt" : stack.pythonDeps === "pipfile" ? "" : ".";
71
+ steps.push(
72
+ "",
73
+ " - name: Python dependency audit (warning only: pip-audit cannot filter by severity)",
74
+ " continue-on-error: true",
75
+ " run: |",
76
+ " pip install pip-audit",
77
+ target
78
+ ? " pip-audit " + target
79
+ : " # TODO: Pipfile detected. Run: pipenv requirements | pip-audit -r /dev/stdin",
80
+ );
81
+ }
82
+
83
+ steps.push(
84
+ "",
85
+ " - name: Static analysis, errors only (blocking)",
86
+ " run: |",
87
+ " pip install semgrep",
88
+ " semgrep scan --config p/default --severity ERROR --error",
89
+ " - name: Static analysis, warnings (report only)",
90
+ " continue-on-error: true",
91
+ " run: semgrep scan --config p/default --severity WARNING",
92
+ );
93
+
94
+ return L(
95
+ "# Generated by vibe-it-securely. This is a security BASELINE, not a guarantee of security.",
96
+ "# TODO: pin third-party actions to commit SHAs for stronger supply-chain safety.",
97
+ "name: Security baseline",
98
+ "on:",
99
+ " push:",
100
+ " branches: [main]",
101
+ " pull_request:",
102
+ "permissions:",
103
+ " contents: read",
104
+ "jobs:",
105
+ " baseline:",
106
+ " runs-on: ubuntu-latest",
107
+ " steps:",
108
+ steps,
109
+ );
110
+ }
111
+
112
+ export function dockerfileNode(stack) {
113
+ const pm = stack.packageManager || "npm";
114
+ const copy = pm === "pnpm" ? "package.json pnpm-lock.yaml*" : pm === "yarn" ? "package.json yarn.lock*" : "package*.json";
115
+ const install =
116
+ pm === "pnpm"
117
+ ? "corepack enable && pnpm install --frozen-lockfile"
118
+ : pm === "yarn"
119
+ ? "corepack enable && yarn install --frozen-lockfile"
120
+ : stack.hasNodeLock
121
+ ? "npm ci"
122
+ : "npm install";
123
+ const prune = pm === "pnpm" ? "pnpm prune --prod" : pm === "npm" ? "npm prune --omit=dev" : null;
124
+
125
+ return L(
126
+ "# syntax=docker/dockerfile:1",
127
+ "# Generated by vibe-it-securely. TODO: pin base images by digest (node:22-slim@sha256:...).",
128
+ "FROM node:22-slim AS build",
129
+ "WORKDIR /app",
130
+ "COPY " + copy + " ./",
131
+ "RUN " + install,
132
+ "COPY . .",
133
+ "# TODO: if your project has a build step (TypeScript, bundlers), enable:",
134
+ "# RUN npm run build",
135
+ prune ? "RUN " + prune : "# TODO: remove dev dependencies for this package manager",
136
+ "",
137
+ "FROM node:22-slim",
138
+ "ENV NODE_ENV=production",
139
+ "WORKDIR /app",
140
+ "# Run as the unprivileged node user, never as root",
141
+ "COPY --from=build --chown=node:node /app /app",
142
+ "USER node",
143
+ "EXPOSE 3000",
144
+ "# TODO: confirm npm start runs your app",
145
+ "CMD [\"npm\", \"start\"]",
146
+ );
147
+ }
148
+
149
+ export function dockerfilePython(stack) {
150
+ const reqs = stack.pythonDeps === "requirements.txt";
151
+ return L(
152
+ "# syntax=docker/dockerfile:1",
153
+ "# Generated by vibe-it-securely. TODO: pin base images by digest (python:3.12-slim@sha256:...).",
154
+ "FROM python:3.12-slim AS build",
155
+ "WORKDIR /app",
156
+ "ENV PIP_NO_CACHE_DIR=1",
157
+ "RUN python -m venv /opt/venv",
158
+ "ENV PATH=\"/opt/venv/bin:$PATH\"",
159
+ reqs
160
+ ? ["COPY requirements.txt ./", "RUN pip install -r requirements.txt"]
161
+ : ["COPY . .", "RUN pip install ."],
162
+ "",
163
+ "FROM python:3.12-slim",
164
+ "ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=\"/opt/venv/bin:$PATH\"",
165
+ "# Run as an unprivileged user, never as root",
166
+ "RUN useradd --create-home --uid 10001 app",
167
+ "WORKDIR /app",
168
+ "COPY --from=build /opt/venv /opt/venv",
169
+ "COPY --chown=app:app . .",
170
+ "USER app",
171
+ "EXPOSE 8000",
172
+ "# TODO: replace with your real start command",
173
+ "CMD [\"python\", \"main.py\"]",
174
+ );
175
+ }
176
+
177
+ export function dockerignore() {
178
+ return L(".git", ".env", ".env.*", "!.env.example", "*.pem", "*.key", "node_modules", "__pycache__", ".venv", ".github", "Dockerfile*");
179
+ }
180
+
181
+ export function envExample() {
182
+ return L(
183
+ "# Copy to .env and fill in real values. NEVER commit .env.",
184
+ "# Keep placeholders here, never real secrets.",
185
+ "DATABASE_URL=",
186
+ "API_KEY=",
187
+ "SECRET_KEY=",
188
+ );
189
+ }
190
+
191
+ export function gitleaksIgnore() {
192
+ return L(
193
+ "# Generated by secure-baseline.",
194
+ "# Secret-scan false alarm? Copy the Fingerprint line from the CI log into this file, one per line.",
195
+ "# Only do this for fake or example values. For a real secret: revoke it and replace it.",
196
+ );
197
+ }
198
+
199
+ export function gitignore() {
200
+ return L(".env", ".env.*", "!.env.example", "node_modules/", "__pycache__/", ".venv/", "*.pem", "*.key");
201
+ }
202
+
203
+ export function agentRules(stack) {
204
+ const rules = [
205
+ "# Security rules for AI coding assistants",
206
+ "",
207
+ "Generated by vibe-it-securely. Follow these in all code you write or modify.",
208
+ "",
209
+ "## Always",
210
+ "- Never hardcode secrets, tokens, passwords or API keys. Read them from environment variables and add new ones to `.env.example` with empty values.",
211
+ "- Validate and sanitize all external input (request bodies, query params, files, env vars).",
212
+ "- Use parameterized queries or an ORM. Never build SQL by string concatenation.",
213
+ "- Add authentication AND authorization checks to every endpoint that touches user data.",
214
+ "- Keep CORS restrictive: list allowed origins explicitly, never `*` with credentials.",
215
+ "- Do not log secrets, tokens or personal data.",
216
+ "- Prefer well-maintained libraries; do not add a dependency for something trivial.",
217
+ "- Run containers as a non-root user.",
218
+ "",
219
+ "## Never",
220
+ "- Never disable TLS verification or security linters to make an error go away.",
221
+ "- Never commit `.env`, private keys or credentials.",
222
+ "- Never use `--no-verify` to skip the pre-commit hooks.",
223
+ ];
224
+ if (stack.node) {
225
+ rules.push(
226
+ "",
227
+ "## JavaScript / TypeScript",
228
+ "- Never use `eval`, `new Function`, or `child_process.exec` with user input.",
229
+ "- Validate request data with a schema library (e.g. zod) at the API boundary.",
230
+ "- Never render user input with `dangerouslySetInnerHTML` or `innerHTML` unsanitized.",
231
+ );
232
+ }
233
+ if (stack.python) {
234
+ rules.push(
235
+ "",
236
+ "## Python",
237
+ "- Never use `eval`, `exec`, `pickle.loads` on untrusted data, or `subprocess` with `shell=True` and user input.",
238
+ "- Use `yaml.safe_load`, never `yaml.load`.",
239
+ "- Use `secrets` (not `random`) for tokens and anything security-related.",
240
+ );
241
+ }
242
+ rules.push("", "If a task seems to require breaking a rule above, stop and ask the user first.");
243
+ return L(rules);
244
+ }
package/src/vibe.js ADDED
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const IGNORE = "vibe-it-securely-ignore";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Secrets that leak through naming mistakes
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Env variables that are exposed to the browser but are named like secrets. */
11
+ export const PUBLIC_ENV = /\b(?:NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC|NUXT_PUBLIC|PUBLIC)_[A-Z0-9_]*(?:SERVICE_ROLE|SECRET|PRIVATE_KEY|PASSWORD)[A-Z0-9_]*/;
12
+
13
+ export const JWT_RE = /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/;
14
+
15
+ /** Reads the "role" claim of a JWT without verifying it. Returns null if unreadable. */
16
+ export function jwtRole(token) {
17
+ try {
18
+ const payload = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
19
+ return JSON.parse(Buffer.from(payload, "base64").toString("utf8")).role ?? null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export const SERVICE_LITERAL = /service_?role\w*["']?\s*[:=]\s*["'`]([A-Za-z0-9._-]{20,})["'`]/i;
26
+ const PLACEHOLDER = /(your|example|change.?me|placeholder|dummy|sample|xxxx|\*\*\*\*|todo|<.*>)/i;
27
+
28
+ /** Per-line Supabase / public-env checks. Returns a rule id or null. */
29
+ export function lineSecretRule(line) {
30
+ const jwt = line.match(JWT_RE);
31
+ if (jwt && jwtRole(jwt[0]) === "service_role") return "supabase-service-key";
32
+ const lit = line.match(SERVICE_LITERAL);
33
+ if (lit && !PLACEHOLDER.test(lit[1])) return "supabase-service-key";
34
+ if (PUBLIC_ENV.test(line)) return "public-env-secret";
35
+ return null;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Routes that change data without any sign of authentication
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const ROUTE_RE = /\b(?:app|router|server|fastify|\w*Router|\w*router)\.(post|put|patch|delete)\s*\(\s*(["'`])([^"'`]+)\2/;
43
+ const ANY_ROUTE = /\b(?:app|router|server|fastify|\w*Router|\w*router)\.(?:get|post|put|patch|delete)\s*\(/;
44
+ const PUBLIC_PATH = /(login|log-in|signin|sign-in|signup|sign-up|register|webhook|callback|health|ping|auth|csrf|logout|forgot|reset-password|contact|subscribe|newsletter|public)/i;
45
+ const AUTH_ARGS = /(auth|jwt|verify|protect|passport|session|token|guard|login|requireUser|isLoggedIn|ensure|permission|role|clerk|middleware)/i;
46
+ const AUTH_USE = /(auth|jwt|verify|passport|session|protect|guard|token|clerk)/i;
47
+ const AUTH_BODY = /(req\.user|req\.session|req\.auth|getUser|getSession|getServerSession|currentUser|verifyToken|jwt\.verify|authorization|\b401\b|\b403\b|unauthori[sz]ed|forbidden|isAuthenticated)/i;
48
+ const NEXT_AUTH = /(getServerSession|getSession|\bauth\(\)|currentUser|getToken|getUser|verifyToken|jwt|withAuth|requireAuth|clerk|supabase\.auth|authorization|req\.user|session)/i;
49
+ const NEXT_APP_ROUTE = /(^|\/)app\/(?:.*\/)?route\.(?:[jt]sx?|mjs)$/;
50
+ const NEXT_PAGES_API = /(^|\/)pages\/api\//;
51
+ const NEXT_APP_EXPORT = /export\s+(?:async\s+function|function|const)\s+(?:POST|PUT|PATCH|DELETE)\b/;
52
+ const NEXT_PAGES_MUTATION = /req\.method\s*===?\s*["'](?:POST|PUT|PATCH|DELETE)["']|\.(?:insert|update|delete|create|upsert|destroy)\w*\s*\(/;
53
+
54
+ /** Heuristic: flags routes that change data and show no sign of an auth check. */
55
+ export function checkRoutes(rel, lines, add) {
56
+ if (/\.(test|spec)\.[jt]sx?$/.test(rel) || /(^|\/)(tests?|__tests__)\//.test(rel)) return;
57
+ const text = lines.join("\n");
58
+
59
+ if (NEXT_APP_ROUTE.test(rel) || NEXT_PAGES_API.test(rel)) {
60
+ if (PUBLIC_PATH.test(rel) || NEXT_AUTH.test(text)) return;
61
+ const pattern = NEXT_APP_ROUTE.test(rel) ? NEXT_APP_EXPORT : NEXT_PAGES_MUTATION;
62
+ const idx = lines.findIndex((l) => pattern.test(l) && !l.includes(IGNORE));
63
+ if (idx >= 0) add("js-route-no-auth", rel, idx + 1);
64
+ return;
65
+ }
66
+
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const line = lines[i];
69
+ const m = line.match(ROUTE_RE);
70
+ if (!m || line.includes(IGNORE) || PUBLIC_PATH.test(m[3])) continue;
71
+
72
+ // 1. middleware listed in the route arguments, before the handler starts
73
+ const win = lines.slice(i, i + 4).join("\n");
74
+ const after = win.slice(win.indexOf(m[0]) + m[0].length);
75
+ const cut = after.search(/=>|function|;|\.(?:get|post|put|patch|delete|use)\s*\(/);
76
+ if (AUTH_ARGS.test(cut >= 0 ? after.slice(0, cut) : after)) continue;
77
+
78
+ // 2. an auth middleware registered earlier for the whole app/router
79
+ if (lines.slice(0, i).some((l) => /\.use\s*\(/.test(l) && AUTH_USE.test(l))) continue;
80
+
81
+ // 3. an auth check inside the handler
82
+ let body = [];
83
+ for (let j = i + 1; j < Math.min(lines.length, i + 26) && !ANY_ROUTE.test(lines[j]); j++) body.push(lines[j]);
84
+ if (AUTH_BODY.test(body.join("\n"))) continue;
85
+
86
+ add("js-route-no-auth", rel, i + 1);
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Supabase row level security (SQL migrations)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const stripSqlComments = (s) => s.replace(/--.*$/gm, (c) => " ".repeat(c.length));
95
+
96
+ export function analyzeSql(dir, sqlFiles, usesSupabase, add) {
97
+ if (!usesSupabase) return;
98
+ const created = new Map(); // table -> {file, line}
99
+ const enabled = new Set();
100
+
101
+ for (const rel of sqlFiles) {
102
+ let raw;
103
+ try {
104
+ raw = fs.readFileSync(path.join(dir, rel), "utf8");
105
+ } catch {
106
+ continue;
107
+ }
108
+ const text = stripSqlComments(raw);
109
+ const lineOf = (index) => text.slice(0, index).split("\n").length;
110
+
111
+ for (const m of text.matchAll(/disable\s+row\s+level\s+security/gi)) {
112
+ if (!raw.split("\n")[lineOf(m.index) - 1]?.includes(IGNORE)) add("sql-rls-disabled", rel, lineOf(m.index));
113
+ }
114
+ for (const m of text.matchAll(/create\s+table\s+(?:if\s+not\s+exists\s+)?(?:"?(\w+)"?\.)?"?(\w+)"?/gi)) {
115
+ const schema = (m[1] || "public").toLowerCase();
116
+ if (schema !== "public") continue;
117
+ created.set(m[2].toLowerCase(), { file: rel, line: lineOf(m.index) });
118
+ }
119
+ for (const m of text.matchAll(/alter\s+table\s+(?:only\s+)?(?:"?(\w+)"?\.)?"?(\w+)"?\s+enable\s+row\s+level\s+security/gi)) {
120
+ enabled.add(m[2].toLowerCase());
121
+ }
122
+ }
123
+ for (const [table, where] of created) {
124
+ if (!enabled.has(table)) add("sql-rls-missing", where.file, where.line, table);
125
+ }
126
+ }
127
+
128
+ export function detectSupabase(dir, files) {
129
+ if (files.some((f) => f.startsWith("supabase/"))) return true;
130
+ try {
131
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
132
+ if (JSON.stringify({ ...pkg.dependencies, ...pkg.devDependencies }).includes("supabase")) return true;
133
+ } catch {
134
+ /* no package.json */
135
+ }
136
+ try {
137
+ if (/supabase/i.test(fs.readFileSync(path.join(dir, "requirements.txt"), "utf8"))) return true;
138
+ } catch {
139
+ /* no requirements.txt */
140
+ }
141
+ return false;
142
+ }