itworksbut 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -0
- package/bin/itworksbut.js +63 -0
- package/itworksbut.config.json +5 -0
- package/package.json +46 -0
- package/src/checks/auth/idor-risk.js +45 -0
- package/src/checks/auth/missing-auth-on-routes.js +55 -0
- package/src/checks/ci/no-build-step.js +23 -0
- package/src/checks/ci/no-ci-config.js +20 -0
- package/src/checks/ci/no-test-step.js +23 -0
- package/src/checks/ci/npm-install-instead-of-npm-ci.js +29 -0
- package/src/checks/database/no-migrations.js +33 -0
- package/src/checks/database/raw-sql-interpolation.js +41 -0
- package/src/checks/dependencies/audit-script-missing.js +23 -0
- package/src/checks/dependencies/install-scripts-risk.js +18 -0
- package/src/checks/dependencies/lockfile-missing.js +21 -0
- package/src/checks/dependencies/multiple-lockfiles.js +21 -0
- package/src/checks/electron/context-isolation-disabled.js +51 -0
- package/src/checks/electron/node-integration-enabled.js +26 -0
- package/src/checks/env/env-example-missing.js +28 -0
- package/src/checks/env/env-file-tracked.js +20 -0
- package/src/checks/env/frontend-secret-exposure.js +44 -0
- package/src/checks/env/possible-secret-in-code.js +72 -0
- package/src/checks/git/gitignore-incomplete.js +47 -0
- package/src/checks/git/gitignore-missing.js +16 -0
- package/src/checks/git/ignored-files-tracked.js +38 -0
- package/src/checks/helpers.js +122 -0
- package/src/checks/index.js +63 -0
- package/src/checks/node/cors-wildcard.js +35 -0
- package/src/checks/node/express-json-limit-missing.js +30 -0
- package/src/checks/node/helmet-missing.js +22 -0
- package/src/checks/node/rate-limit-missing.js +30 -0
- package/src/checks/package/scripts-missing.js +30 -0
- package/src/checks/tauri/dangerous-allowlist-or-capabilities.js +142 -0
- package/src/checks/web/client-side-auth-only.js +40 -0
- package/src/checks/web/dangerous-inner-html.js +33 -0
- package/src/checks/web/missing-output-sanitization.js +34 -0
- package/src/cli/output.js +29 -0
- package/src/cli/parseArgs.js +75 -0
- package/src/cli/terminal.js +112 -0
- package/src/core/config.js +51 -0
- package/src/core/context.js +87 -0
- package/src/core/fileWalker.js +44 -0
- package/src/core/findings.js +39 -0
- package/src/core/git.js +92 -0
- package/src/core/scanner.js +56 -0
- package/src/reporters/consoleReporter.js +107 -0
- package/src/reporters/consoleStyle.js +155 -0
- package/src/reporters/jsonReporter.js +17 -0
- package/src/reporters/sarifReporter.js +82 -0
- package/src/utils/fs.js +57 -0
- package/src/utils/mask.js +14 -0
- package/src/utils/packageJson.js +31 -0
- package/src/utils/path.js +71 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: "dependencies.lockfile-missing",
|
|
5
|
+
title: "Package lockfile should be committed",
|
|
6
|
+
category: "dependencies",
|
|
7
|
+
severity: "medium",
|
|
8
|
+
tags: ["dependencies", "reproducibility", "ci"],
|
|
9
|
+
run: async (context) => {
|
|
10
|
+
if (!context.packageJson) return [];
|
|
11
|
+
if (LOCKFILES.some((file) => context.allFiles.includes(file))) return [];
|
|
12
|
+
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
message: "package.json exists, but no package-lock.json, pnpm-lock.yaml, or yarn.lock was found.",
|
|
16
|
+
file: "package.json",
|
|
17
|
+
recommendation: "Commit exactly one lockfile so local and CI installs resolve the same dependency graph."
|
|
18
|
+
}
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: "dependencies.multiple-lockfiles",
|
|
5
|
+
title: "Only one JavaScript package lockfile should be committed",
|
|
6
|
+
category: "dependencies",
|
|
7
|
+
severity: "medium",
|
|
8
|
+
tags: ["dependencies", "reproducibility", "ci"],
|
|
9
|
+
run: async (context) => {
|
|
10
|
+
const present = LOCKFILES.filter((file) => context.allFiles.includes(file));
|
|
11
|
+
if (present.length <= 1) return [];
|
|
12
|
+
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
message: `Multiple package lockfiles were found: ${present.join(", ")}.`,
|
|
16
|
+
recommendation: "Keep the lockfile for the package manager used by the project and remove the others.",
|
|
17
|
+
metadata: { lockfiles: present }
|
|
18
|
+
}
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { hasText, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const WEB_PREFERENCES_RE = /webPreferences\s*:\s*{([\s\S]{0,800}?)}\s*[,}]/g;
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: "electron.context-isolation-disabled",
|
|
7
|
+
title: "Electron contextIsolation should be explicitly enabled",
|
|
8
|
+
category: "electron",
|
|
9
|
+
severity: "high",
|
|
10
|
+
tags: ["electron", "desktop", "xss"],
|
|
11
|
+
run: async (context) => {
|
|
12
|
+
const electronDetected = context.hasDependency("electron") || (await hasText(context, /\bfrom\s+["']electron["']|\brequire\(["']electron["']\)/g));
|
|
13
|
+
if (!electronDetected) return [];
|
|
14
|
+
|
|
15
|
+
const findings = [];
|
|
16
|
+
const disabled = await context.grep(/contextIsolation\s*:\s*false/g, {
|
|
17
|
+
include: ["*.js", "*.ts", "*.mjs", "*.cjs"],
|
|
18
|
+
maxMatches: 100
|
|
19
|
+
});
|
|
20
|
+
for (const match of disabled) {
|
|
21
|
+
findings.push({
|
|
22
|
+
message: "Electron BrowserWindow webPreferences disables contextIsolation.",
|
|
23
|
+
file: match.file,
|
|
24
|
+
line: match.line,
|
|
25
|
+
column: match.column,
|
|
26
|
+
recommendation: "Set contextIsolation: true and expose narrow, validated APIs from preload."
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const file of context.textFiles) {
|
|
31
|
+
if (!/\.[cm]?[jt]s$/.test(file)) continue;
|
|
32
|
+
const content = await context.readFileSafe(file);
|
|
33
|
+
if (!content || !/new\s+BrowserWindow\s*\(/.test(content)) continue;
|
|
34
|
+
|
|
35
|
+
WEB_PREFERENCES_RE.lastIndex = 0;
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = WEB_PREFERENCES_RE.exec(content)) !== null) {
|
|
38
|
+
if (/contextIsolation\s*:/.test(match[1])) continue;
|
|
39
|
+
findings.push({
|
|
40
|
+
message: "Electron BrowserWindow webPreferences appears to omit contextIsolation.",
|
|
41
|
+
file,
|
|
42
|
+
line: lineFromOffset(content, match.index),
|
|
43
|
+
recommendation: "Set contextIsolation: true explicitly and review preload exposure.",
|
|
44
|
+
heuristic: true
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return findings.slice(0, 100);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { hasText } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: "electron.node-integration-enabled",
|
|
5
|
+
title: "Electron nodeIntegration should be disabled",
|
|
6
|
+
category: "electron",
|
|
7
|
+
severity: "high",
|
|
8
|
+
tags: ["electron", "desktop", "xss"],
|
|
9
|
+
run: async (context) => {
|
|
10
|
+
const electronDetected = context.hasDependency("electron") || (await hasText(context, /\bfrom\s+["']electron["']|\brequire\(["']electron["']\)/g));
|
|
11
|
+
if (!electronDetected) return [];
|
|
12
|
+
|
|
13
|
+
const matches = await context.grep(/nodeIntegration\s*:\s*true/g, {
|
|
14
|
+
include: ["*.js", "*.ts", "*.mjs", "*.cjs"],
|
|
15
|
+
maxMatches: 100
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return matches.map((match) => ({
|
|
19
|
+
message: "Electron BrowserWindow webPreferences enables nodeIntegration.",
|
|
20
|
+
file: match.file,
|
|
21
|
+
line: match.line,
|
|
22
|
+
column: match.column,
|
|
23
|
+
recommendation: "Set nodeIntegration: false, keep contextIsolation: true, and expose only minimal APIs through a strict preload script."
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isEnvFile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: "env.env-example-missing",
|
|
5
|
+
title: ".env.example should document required environment variables",
|
|
6
|
+
category: "env",
|
|
7
|
+
severity: "medium",
|
|
8
|
+
tags: ["env", "developer-experience", "deployment"],
|
|
9
|
+
run: async (context) => {
|
|
10
|
+
if (await context.fileExists(".env.example")) return [];
|
|
11
|
+
|
|
12
|
+
const envFilePresent = context.allFiles.some((file) => isEnvFile(file));
|
|
13
|
+
const envUsage = await context.grep(/\b(process\.env|import\.meta\.env|Deno\.env)\b/, {
|
|
14
|
+
include: ["*.js", "*.ts", "*.jsx", "*.tsx", "*.mjs", "*.cjs"],
|
|
15
|
+
maxMatches: 1
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!envFilePresent && envUsage.length === 0) return [];
|
|
19
|
+
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
message: "Environment variable usage appears to exist, but .env.example is missing.",
|
|
23
|
+
recommendation: "Add .env.example with variable names and safe placeholder values so CI and contributors know what must be configured.",
|
|
24
|
+
heuristic: true
|
|
25
|
+
}
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isEnvExampleFile, isEnvFile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: "env.env-file-tracked",
|
|
5
|
+
title: "Environment files must not be tracked",
|
|
6
|
+
category: "env",
|
|
7
|
+
severity: "critical",
|
|
8
|
+
tags: ["secrets", "git", "node"],
|
|
9
|
+
run: async (context) => {
|
|
10
|
+
if (!context.gitAvailable) return [];
|
|
11
|
+
|
|
12
|
+
return context.gitTrackedFiles
|
|
13
|
+
.filter((file) => isEnvFile(file) && !isEnvExampleFile(file))
|
|
14
|
+
.map((file) => ({
|
|
15
|
+
message: `${file} appears to be tracked by git. Secrets may be exposed.`,
|
|
16
|
+
file,
|
|
17
|
+
recommendation: "Remove it from git with git rm --cached, rotate any exposed secrets, and commit .env.example instead."
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isCodeLikeFile, isLockfile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const PUBLIC_SECRET_RE = /\b((?:VITE_|NEXT_PUBLIC_|PUBLIC_)[A-Z0-9_]*(?:SECRET|TOKEN|PRIVATE|KEY|SERVICE_ROLE|DATABASE_URL)[A-Z0-9_]*)\b/g;
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: "env.frontend-secret-exposure",
|
|
7
|
+
title: "Frontend-public environment variables should not look secret",
|
|
8
|
+
category: "env",
|
|
9
|
+
severity: "high",
|
|
10
|
+
tags: ["secrets", "frontend", "env"],
|
|
11
|
+
run: async (context) => {
|
|
12
|
+
const findings = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
|
|
15
|
+
for (const file of context.textFiles) {
|
|
16
|
+
if (isLockfile(file) || !isCodeLikeFile(file)) continue;
|
|
17
|
+
const content = await context.readFileSafe(file);
|
|
18
|
+
if (!content) continue;
|
|
19
|
+
|
|
20
|
+
const lines = content.split(/\r?\n/);
|
|
21
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
22
|
+
PUBLIC_SECRET_RE.lastIndex = 0;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = PUBLIC_SECRET_RE.exec(lines[index])) !== null) {
|
|
25
|
+
const envName = match[1];
|
|
26
|
+
if (/_RE$|_REGEX$/.test(envName)) continue;
|
|
27
|
+
const key = `${file}:${index + 1}:${envName}`;
|
|
28
|
+
if (seen.has(key)) continue;
|
|
29
|
+
seen.add(key);
|
|
30
|
+
findings.push({
|
|
31
|
+
message: `Potential frontend-exposed secret-like environment variable ${envName} appears in client-visible code or config.`,
|
|
32
|
+
file,
|
|
33
|
+
line: index + 1,
|
|
34
|
+
recommendation: "Do not expose secrets through VITE_, NEXT_PUBLIC_, or PUBLIC_ variables. Move secret operations to server-side code.",
|
|
35
|
+
heuristic: true,
|
|
36
|
+
metadata: { envName }
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return findings.slice(0, 100);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const SECRET_NAMES = [
|
|
4
|
+
"OPENAI_API_KEY",
|
|
5
|
+
"STRIPE_SECRET_KEY",
|
|
6
|
+
"GITHUB_TOKEN",
|
|
7
|
+
"JWT_SECRET",
|
|
8
|
+
"PRIVATE_KEY",
|
|
9
|
+
"DATABASE_URL",
|
|
10
|
+
"SUPABASE_SERVICE_ROLE_KEY",
|
|
11
|
+
"AWS_SECRET_ACCESS_KEY"
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const PLACEHOLDER_RE = /^(your_|example|changeme|change_me|todo|test|dummy|xxx|placeholder|<|""|''|null|undefined)/i;
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
id: "env.possible-secret-in-code",
|
|
18
|
+
title: "Possible hardcoded secrets should not appear in source",
|
|
19
|
+
category: "env",
|
|
20
|
+
severity: "critical",
|
|
21
|
+
tags: ["secrets", "static-analysis"],
|
|
22
|
+
run: async (context) => {
|
|
23
|
+
const findings = [];
|
|
24
|
+
|
|
25
|
+
for (const file of context.textFiles) {
|
|
26
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
27
|
+
|
|
28
|
+
const content = await context.readFileSafe(file);
|
|
29
|
+
if (!content) continue;
|
|
30
|
+
const lines = content.split(/\r?\n/);
|
|
31
|
+
|
|
32
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
33
|
+
const line = lines[index];
|
|
34
|
+
|
|
35
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(line)) {
|
|
36
|
+
findings.push(secretFinding(file, index + 1, "PRIVATE_KEY"));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const secretName of SECRET_NAMES) {
|
|
41
|
+
const regex = new RegExp(`(?:^|[\\s{,;])["']?(${escapeRegExp(secretName)})["']?\\s*(?:=|:)\\s*["']?([^"'\\s,;]+)`, "i");
|
|
42
|
+
const match = line.match(regex);
|
|
43
|
+
if (!match) continue;
|
|
44
|
+
|
|
45
|
+
const possibleValue = match[2] || "";
|
|
46
|
+
if (PLACEHOLDER_RE.test(possibleValue) || /process\.env|import\.meta\.env/i.test(possibleValue)) continue;
|
|
47
|
+
findings.push(secretFinding(file, index + 1, secretName));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return findings.slice(0, 100);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function secretFinding(file, line, secretType) {
|
|
57
|
+
return {
|
|
58
|
+
message: `A possible ${secretType} value appears to be hardcoded. The value was not printed.`,
|
|
59
|
+
file,
|
|
60
|
+
line,
|
|
61
|
+
recommendation: "Move the secret to a secure runtime secret store or CI secret, rotate it if it was committed, and keep only safe placeholders in examples.",
|
|
62
|
+
heuristic: true,
|
|
63
|
+
metadata: {
|
|
64
|
+
secretType,
|
|
65
|
+
valueRedacted: true
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function escapeRegExp(value) {
|
|
71
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
72
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const REQUIRED_ENTRIES = [
|
|
2
|
+
"node_modules/",
|
|
3
|
+
".env",
|
|
4
|
+
".env.*",
|
|
5
|
+
"dist/",
|
|
6
|
+
"build/",
|
|
7
|
+
".next/",
|
|
8
|
+
"coverage/",
|
|
9
|
+
"*.log",
|
|
10
|
+
"*.sqlite",
|
|
11
|
+
"*.db",
|
|
12
|
+
".DS_Store"
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
id: "git.gitignore-incomplete",
|
|
17
|
+
title: ".gitignore should cover common generated and secret files",
|
|
18
|
+
category: "git",
|
|
19
|
+
severity: "medium",
|
|
20
|
+
tags: ["git", "repo-hygiene", "secrets"],
|
|
21
|
+
run: async (context) => {
|
|
22
|
+
const content = await context.readFileSafe(".gitignore");
|
|
23
|
+
if (!content) return [];
|
|
24
|
+
|
|
25
|
+
const lines = content
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.map((line) => line.trim())
|
|
28
|
+
.filter((line) => line && !line.startsWith("#") && !line.startsWith("!"));
|
|
29
|
+
|
|
30
|
+
const missing = REQUIRED_ENTRIES.filter((entry) => !hasEquivalentEntry(lines, entry));
|
|
31
|
+
if (missing.length === 0) return [];
|
|
32
|
+
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
message: `.gitignore appears to be missing common entries: ${missing.join(", ")}.`,
|
|
36
|
+
file: ".gitignore",
|
|
37
|
+
recommendation: "Add the missing ignore patterns so local secrets, dependencies, logs, databases, and build output are not committed.",
|
|
38
|
+
metadata: { missing }
|
|
39
|
+
}
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function hasEquivalentEntry(lines, entry) {
|
|
45
|
+
const variants = new Set([entry, entry.replace(/\/$/, ""), entry.endsWith("/") ? `${entry}**` : entry]);
|
|
46
|
+
return lines.some((line) => variants.has(line));
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
id: "git.gitignore-missing",
|
|
3
|
+
title: ".gitignore should exist",
|
|
4
|
+
category: "git",
|
|
5
|
+
severity: "medium",
|
|
6
|
+
tags: ["git", "repo-hygiene"],
|
|
7
|
+
run: async (context) => {
|
|
8
|
+
if (await context.fileExists(".gitignore")) return [];
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
message: "The repository appears to be missing a .gitignore file.",
|
|
12
|
+
recommendation: "Add a .gitignore that excludes dependencies, build output, local env files, logs, and local databases."
|
|
13
|
+
}
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { matchesAnyGlob } from "../../utils/path.js";
|
|
2
|
+
import { isEnvExampleFile } from "../helpers.js";
|
|
3
|
+
|
|
4
|
+
const RISKY_TRACKED_PATTERNS = [
|
|
5
|
+
".env",
|
|
6
|
+
".env.*",
|
|
7
|
+
"node_modules/**",
|
|
8
|
+
"dist/**",
|
|
9
|
+
"build/**",
|
|
10
|
+
".next/**",
|
|
11
|
+
"coverage/**",
|
|
12
|
+
"*.sqlite",
|
|
13
|
+
"*.db",
|
|
14
|
+
"*.log"
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
id: "git.ignored-files-tracked",
|
|
19
|
+
title: "Ignored files should not be tracked by git",
|
|
20
|
+
category: "git",
|
|
21
|
+
severity: "high",
|
|
22
|
+
tags: ["git", "repo-hygiene", "secrets"],
|
|
23
|
+
run: async (context) => {
|
|
24
|
+
if (!context.gitAvailable) return [];
|
|
25
|
+
|
|
26
|
+
const candidates = new Set(context.gitIgnoredTrackedFiles || []);
|
|
27
|
+
for (const file of context.gitTrackedFiles) {
|
|
28
|
+
if (isEnvExampleFile(file)) continue;
|
|
29
|
+
if (matchesAnyGlob(file, RISKY_TRACKED_PATTERNS)) candidates.add(file);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [...candidates].slice(0, 50).map((file) => ({
|
|
33
|
+
message: `${file} appears to be tracked even though it matches an ignore or high-risk generated-file pattern.`,
|
|
34
|
+
file,
|
|
35
|
+
recommendation: "Remove generated or local-only files from git with git rm --cached, then commit the corrected .gitignore."
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { matchesGlob } from "../utils/path.js";
|
|
3
|
+
|
|
4
|
+
export const CODE_FILE_EXTENSIONS = new Set([
|
|
5
|
+
".js",
|
|
6
|
+
".jsx",
|
|
7
|
+
".mjs",
|
|
8
|
+
".cjs",
|
|
9
|
+
".ts",
|
|
10
|
+
".tsx",
|
|
11
|
+
".vue",
|
|
12
|
+
".svelte",
|
|
13
|
+
".astro",
|
|
14
|
+
".html",
|
|
15
|
+
".css",
|
|
16
|
+
".json",
|
|
17
|
+
".yml",
|
|
18
|
+
".yaml",
|
|
19
|
+
".env"
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function isCodeLikeFile(file) {
|
|
23
|
+
if (isLockfile(file)) return false;
|
|
24
|
+
const extension = path.extname(file);
|
|
25
|
+
return CODE_FILE_EXTENSIONS.has(extension) || file.includes(".env");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isLockfile(file) {
|
|
29
|
+
return ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"].includes(file);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isEnvFile(file) {
|
|
33
|
+
const base = path.basename(file);
|
|
34
|
+
return base === ".env" || (base.startsWith(".env.") && !isEnvExampleFile(file));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isEnvExampleFile(file) {
|
|
38
|
+
const base = path.basename(file);
|
|
39
|
+
return [".env.example", ".env.sample", ".env.template", ".env.defaults"].includes(base);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isExpressProject(context) {
|
|
43
|
+
return context.hasDependency("express") || context.textFiles.some((file) => file.endsWith(".js") || file.endsWith(".ts"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function hasText(context, regex, options = {}) {
|
|
47
|
+
const files = options.files || context.textFiles;
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const content = await context.readFileSafe(file);
|
|
50
|
+
if (content && regex.test(content)) {
|
|
51
|
+
regex.lastIndex = 0;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
regex.lastIndex = 0;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function collectCiFiles(context) {
|
|
60
|
+
return context.textFiles.filter((file) => {
|
|
61
|
+
return (
|
|
62
|
+
matchesGlob(file, ".github/workflows/*.yml") ||
|
|
63
|
+
matchesGlob(file, ".github/workflows/*.yaml") ||
|
|
64
|
+
file === ".gitlab-ci.yml" ||
|
|
65
|
+
file === ".circleci/config.yml" ||
|
|
66
|
+
file === "Jenkinsfile" ||
|
|
67
|
+
file.startsWith("ci/") ||
|
|
68
|
+
file.startsWith(".buildkite/")
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readNearby(context, file, line, radius = 6) {
|
|
74
|
+
const content = await context.readFileSafe(file);
|
|
75
|
+
if (!content) return "";
|
|
76
|
+
const lines = content.split(/\r?\n/);
|
|
77
|
+
const start = Math.max(0, line - radius - 1);
|
|
78
|
+
const end = Math.min(lines.length, line + radius);
|
|
79
|
+
return lines.slice(start, end).join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hasAuthKeyword(text) {
|
|
83
|
+
return /\b(auth|authenticate|authenticated|authorization|authorize|requireAuth|requireUser|session|passport|jwt|bearer|getServerSession|clerk|supabase\.auth|currentUser|isAuthenticated)\b/i.test(
|
|
84
|
+
text
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function hasOwnerKeyword(text) {
|
|
89
|
+
return /\b(userId|ownerId|accountId|tenantId|orgId|organizationId|workspaceId|teamId|createdBy|authorId)\b/i.test(text);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function lineFromOffset(content, offset) {
|
|
93
|
+
return content.slice(0, offset).split(/\r?\n/).length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isFrontendFile(file) {
|
|
97
|
+
return /\.(jsx|tsx|vue|svelte|astro)$/.test(file) || file.startsWith("src/components/") || file.startsWith("src/pages/");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isServerOrApiFile(file) {
|
|
101
|
+
return (
|
|
102
|
+
file.includes("/api/") ||
|
|
103
|
+
file.includes("/routes/") ||
|
|
104
|
+
file.includes("/server/") ||
|
|
105
|
+
file.includes("/middleware") ||
|
|
106
|
+
file.startsWith("pages/api/") ||
|
|
107
|
+
file.startsWith("app/api/") ||
|
|
108
|
+
/server\.[cm]?[jt]s$/.test(file) ||
|
|
109
|
+
/app\.[cm]?[jt]s$/.test(file)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseJsonWithComments(content) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(content);
|
|
116
|
+
} catch {
|
|
117
|
+
const withoutComments = content
|
|
118
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
119
|
+
.replace(/(^|\s)\/\/.*$/gm, "$1");
|
|
120
|
+
return JSON.parse(withoutComments);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import gitignoreMissing from "./git/gitignore-missing.js";
|
|
2
|
+
import gitignoreIncomplete from "./git/gitignore-incomplete.js";
|
|
3
|
+
import ignoredFilesTracked from "./git/ignored-files-tracked.js";
|
|
4
|
+
import envFileTracked from "./env/env-file-tracked.js";
|
|
5
|
+
import envExampleMissing from "./env/env-example-missing.js";
|
|
6
|
+
import possibleSecretInCode from "./env/possible-secret-in-code.js";
|
|
7
|
+
import frontendSecretExposure from "./env/frontend-secret-exposure.js";
|
|
8
|
+
import lockfileMissing from "./dependencies/lockfile-missing.js";
|
|
9
|
+
import multipleLockfiles from "./dependencies/multiple-lockfiles.js";
|
|
10
|
+
import installScriptsRisk from "./dependencies/install-scripts-risk.js";
|
|
11
|
+
import auditScriptMissing from "./dependencies/audit-script-missing.js";
|
|
12
|
+
import packageScriptsMissing from "./package/scripts-missing.js";
|
|
13
|
+
import noCiConfig from "./ci/no-ci-config.js";
|
|
14
|
+
import npmInstallInsteadOfNpmCi from "./ci/npm-install-instead-of-npm-ci.js";
|
|
15
|
+
import noBuildStep from "./ci/no-build-step.js";
|
|
16
|
+
import noTestStep from "./ci/no-test-step.js";
|
|
17
|
+
import expressJsonLimitMissing from "./node/express-json-limit-missing.js";
|
|
18
|
+
import rateLimitMissing from "./node/rate-limit-missing.js";
|
|
19
|
+
import helmetMissing from "./node/helmet-missing.js";
|
|
20
|
+
import corsWildcard from "./node/cors-wildcard.js";
|
|
21
|
+
import clientSideAuthOnly from "./web/client-side-auth-only.js";
|
|
22
|
+
import dangerousInnerHtml from "./web/dangerous-inner-html.js";
|
|
23
|
+
import missingOutputSanitization from "./web/missing-output-sanitization.js";
|
|
24
|
+
import missingAuthOnRoutes from "./auth/missing-auth-on-routes.js";
|
|
25
|
+
import idorRisk from "./auth/idor-risk.js";
|
|
26
|
+
import rawSqlInterpolation from "./database/raw-sql-interpolation.js";
|
|
27
|
+
import noMigrations from "./database/no-migrations.js";
|
|
28
|
+
import electronNodeIntegrationEnabled from "./electron/node-integration-enabled.js";
|
|
29
|
+
import electronContextIsolationDisabled from "./electron/context-isolation-disabled.js";
|
|
30
|
+
import tauriDangerousAllowlistOrCapabilities from "./tauri/dangerous-allowlist-or-capabilities.js";
|
|
31
|
+
|
|
32
|
+
export default [
|
|
33
|
+
gitignoreMissing,
|
|
34
|
+
gitignoreIncomplete,
|
|
35
|
+
ignoredFilesTracked,
|
|
36
|
+
envFileTracked,
|
|
37
|
+
envExampleMissing,
|
|
38
|
+
possibleSecretInCode,
|
|
39
|
+
frontendSecretExposure,
|
|
40
|
+
lockfileMissing,
|
|
41
|
+
multipleLockfiles,
|
|
42
|
+
installScriptsRisk,
|
|
43
|
+
auditScriptMissing,
|
|
44
|
+
packageScriptsMissing,
|
|
45
|
+
noCiConfig,
|
|
46
|
+
npmInstallInsteadOfNpmCi,
|
|
47
|
+
noBuildStep,
|
|
48
|
+
noTestStep,
|
|
49
|
+
expressJsonLimitMissing,
|
|
50
|
+
rateLimitMissing,
|
|
51
|
+
helmetMissing,
|
|
52
|
+
corsWildcard,
|
|
53
|
+
clientSideAuthOnly,
|
|
54
|
+
dangerousInnerHtml,
|
|
55
|
+
missingOutputSanitization,
|
|
56
|
+
missingAuthOnRoutes,
|
|
57
|
+
idorRisk,
|
|
58
|
+
rawSqlInterpolation,
|
|
59
|
+
noMigrations,
|
|
60
|
+
electronNodeIntegrationEnabled,
|
|
61
|
+
electronContextIsolationDisabled,
|
|
62
|
+
tauriDangerousAllowlistOrCapabilities
|
|
63
|
+
];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
id: "node.cors-wildcard",
|
|
3
|
+
title: "CORS should not be broadly open by default",
|
|
4
|
+
category: "node",
|
|
5
|
+
severity: "high",
|
|
6
|
+
tags: ["node", "cors", "api"],
|
|
7
|
+
run: async (context) => {
|
|
8
|
+
const findings = [];
|
|
9
|
+
const patterns = [
|
|
10
|
+
{ regex: /origin\s*:\s*["']\*["']/g, label: "origin: \"*\"" },
|
|
11
|
+
{ regex: /Access-Control-Allow-Origin["']?\s*,\s*["']\*["']/g, label: "Access-Control-Allow-Origin: *" },
|
|
12
|
+
{ regex: /\bcors\s*\(\s*\)/g, label: "cors() with default open origin" },
|
|
13
|
+
{ regex: /origin\s*:\s*true/g, label: "origin: true" }
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
for (const pattern of patterns) {
|
|
17
|
+
const matches = await context.grep(pattern.regex, {
|
|
18
|
+
include: ["*.js", "*.ts", "*.mjs", "*.cjs"],
|
|
19
|
+
maxMatches: 100
|
|
20
|
+
});
|
|
21
|
+
for (const match of matches) {
|
|
22
|
+
findings.push({
|
|
23
|
+
message: `CORS configuration appears broadly open (${pattern.label}).`,
|
|
24
|
+
file: match.file,
|
|
25
|
+
line: match.line,
|
|
26
|
+
column: match.column,
|
|
27
|
+
recommendation: "Restrict CORS origins to the exact trusted application origins and handle credentials carefully.",
|
|
28
|
+
heuristic: true
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return findings;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { hasText } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: "node.express-json-limit-missing",
|
|
5
|
+
title: "express.json should set a body size limit",
|
|
6
|
+
category: "node",
|
|
7
|
+
severity: "medium",
|
|
8
|
+
tags: ["node", "express", "availability"],
|
|
9
|
+
run: async (context) => {
|
|
10
|
+
const expressDetected = context.hasDependency("express") || (await hasText(context, /\bfrom\s+["']express["']|\brequire\(["']express["']\)/g));
|
|
11
|
+
if (!expressDetected) return [];
|
|
12
|
+
|
|
13
|
+
const findings = [];
|
|
14
|
+
for (const match of await context.grep(/express\.json\s*\(([^)]*)\)/g, {
|
|
15
|
+
include: ["*.js", "*.ts", "*.mjs", "*.cjs"],
|
|
16
|
+
maxMatches: 100
|
|
17
|
+
})) {
|
|
18
|
+
const args = match.match[1] || "";
|
|
19
|
+
if (/\blimit\s*:/.test(args)) continue;
|
|
20
|
+
findings.push({
|
|
21
|
+
message: "express.json() appears to be used without an explicit request body size limit.",
|
|
22
|
+
file: match.file,
|
|
23
|
+
line: match.line,
|
|
24
|
+
column: match.column,
|
|
25
|
+
recommendation: "Set a conservative limit, for example express.json({ limit: '100kb' }), and tune it per route when needed."
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return findings;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { hasText } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: "node.helmet-missing",
|
|
5
|
+
title: "Express apps should use Helmet or equivalent security headers",
|
|
6
|
+
category: "node",
|
|
7
|
+
severity: "medium",
|
|
8
|
+
tags: ["node", "express", "headers"],
|
|
9
|
+
run: async (context) => {
|
|
10
|
+
const expressDetected = context.hasDependency("express") || (await hasText(context, /\bfrom\s+["']express["']|\brequire\(["']express["']\)/g));
|
|
11
|
+
if (!expressDetected) return [];
|
|
12
|
+
if (context.hasDependency("helmet") || context.hasDevDependency("helmet")) return [];
|
|
13
|
+
if (await hasText(context, /\bhelmet\s*\(/g)) return [];
|
|
14
|
+
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
message: "Express appears to be used, but Helmet was not detected.",
|
|
18
|
+
recommendation: "Install and use helmet() early in the middleware stack, or document equivalent security header handling."
|
|
19
|
+
}
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
};
|