itworksbut 0.4.0 → 0.6.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/README.md +35 -31
- package/bin/itworksbut.js +9 -0
- package/package.json +1 -1
- package/src/checks/api/mass-assignment-risk.js +81 -0
- package/src/checks/api/missing-method-guard.js +68 -0
- package/src/checks/api/no-schema-validation.js +68 -0
- package/src/checks/auth/missing-csrf-protection.js +75 -0
- package/src/checks/dependencies/outdated-packages.js +297 -0
- package/src/checks/electron/remote-content-with-node.js +52 -0
- package/src/checks/files/path-traversal-risk.js +62 -0
- package/src/checks/frontend/localstorage-token.js +42 -0
- package/src/checks/index.js +23 -1
- package/src/checks/next/public-server-code-risk.js +64 -0
- package/src/checks/ssrf/user-controlled-fetch.js +60 -0
- package/src/checks/tauri/remote-url-permissions-risk.js +115 -0
- package/src/cli/output.js +9 -9
- package/src/cli/parseArgs.js +3 -1
- package/src/core/checkResults.js +53 -0
- package/src/core/scanner.js +33 -4
- package/src/reporters/consoleReporter.js +42 -1
- package/src/reporters/consoleStyle.js +30 -0
- package/src/reporters/jsonReporter.js +3 -0
- package/src/reporters/markdownReport.js +203 -0
- package/src/utils/packageManager.js +28 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { detectOutdatedPackageManager, getOutdatedCommand } from "../../utils/packageManager.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const CHECK_ID = "dependencies.outdated-packages";
|
|
7
|
+
const CHECK_TITLE = "Outdated packages";
|
|
8
|
+
const COMMAND_TIMEOUT_MS = 30_000;
|
|
9
|
+
const COMMAND_MAX_BUFFER = 10 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
id: CHECK_ID,
|
|
13
|
+
title: CHECK_TITLE,
|
|
14
|
+
category: "dependencies",
|
|
15
|
+
severity: "info",
|
|
16
|
+
tags: ["dependencies", "maintenance"],
|
|
17
|
+
run: async (context) => {
|
|
18
|
+
const result = await runOutdatedPackagesCheck(context);
|
|
19
|
+
return { findings: [], result };
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function runOutdatedPackagesCheck(context, options = {}) {
|
|
24
|
+
const detected = detectOutdatedPackageManager({
|
|
25
|
+
packageJson: context.packageJson,
|
|
26
|
+
files: context.allFiles
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (detected.status === "skip") {
|
|
30
|
+
return checkResult({
|
|
31
|
+
status: "skip",
|
|
32
|
+
summary: detected.summary,
|
|
33
|
+
metadata: { reason: "missing-package-json" }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const commandResult = await runOutdatedCommand(detected.manager, context.rootPath, options);
|
|
38
|
+
if (isMissingCommand(commandResult)) {
|
|
39
|
+
return checkResult({
|
|
40
|
+
status: "fail",
|
|
41
|
+
summary: `${detected.manager} is not installed or could not be found.`,
|
|
42
|
+
details: [
|
|
43
|
+
{
|
|
44
|
+
message: `${detected.manager} is not installed or could not be found.`,
|
|
45
|
+
command: renderCommand(commandResult),
|
|
46
|
+
exitCode: commandResult.exitCode
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
metadata: { packageManager: detected.manager }
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parsed = parseOutdatedOutput(detected.manager, commandResult.stdout, context.packageJson);
|
|
54
|
+
if (!parsed.ok) {
|
|
55
|
+
if (!hasCommandFailure(commandResult) && isEmptyOutput(commandResult.stdout)) {
|
|
56
|
+
return checkResult({
|
|
57
|
+
status: "pass",
|
|
58
|
+
summary: "all dependencies are up to date",
|
|
59
|
+
details: [],
|
|
60
|
+
metadata: { packageManager: detected.manager }
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return checkResult({
|
|
65
|
+
status: "fail",
|
|
66
|
+
summary: `${detected.manager} outdated returned output that could not be parsed.`,
|
|
67
|
+
details: [
|
|
68
|
+
{
|
|
69
|
+
message: parsed.error,
|
|
70
|
+
command: renderCommand(commandResult),
|
|
71
|
+
exitCode: commandResult.exitCode,
|
|
72
|
+
stderr: trimText(commandResult.stderr)
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
metadata: { packageManager: detected.manager }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if ((hasCommandFailure(commandResult) || hasEmptyExitOneFailure(commandResult)) && parsed.packages.length === 0) {
|
|
80
|
+
return checkResult({
|
|
81
|
+
status: "fail",
|
|
82
|
+
summary: `${detected.manager} outdated failed.`,
|
|
83
|
+
details: [
|
|
84
|
+
{
|
|
85
|
+
message: trimText(commandResult.stderr) || "Command exited with code " + commandResult.exitCode + ".",
|
|
86
|
+
command: renderCommand(commandResult),
|
|
87
|
+
exitCode: commandResult.exitCode
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
metadata: { packageManager: detected.manager }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (parsed.packages.length === 0) {
|
|
95
|
+
return checkResult({
|
|
96
|
+
status: "pass",
|
|
97
|
+
summary: "all dependencies are up to date",
|
|
98
|
+
details: [],
|
|
99
|
+
metadata: { packageManager: detected.manager }
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return checkResult({
|
|
104
|
+
status: "warn",
|
|
105
|
+
summary: `${parsed.packages.length} ${parsed.packages.length === 1 ? "package" : "packages"} outdated`,
|
|
106
|
+
details: parsed.packages,
|
|
107
|
+
metadata: { packageManager: detected.manager }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function runOutdatedCommand(manager, rootPath, options = {}) {
|
|
112
|
+
const runCommand = options.runCommand || execCommand;
|
|
113
|
+
const { command, args } = getOutdatedCommand(manager);
|
|
114
|
+
return await runCommand(command, args, { cwd: rootPath });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function parseOutdatedOutput(manager, stdout, packageJson = {}) {
|
|
118
|
+
const output = String(stdout || "").trim();
|
|
119
|
+
if (!output) return { ok: true, packages: [] };
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if (manager === "yarn") {
|
|
123
|
+
return { ok: true, packages: parseYarnOutput(output, packageJson) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const parsed = JSON.parse(output);
|
|
127
|
+
return { ok: true, packages: normalizeOutdatedData(parsed, packageJson) };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
packages: [],
|
|
132
|
+
error: error instanceof Error ? error.message : String(error)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function normalizeOutdatedData(data, packageJson = {}) {
|
|
138
|
+
if (!data || typeof data !== "object") return [];
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(data)) {
|
|
141
|
+
return data
|
|
142
|
+
.map((entry) => normalizePackageEntry(entry.name || entry.package, entry, packageJson))
|
|
143
|
+
.filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return Object.entries(data)
|
|
147
|
+
.map(([name, entry]) => normalizePackageEntry(name, entry, packageJson))
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function execCommand(command, args, options) {
|
|
152
|
+
try {
|
|
153
|
+
const result = await execFileAsync(command, args, {
|
|
154
|
+
cwd: options.cwd,
|
|
155
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
156
|
+
maxBuffer: COMMAND_MAX_BUFFER
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
command,
|
|
160
|
+
args,
|
|
161
|
+
stdout: result.stdout || "",
|
|
162
|
+
stderr: result.stderr || "",
|
|
163
|
+
exitCode: 0
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return {
|
|
167
|
+
command,
|
|
168
|
+
args,
|
|
169
|
+
stdout: error?.stdout || "",
|
|
170
|
+
stderr: error?.stderr || error?.message || "",
|
|
171
|
+
exitCode: error?.code ?? null,
|
|
172
|
+
signal: error?.signal,
|
|
173
|
+
error
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseYarnOutput(output, packageJson) {
|
|
179
|
+
const directJson = tryJson(output);
|
|
180
|
+
if (directJson.ok) {
|
|
181
|
+
if (directJson.value?.type === "table") return parseYarnTable(directJson.value.data, packageJson);
|
|
182
|
+
return normalizeOutdatedData(directJson.value, packageJson);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const records = output
|
|
186
|
+
.split(/\r?\n/)
|
|
187
|
+
.map((line) => line.trim())
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.map((line) => tryJson(line))
|
|
190
|
+
.filter((record) => record.ok)
|
|
191
|
+
.map((record) => record.value);
|
|
192
|
+
|
|
193
|
+
const tableRecord = records.find((record) => record?.type === "table" && record.data);
|
|
194
|
+
if (tableRecord) return parseYarnTable(tableRecord.data, packageJson);
|
|
195
|
+
|
|
196
|
+
const packageRecords = records
|
|
197
|
+
.filter((record) => record?.data && typeof record.data === "object")
|
|
198
|
+
.map((record) => record.data);
|
|
199
|
+
if (packageRecords.length > 0) return normalizeOutdatedData(packageRecords, packageJson);
|
|
200
|
+
|
|
201
|
+
throw new Error("No parseable Yarn outdated JSON records were found.");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseYarnTable(data, packageJson) {
|
|
205
|
+
const head = Array.isArray(data?.head) ? data.head.map((value) => String(value).toLowerCase()) : [];
|
|
206
|
+
const body = Array.isArray(data?.body) ? data.body : [];
|
|
207
|
+
|
|
208
|
+
return body.map((row) => {
|
|
209
|
+
const entry = {
|
|
210
|
+
name: row[indexOf(head, "package", 0)],
|
|
211
|
+
current: row[indexOf(head, "current", 1)],
|
|
212
|
+
wanted: row[indexOf(head, "wanted", 2)],
|
|
213
|
+
latest: row[indexOf(head, "latest", 3)],
|
|
214
|
+
type: row[indexOf(head, "package type", 4)]
|
|
215
|
+
};
|
|
216
|
+
return normalizePackageEntry(entry.name, entry, packageJson);
|
|
217
|
+
}).filter(Boolean);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizePackageEntry(name, entry, packageJson) {
|
|
221
|
+
if (!name || !entry || typeof entry !== "object") return null;
|
|
222
|
+
|
|
223
|
+
const current = stringValue(entry.current ?? entry.installed ?? entry.version);
|
|
224
|
+
const wanted = stringValue(entry.wanted ?? entry.latest);
|
|
225
|
+
const latest = stringValue(entry.latest ?? entry.wanted);
|
|
226
|
+
|
|
227
|
+
if (!current && !wanted && !latest) return null;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
name: String(name),
|
|
231
|
+
current: current || "unknown",
|
|
232
|
+
wanted: wanted || "unknown",
|
|
233
|
+
latest: latest || "unknown",
|
|
234
|
+
type: stringValue(entry.type ?? entry.dependencyType ?? entry.packageType) || inferDependencyType(name, packageJson)
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function inferDependencyType(name, packageJson = {}) {
|
|
239
|
+
if (Object.hasOwn(packageJson.dependencies || {}, name)) return "dependencies";
|
|
240
|
+
if (Object.hasOwn(packageJson.devDependencies || {}, name)) return "devDependencies";
|
|
241
|
+
if (Object.hasOwn(packageJson.peerDependencies || {}, name)) return "peerDependencies";
|
|
242
|
+
if (Object.hasOwn(packageJson.optionalDependencies || {}, name)) return "optionalDependencies";
|
|
243
|
+
return "dependencies";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function checkResult(result) {
|
|
247
|
+
return {
|
|
248
|
+
id: CHECK_ID,
|
|
249
|
+
title: CHECK_TITLE,
|
|
250
|
+
category: "dependencies",
|
|
251
|
+
details: [],
|
|
252
|
+
...result
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function hasCommandFailure(result) {
|
|
257
|
+
return result.exitCode !== 0 && result.exitCode !== 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function hasEmptyExitOneFailure(result) {
|
|
261
|
+
return result.exitCode === 1 && isEmptyOutput(result.stdout) && !isEmptyOutput(result.stderr);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isMissingCommand(result) {
|
|
265
|
+
return result.exitCode === "ENOENT" || result.error?.code === "ENOENT";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isEmptyOutput(value) {
|
|
269
|
+
return String(value || "").trim() === "";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderCommand(result) {
|
|
273
|
+
return [result.command, ...(result.args || [])].filter(Boolean).join(" ");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function trimText(value) {
|
|
277
|
+
const normalized = String(value || "").trim();
|
|
278
|
+
return normalized.length > 1000 ? `${normalized.slice(0, 1000)}...` : normalized;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function tryJson(value) {
|
|
282
|
+
try {
|
|
283
|
+
return { ok: true, value: JSON.parse(value) };
|
|
284
|
+
} catch {
|
|
285
|
+
return { ok: false, value: null };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function indexOf(head, name, fallback) {
|
|
290
|
+
const index = head.indexOf(name);
|
|
291
|
+
return index === -1 ? fallback : index;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function stringValue(value) {
|
|
295
|
+
if (value === undefined || value === null || value === "") return "";
|
|
296
|
+
return String(value);
|
|
297
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { hasText, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const LOAD_REMOTE_RE =
|
|
4
|
+
/\b(?:mainWindow|win|window|BrowserWindow|\w+)\.loadURL\s*\(\s*(?:["'`]https?:\/\/[^"'`]+["'`]|remoteUrl|process\.env\.\w+|config\.url)\s*\)/g;
|
|
5
|
+
const RISKY_WEB_PREFERENCES_RE =
|
|
6
|
+
/\b(?:nodeIntegration\s*:\s*true|contextIsolation\s*:\s*false|webSecurity\s*:\s*false|allowRunningInsecureContent\s*:\s*true|experimentalFeatures\s*:\s*true|enableRemoteModule\s*:\s*true|sandbox\s*:\s*false)\b/i;
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
id: "electron.remote-content-with-node",
|
|
10
|
+
title: "Electron remote content should not run with privileged renderer settings",
|
|
11
|
+
category: "electron",
|
|
12
|
+
severity: "critical",
|
|
13
|
+
tags: ["electron", "desktop", "xss", "heuristic"],
|
|
14
|
+
run: async (context) => {
|
|
15
|
+
if (!(await isElectronProject(context))) return [];
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
for (const file of context.textFiles) {
|
|
19
|
+
if (!/\.[cm]?[jt]s$/.test(file)) continue;
|
|
20
|
+
const content = await context.readFileSafe(file);
|
|
21
|
+
if (!content || !/loadURL\s*\(/.test(content) || !RISKY_WEB_PREFERENCES_RE.test(content)) continue;
|
|
22
|
+
|
|
23
|
+
LOAD_REMOTE_RE.lastIndex = 0;
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = LOAD_REMOTE_RE.exec(content)) !== null) {
|
|
26
|
+
const line = lineFromOffset(content, match.index);
|
|
27
|
+
const nearby = await readNearby(context, file, line, 20);
|
|
28
|
+
if (!RISKY_WEB_PREFERENCES_RE.test(nearby) && !RISKY_WEB_PREFERENCES_RE.test(content)) continue;
|
|
29
|
+
|
|
30
|
+
findings.push({
|
|
31
|
+
message: "Electron appears to load remote content while enabling risky renderer privileges.",
|
|
32
|
+
file,
|
|
33
|
+
line,
|
|
34
|
+
recommendation:
|
|
35
|
+
"Avoid loading remote content with Node.js integration. Use nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true and a minimal preload bridge.",
|
|
36
|
+
heuristic: true,
|
|
37
|
+
metadata: { pattern: "remote-load-url-with-risky-web-preferences" }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return findings.slice(0, 100);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function isElectronProject(context) {
|
|
47
|
+
return (
|
|
48
|
+
context.hasDependency("electron") ||
|
|
49
|
+
context.hasDevDependency("electron") ||
|
|
50
|
+
(await hasText(context, /\b(?:from\s+["'`]electron["'`]|require\s*\(\s*["'`]electron["'`]\s*\)|BrowserWindow)\b/g))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, isServerOrApiFile, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const FILE_PATH_SINK_RE =
|
|
4
|
+
/\b(?:fs\.(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream)|res\.sendFile|reply\.sendFile|path\.(?:join|resolve)|Bun\.file|Deno\.readTextFile)\s*\(/g;
|
|
5
|
+
const REQUEST_PATH_SOURCE_RE =
|
|
6
|
+
/\breq\.(?:query|params|body)\.(?:file|path|filename|filepath|filePath)\b|\brequest\.query\b|\bsearchParams\.get\s*\(\s*["'`](?:file|path)["'`]\s*\)|\bformData\.get\s*\(\s*["'`](?:file|path)["'`]\s*\)/i;
|
|
7
|
+
const GENERIC_PATH_SOURCE_RE = /\b(?:userInput|filename|filepath|filePath|pathParam)\b/i;
|
|
8
|
+
const PATH_MITIGATION_RE =
|
|
9
|
+
/\b(?:path\.basename|allowlist|allowedPaths|sanitizeFilename|safeJoin|validatePath|rejectPathSeparators)\b|(?:\bnormalize\b[\s\S]{0,160}\bstartsWith\s*\()|(?:\bstartsWith\s*\([\s\S]{0,160}\bbaseDir\b)|(?:\.\.["'`][\s\S]{0,120}(?:includes|reject|throw|return))/i;
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
id: "files.path-traversal-risk",
|
|
13
|
+
title: "File path operations should not trust request input",
|
|
14
|
+
category: "files",
|
|
15
|
+
severity: "critical",
|
|
16
|
+
tags: ["files", "path-traversal", "heuristic"],
|
|
17
|
+
run: async (context) => {
|
|
18
|
+
const findings = [];
|
|
19
|
+
|
|
20
|
+
for (const file of context.textFiles) {
|
|
21
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
22
|
+
const content = await context.readFileSafe(file);
|
|
23
|
+
if (!content || !/\b(?:fs\.|sendFile|path\.|Bun\.file|Deno\.readTextFile)\b/.test(content)) continue;
|
|
24
|
+
|
|
25
|
+
FILE_PATH_SINK_RE.lastIndex = 0;
|
|
26
|
+
let match;
|
|
27
|
+
while ((match = FILE_PATH_SINK_RE.exec(content)) !== null) {
|
|
28
|
+
const line = lineFromOffset(content, match.index);
|
|
29
|
+
const nearby = await readNearby(context, file, line, 8);
|
|
30
|
+
if (!hasRiskyPathSource(file, nearby)) continue;
|
|
31
|
+
if (PATH_MITIGATION_RE.test(nearby)) continue;
|
|
32
|
+
|
|
33
|
+
findings.push({
|
|
34
|
+
message: "User-controlled input appears to be used in a file path operation.",
|
|
35
|
+
file,
|
|
36
|
+
line,
|
|
37
|
+
recommendation:
|
|
38
|
+
"Normalize and validate paths, use allowlists, reject traversal sequences, and ensure resolved paths stay inside an intended base directory.",
|
|
39
|
+
heuristic: true,
|
|
40
|
+
metadata: { pattern: "user-input-file-path" }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return findings.slice(0, 100);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function hasRiskyPathSource(file, nearby) {
|
|
50
|
+
if (REQUEST_PATH_SOURCE_RE.test(nearby)) return true;
|
|
51
|
+
if (!GENERIC_PATH_SOURCE_RE.test(nearby)) return false;
|
|
52
|
+
return (
|
|
53
|
+
isServerOrApiFile(file) ||
|
|
54
|
+
file.startsWith("server/") ||
|
|
55
|
+
file.startsWith("routes/") ||
|
|
56
|
+
file.startsWith("api/") ||
|
|
57
|
+
file.includes("/server/") ||
|
|
58
|
+
file.includes("/routes/") ||
|
|
59
|
+
file.includes("/controllers/") ||
|
|
60
|
+
file.includes("/handlers/")
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const STORAGE_TOKEN_RE =
|
|
4
|
+
/\b(?:window\.)?(localStorage|sessionStorage)\.setItem\s*\(\s*["'`]([^"'`]*(?:token|jwt|access|refresh|auth|bearer|session)[^"'`]*)["'`]/gi;
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
id: "frontend.localstorage-token",
|
|
8
|
+
title: "Authentication tokens should not live in browser storage by default",
|
|
9
|
+
category: "frontend",
|
|
10
|
+
severity: "high",
|
|
11
|
+
tags: ["frontend", "auth", "xss", "heuristic"],
|
|
12
|
+
run: async (context) => {
|
|
13
|
+
const findings = [];
|
|
14
|
+
|
|
15
|
+
for (const file of context.textFiles) {
|
|
16
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file)) continue;
|
|
17
|
+
const content = await context.readFileSafe(file);
|
|
18
|
+
if (!content || !/(?:localStorage|sessionStorage)\.setItem/.test(content)) continue;
|
|
19
|
+
const lines = content.split(/\r?\n/);
|
|
20
|
+
|
|
21
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
22
|
+
STORAGE_TOKEN_RE.lastIndex = 0;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = STORAGE_TOKEN_RE.exec(lines[index])) !== null) {
|
|
25
|
+
findings.push({
|
|
26
|
+
message: "Authentication tokens appear to be stored in localStorage or sessionStorage.",
|
|
27
|
+
file,
|
|
28
|
+
line: index + 1,
|
|
29
|
+
recommendation:
|
|
30
|
+
"Prefer secure, httpOnly cookies for session tokens where appropriate. If browser storage is unavoidable, minimize token lifetime and harden XSS protections.",
|
|
31
|
+
heuristic: true,
|
|
32
|
+
metadata: {
|
|
33
|
+
pattern: `${match[1]}.setItem(auth-token-key)`
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return findings.slice(0, 100);
|
|
41
|
+
}
|
|
42
|
+
};
|
package/src/checks/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import lockfileMissing from "./dependencies/lockfile-missing.js";
|
|
|
10
10
|
import multipleLockfiles from "./dependencies/multiple-lockfiles.js";
|
|
11
11
|
import installScriptsRisk from "./dependencies/install-scripts-risk.js";
|
|
12
12
|
import auditScriptMissing from "./dependencies/audit-script-missing.js";
|
|
13
|
+
import outdatedPackages from "./dependencies/outdated-packages.js";
|
|
13
14
|
import packageScriptsMissing from "./package/scripts-missing.js";
|
|
14
15
|
import noCiConfig from "./ci/no-ci-config.js";
|
|
15
16
|
import npmInstallInsteadOfNpmCi from "./ci/npm-install-instead-of-npm-ci.js";
|
|
@@ -27,6 +28,10 @@ import missingAuthOnRoutes from "./auth/missing-auth-on-routes.js";
|
|
|
27
28
|
import idorRisk from "./auth/idor-risk.js";
|
|
28
29
|
import jwtSecretWeakOrFallback from "./auth/jwt-secret-weak-or-fallback.js";
|
|
29
30
|
import passwordHashingMissing from "./auth/password-hashing-missing.js";
|
|
31
|
+
import missingCsrfProtection from "./auth/missing-csrf-protection.js";
|
|
32
|
+
import missingMethodGuard from "./api/missing-method-guard.js";
|
|
33
|
+
import massAssignmentRisk from "./api/mass-assignment-risk.js";
|
|
34
|
+
import noSchemaValidation from "./api/no-schema-validation.js";
|
|
30
35
|
import rawSqlInterpolation from "./database/raw-sql-interpolation.js";
|
|
31
36
|
import noMigrations from "./database/no-migrations.js";
|
|
32
37
|
import insecureSessionCookie from "./cookies/insecure-session-cookie.js";
|
|
@@ -34,10 +39,16 @@ import publicExecutableUpload from "./uploads/public-executable-upload.js";
|
|
|
34
39
|
import missingRawBody from "./webhooks/missing-raw-body.js";
|
|
35
40
|
import promptInjectionRisk from "./llm/prompt-injection-risk.js";
|
|
36
41
|
import sourceMapsProduction from "./frontend/sourcemaps-production.js";
|
|
42
|
+
import localstorageToken from "./frontend/localstorage-token.js";
|
|
43
|
+
import pathTraversalRisk from "./files/path-traversal-risk.js";
|
|
44
|
+
import userControlledFetch from "./ssrf/user-controlled-fetch.js";
|
|
45
|
+
import nextPublicServerCodeRisk from "./next/public-server-code-risk.js";
|
|
37
46
|
import debugProduction from "./config/debug-production.js";
|
|
38
47
|
import electronNodeIntegrationEnabled from "./electron/node-integration-enabled.js";
|
|
39
48
|
import electronContextIsolationDisabled from "./electron/context-isolation-disabled.js";
|
|
49
|
+
import electronRemoteContentWithNode from "./electron/remote-content-with-node.js";
|
|
40
50
|
import tauriDangerousAllowlistOrCapabilities from "./tauri/dangerous-allowlist-or-capabilities.js";
|
|
51
|
+
import tauriRemoteUrlPermissionsRisk from "./tauri/remote-url-permissions-risk.js";
|
|
41
52
|
|
|
42
53
|
export default [
|
|
43
54
|
gitignoreMissing,
|
|
@@ -52,6 +63,7 @@ export default [
|
|
|
52
63
|
multipleLockfiles,
|
|
53
64
|
installScriptsRisk,
|
|
54
65
|
auditScriptMissing,
|
|
66
|
+
outdatedPackages,
|
|
55
67
|
packageScriptsMissing,
|
|
56
68
|
noCiConfig,
|
|
57
69
|
npmInstallInsteadOfNpmCi,
|
|
@@ -69,6 +81,10 @@ export default [
|
|
|
69
81
|
idorRisk,
|
|
70
82
|
jwtSecretWeakOrFallback,
|
|
71
83
|
passwordHashingMissing,
|
|
84
|
+
missingCsrfProtection,
|
|
85
|
+
missingMethodGuard,
|
|
86
|
+
massAssignmentRisk,
|
|
87
|
+
noSchemaValidation,
|
|
72
88
|
rawSqlInterpolation,
|
|
73
89
|
noMigrations,
|
|
74
90
|
insecureSessionCookie,
|
|
@@ -76,8 +92,14 @@ export default [
|
|
|
76
92
|
missingRawBody,
|
|
77
93
|
promptInjectionRisk,
|
|
78
94
|
sourceMapsProduction,
|
|
95
|
+
localstorageToken,
|
|
96
|
+
pathTraversalRisk,
|
|
97
|
+
userControlledFetch,
|
|
98
|
+
nextPublicServerCodeRisk,
|
|
79
99
|
debugProduction,
|
|
80
100
|
electronNodeIntegrationEnabled,
|
|
81
101
|
electronContextIsolationDisabled,
|
|
82
|
-
|
|
102
|
+
electronRemoteContentWithNode,
|
|
103
|
+
tauriDangerousAllowlistOrCapabilities,
|
|
104
|
+
tauriRemoteUrlPermissionsRisk
|
|
83
105
|
];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { hasText, isEnvExampleFile, isLockfile, lineFromOffset } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const CLIENT_DIRECTIVE_RE = /^\s*["'`]use client["'`]\s*;?/m;
|
|
4
|
+
const RISKY_CLIENT_CODE_RE =
|
|
5
|
+
/\bfrom\s+["'`](?:node:)?(?:fs|path|child_process|crypto)["'`]|\brequire\s*\(\s*["'`](?:node:)?(?:fs|path|child_process|crypto)["'`]\s*\)|\bfrom\s+["'`](?:@prisma\/client|server-only|@\/lib\/(?:db|prisma)|(?:\.\.\/)+lib\/(?:db|prisma))["'`]|\brequire\s*\(\s*["'`](?:@prisma\/client|server-only|@\/lib\/(?:db|prisma)|(?:\.\.\/)+lib\/(?:db|prisma))["'`]\s*\)|\bprisma\b|\bprocess\.env\.(?:DATABASE_URL|JWT_SECRET|STRIPE_SECRET_KEY|OPENAI_API_KEY)\b/g;
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
id: "next.public-server-code-risk",
|
|
9
|
+
title: "Next.js Client Components should not import server-only code",
|
|
10
|
+
category: "next",
|
|
11
|
+
severity: "high",
|
|
12
|
+
tags: ["next", "frontend", "server-only", "heuristic"],
|
|
13
|
+
run: async (context) => {
|
|
14
|
+
if (!(await isNextProject(context))) return [];
|
|
15
|
+
|
|
16
|
+
const findings = [];
|
|
17
|
+
for (const file of context.textFiles) {
|
|
18
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isNextClientCandidate(file)) continue;
|
|
19
|
+
const content = await context.readFileSafe(file);
|
|
20
|
+
if (!content || !CLIENT_DIRECTIVE_RE.test(content)) continue;
|
|
21
|
+
|
|
22
|
+
RISKY_CLIENT_CODE_RE.lastIndex = 0;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = RISKY_CLIENT_CODE_RE.exec(content)) !== null) {
|
|
25
|
+
findings.push({
|
|
26
|
+
message: "A Next.js Client Component appears to import server-side code or access server-only configuration.",
|
|
27
|
+
file,
|
|
28
|
+
line: lineFromOffset(content, match.index),
|
|
29
|
+
recommendation:
|
|
30
|
+
"Move database, filesystem, secret and server-only logic into Server Components, API routes or server actions. Keep Client Components free of backend dependencies.",
|
|
31
|
+
heuristic: true,
|
|
32
|
+
metadata: { pattern: classifyRisk(match[0]) }
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return findings.slice(0, 100);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function isNextProject(context) {
|
|
42
|
+
return (
|
|
43
|
+
context.hasDependency("next") ||
|
|
44
|
+
context.hasDevDependency("next") ||
|
|
45
|
+
context.allFiles.some((file) => /^next\.config\.[cm]?[jt]s$/.test(file)) ||
|
|
46
|
+
context.allFiles.some((file) => file.startsWith("app/")) ||
|
|
47
|
+
(await hasText(context, /\bfrom\s+["'`]next\//g, { files: context.textFiles.filter((file) => /\.[cm]?[jt]sx?$/.test(file)) }))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isNextClientCandidate(file) {
|
|
52
|
+
return (
|
|
53
|
+
/\.(?:js|jsx|ts|tsx)$/.test(file) &&
|
|
54
|
+
(file.startsWith("app/") || file.startsWith("components/") || file.includes("/components/"))
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function classifyRisk(value) {
|
|
59
|
+
if (/process\.env/.test(value)) return "server-secret-env-access";
|
|
60
|
+
if (/prisma|db/.test(value)) return "database-import";
|
|
61
|
+
if (/child_process/.test(value)) return "child-process-import";
|
|
62
|
+
if (/server-only/.test(value)) return "server-only-import";
|
|
63
|
+
return "node-server-import";
|
|
64
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isCodeLikeFile, isEnvExampleFile, isLockfile, isFrontendFile, isServerOrApiFile, lineFromOffset, readNearby } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const HTTP_REQUEST_RE =
|
|
4
|
+
/\b(?:fetch|got|request|ky)\s*\(|\baxios\.(?:get|post|put|patch|delete|request)\s*\(|\baxios\s*\(\s*{|\bundici\.request\s*\(|\bhttps?\.get\s*\(/g;
|
|
5
|
+
const USER_URL_SOURCE_RE =
|
|
6
|
+
/\breq\.(?:body|query|params)\.url\b|\bsearchParams\.get\s*\(\s*["'`]url["'`]\s*\)|\bformData\.get\s*\(\s*["'`]url["'`]\s*\)|\b(?:requestUrl|userUrl|targetUrl|webhookUrl|callbackUrl|imageUrl|avatarUrl)\b/i;
|
|
7
|
+
const URL_ALLOWLIST_RE =
|
|
8
|
+
/\b(?:allowlist|allowedHosts|allowedDomains|hostname\s*(?:===|!==|==|!=)|private\s+IP|localhost\s+block|metadata\s+IP|validateUrl|isAllowedUrl|isPrivateIp|blockPrivate|blockLocalhost)\b|169\.254\.169\.254|127\.0\.0\.1|localhost/i;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: "ssrf.user-controlled-fetch",
|
|
12
|
+
title: "Server-side HTTP requests should not trust user-controlled URLs",
|
|
13
|
+
category: "ssrf",
|
|
14
|
+
severity: "critical",
|
|
15
|
+
tags: ["ssrf", "api", "network", "heuristic"],
|
|
16
|
+
run: async (context) => {
|
|
17
|
+
const findings = [];
|
|
18
|
+
|
|
19
|
+
for (const file of context.textFiles) {
|
|
20
|
+
if (isLockfile(file) || isEnvExampleFile(file) || !isCodeLikeFile(file) || isFrontendFile(file)) continue;
|
|
21
|
+
const content = await context.readFileSafe(file);
|
|
22
|
+
if (!content || !isLikelyServerSide(file, content) || !/\b(?:fetch|axios|got|request|undici|http|https|ky)\b/.test(content)) continue;
|
|
23
|
+
|
|
24
|
+
HTTP_REQUEST_RE.lastIndex = 0;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = HTTP_REQUEST_RE.exec(content)) !== null) {
|
|
27
|
+
const line = lineFromOffset(content, match.index);
|
|
28
|
+
const nearby = await readNearby(context, file, line, 8);
|
|
29
|
+
if (!USER_URL_SOURCE_RE.test(nearby)) continue;
|
|
30
|
+
if (URL_ALLOWLIST_RE.test(nearby)) continue;
|
|
31
|
+
|
|
32
|
+
findings.push({
|
|
33
|
+
message: "User-controlled input appears to flow into a server-side HTTP request.",
|
|
34
|
+
file,
|
|
35
|
+
line,
|
|
36
|
+
recommendation:
|
|
37
|
+
"Use strict URL allowlists, block private/internal IP ranges including 127.0.0.1, localhost, 169.254.169.254 and RFC1918 ranges, and avoid fetching arbitrary user-provided URLs.",
|
|
38
|
+
heuristic: true,
|
|
39
|
+
metadata: { pattern: "user-controlled-server-fetch" }
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return findings.slice(0, 100);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isLikelyServerSide(file, content) {
|
|
49
|
+
return (
|
|
50
|
+
isServerOrApiFile(file) ||
|
|
51
|
+
/^server\.[cm]?[jt]s$/.test(file) ||
|
|
52
|
+
file.startsWith("server/") ||
|
|
53
|
+
file.startsWith("routes/") ||
|
|
54
|
+
file.startsWith("api/") ||
|
|
55
|
+
file.includes("/server/") ||
|
|
56
|
+
file.includes("/routes/") ||
|
|
57
|
+
file.includes("/controllers/") ||
|
|
58
|
+
/\b(?:req\.body|req\.query|req\.params|request\.json|ctx\.request|express|fastify|hono)\b/.test(content)
|
|
59
|
+
);
|
|
60
|
+
}
|