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,115 @@
|
|
|
1
|
+
import { lineFromOffset, parseJsonWithComments } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const CONFIG_FILES = ["src-tauri/tauri.conf.json", "src-tauri/tauri.conf.json5", "src-tauri/Tauri.toml"];
|
|
4
|
+
const WEAK_CSP_RE =
|
|
5
|
+
/"csp"\s*:\s*(?:null|false|""|"[^"]*(?:\*|unsafe-inline|unsafe-eval)[^"]*")|csp\s*=\s*(?:false|""|"[^"]*(?:\*|unsafe-inline|unsafe-eval)[^"]*")/gi;
|
|
6
|
+
const DANGEROUS_ASSET_CSP_RE = /"dangerousDisableAssetCspModification"\s*:\s*true|dangerousDisableAssetCspModification\s*=\s*true/gi;
|
|
7
|
+
const BROAD_ALLOWLIST_RE = /"allowlist"\s*:\s*{[\s\S]{0,400}?"all"\s*:\s*true|"all"\s*:\s*true[\s\S]{0,120}?"(?:shell|fs|http)"/gi;
|
|
8
|
+
const BROAD_PERMISSION_RE =
|
|
9
|
+
/"permissions"\s*:\s*\[[\s\S]{0,240}?["'`]\*["'`]|["'`](?:shell|fs|http):(?:\*|allow-\*|allow-all|allow-execute|allow-fetch)["'`]|["'`]fs:allow-[^"'`]*["'`][\s\S]{0,160}(?:\*\*|["'`]\*["'`]|\/)|["'`]http:allow-fetch["'`][\s\S]{0,220}(?:["'`]\*["'`]|https?:\/\/\*)/gi;
|
|
10
|
+
const REMOTE_URL_RE = /"(?:devUrl|frontendDist|url)"\s*:\s*"https?:\/\/(?!localhost\b|127\.0\.0\.1\b|0\.0\.0\.0\b)[^"]+"|(?:devUrl|frontendDist|url)\s*=\s*"https?:\/\/(?!localhost\b|127\.0\.0\.1\b|0\.0\.0\.0\b)[^"]+"/gi;
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
id: "tauri.remote-url-permissions-risk",
|
|
14
|
+
title: "Tauri remote URLs and permissions should be least privilege",
|
|
15
|
+
category: "tauri",
|
|
16
|
+
severity: "high",
|
|
17
|
+
tags: ["tauri", "desktop", "permissions", "heuristic"],
|
|
18
|
+
run: async (context) => {
|
|
19
|
+
if (!isTauriProject(context)) return [];
|
|
20
|
+
const findings = [];
|
|
21
|
+
|
|
22
|
+
for (const file of collectTauriFiles(context)) {
|
|
23
|
+
const content = await context.readFileSafe(file);
|
|
24
|
+
if (!content) continue;
|
|
25
|
+
|
|
26
|
+
if (file.endsWith(".json") || file.endsWith(".json5")) {
|
|
27
|
+
inspectParsedJson(content, file, findings);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
inspectRegexPattern(content, file, findings, WEAK_CSP_RE, "weak-csp");
|
|
31
|
+
inspectRegexPattern(content, file, findings, DANGEROUS_ASSET_CSP_RE, "dangerous-asset-csp");
|
|
32
|
+
inspectRegexPattern(content, file, findings, BROAD_ALLOWLIST_RE, "broad-allowlist");
|
|
33
|
+
inspectRegexPattern(content, file, findings, BROAD_PERMISSION_RE, "broad-permission");
|
|
34
|
+
inspectRegexPattern(content, file, findings, REMOTE_URL_RE, "remote-url");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return dedupe(findings).slice(0, 100);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function isTauriProject(context) {
|
|
42
|
+
return (
|
|
43
|
+
context.allFiles.some((file) => file.startsWith("src-tauri/")) ||
|
|
44
|
+
context.hasDependency("@tauri-apps/api") ||
|
|
45
|
+
context.hasDevDependency("@tauri-apps/cli") ||
|
|
46
|
+
context.hasDependency("@tauri-apps/cli")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function collectTauriFiles(context) {
|
|
51
|
+
return context.textFiles.filter((file) => {
|
|
52
|
+
return (
|
|
53
|
+
CONFIG_FILES.includes(file) ||
|
|
54
|
+
/^src-tauri\/capabilities\/[^/]+\.json$/i.test(file) ||
|
|
55
|
+
/^src-tauri\/permissions\/[^/]+\.json$/i.test(file)
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inspectParsedJson(content, file, findings) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = parseJsonWithComments(content);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const security = parsed?.app?.security || parsed?.tauri?.security || {};
|
|
69
|
+
if (security.csp === null || security.csp === false || security.csp === "") {
|
|
70
|
+
findings.push(finding(file, lineOfKey(content, "csp"), "weak-csp"));
|
|
71
|
+
}
|
|
72
|
+
if (security.dangerousDisableAssetCspModification === true) {
|
|
73
|
+
findings.push(finding(file, lineOfKey(content, "dangerousDisableAssetCspModification"), "dangerous-asset-csp"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const permissions = JSON.stringify(parsed?.permissions || parsed?.app?.permissions || parsed?.tauri?.allowlist || []);
|
|
77
|
+
if (/"\*"|"all":true|shell:allow-execute|fs:allow-|http:allow-fetch/.test(permissions)) {
|
|
78
|
+
findings.push(finding(file, undefined, "broad-permission"));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inspectRegexPattern(content, file, findings, regex, pattern) {
|
|
83
|
+
regex.lastIndex = 0;
|
|
84
|
+
let match;
|
|
85
|
+
while ((match = regex.exec(content)) !== null) {
|
|
86
|
+
findings.push(finding(file, lineFromOffset(content, match.index), pattern));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function finding(file, line, pattern) {
|
|
91
|
+
return {
|
|
92
|
+
message: "Tauri configuration appears to allow broad permissions, remote URLs or weak CSP settings.",
|
|
93
|
+
file,
|
|
94
|
+
line,
|
|
95
|
+
recommendation:
|
|
96
|
+
"Use least-privilege capabilities, restrict shell/fs/http permissions, avoid broad wildcards, and configure a strict CSP.",
|
|
97
|
+
heuristic: true,
|
|
98
|
+
metadata: { pattern }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lineOfKey(content, key) {
|
|
103
|
+
const index = content.indexOf(`"${key}"`);
|
|
104
|
+
return index >= 0 ? lineFromOffset(content, index) : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dedupe(findings) {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
return findings.filter((item) => {
|
|
110
|
+
const key = `${item.file}:${item.line || 0}:${item.metadata?.pattern || ""}`;
|
|
111
|
+
if (seen.has(key)) return false;
|
|
112
|
+
seen.add(key);
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
}
|
package/src/cli/output.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import gradient from 'gradient-string';
|
|
2
2
|
|
|
3
3
|
export function printUsage() {
|
|
4
|
-
|
|
4
|
+
process.stdout.write(`ItWorksBut
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
7
|
itworksbut scan [options]
|
|
8
|
-
node ./bin/itworksbut.js scan [options]
|
|
9
8
|
|
|
10
9
|
Options:
|
|
11
10
|
--path <path> Project path to scan. Defaults to current directory.
|
|
@@ -15,6 +14,7 @@ Options:
|
|
|
15
14
|
--json Print machine-readable JSON.
|
|
16
15
|
--sarif Print SARIF for GitHub Code Scanning.
|
|
17
16
|
--todo Write an AI-ready todo.md to the scanned project.
|
|
17
|
+
--report Write a Markdown scan report.md to the current directory.
|
|
18
18
|
--no-color Disable color styling.
|
|
19
19
|
--no-banner Disable the intro banner.
|
|
20
20
|
--quiet Print only the summary.
|
|
@@ -25,14 +25,14 @@ Options:
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function printVersion(version) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
try {
|
|
29
|
+
process.stdout.write(`${gradient.rainbow(version)}\n`);
|
|
30
|
+
} catch {
|
|
31
|
+
process.stdout.write(`${version}\n`);
|
|
32
|
+
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function printRuntimeError(error) {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
process.stderr.write(`ItWorksBut runtime error: ${message}\n`);
|
|
38
38
|
}
|
package/src/cli/parseArgs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path"]);
|
|
2
|
-
const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
|
|
2
|
+
const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--report", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
|
|
3
3
|
|
|
4
4
|
export function parseArgs(argv) {
|
|
5
5
|
const args = {
|
|
@@ -10,6 +10,7 @@ export function parseArgs(argv) {
|
|
|
10
10
|
json: false,
|
|
11
11
|
sarif: false,
|
|
12
12
|
todo: false,
|
|
13
|
+
report: false,
|
|
13
14
|
verbose: false,
|
|
14
15
|
noColor: false,
|
|
15
16
|
noBanner: false,
|
|
@@ -48,6 +49,7 @@ export function parseArgs(argv) {
|
|
|
48
49
|
if (token === "--json") args.json = true;
|
|
49
50
|
if (token === "--sarif") args.sarif = true;
|
|
50
51
|
if (token === "--todo") args.todo = true;
|
|
52
|
+
if (token === "--report") args.report = true;
|
|
51
53
|
if (token === "--verbose") args.verbose = true;
|
|
52
54
|
if (token === "--no-color") args.noColor = true;
|
|
53
55
|
if (token === "--no-banner") args.noBanner = true;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const CHECK_STATUSES = ["pass", "warn", "fail", "skip"];
|
|
2
|
+
|
|
3
|
+
export function normalizeCheckResult(check, value = {}, findings = []) {
|
|
4
|
+
const status = normalizeStatus(value.status || deriveStatusFromFindings(findings));
|
|
5
|
+
const details = Array.isArray(value.details) ? value.details : detailsFromFindings(findings);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
id: value.id || check.id,
|
|
9
|
+
title: value.title || check.title,
|
|
10
|
+
category: value.category || check.category,
|
|
11
|
+
status,
|
|
12
|
+
summary: value.summary || defaultSummary(status, findings),
|
|
13
|
+
details,
|
|
14
|
+
metadata: value.metadata || undefined
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function deriveStatusFromFindings(findings = []) {
|
|
19
|
+
if (!findings.length) return "pass";
|
|
20
|
+
if (findings.some((finding) => finding.severity === "critical" || finding.severity === "high")) return "fail";
|
|
21
|
+
return "warn";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function countByStatus(checks = []) {
|
|
25
|
+
return Object.fromEntries(
|
|
26
|
+
CHECK_STATUSES.map((status) => [status, checks.filter((check) => check.status === status).length])
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeStatus(value) {
|
|
31
|
+
const normalized = String(value || "pass").toLowerCase();
|
|
32
|
+
return CHECK_STATUSES.includes(normalized) ? normalized : "fail";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultSummary(status, findings) {
|
|
36
|
+
if (status === "pass") return "No issues found.";
|
|
37
|
+
if (status === "skip") return "Skipped.";
|
|
38
|
+
if (!findings.length) return status === "fail" ? "Check failed." : "Check needs review.";
|
|
39
|
+
|
|
40
|
+
const count = findings.length;
|
|
41
|
+
const noun = count === 1 ? "finding" : "findings";
|
|
42
|
+
return `${count} ${noun} reported.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function detailsFromFindings(findings) {
|
|
46
|
+
return findings.map((finding) => ({
|
|
47
|
+
message: finding.message,
|
|
48
|
+
file: finding.file,
|
|
49
|
+
line: finding.line,
|
|
50
|
+
severity: finding.severity,
|
|
51
|
+
recommendation: finding.recommendation
|
|
52
|
+
}));
|
|
53
|
+
}
|
package/src/core/scanner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import checks from "../checks/index.js";
|
|
2
2
|
import { createContext } from "./context.js";
|
|
3
|
+
import { normalizeCheckResult } from "./checkResults.js";
|
|
3
4
|
import { normalizeFinding, severityRank } from "./findings.js";
|
|
4
5
|
import { packageInfo } from "./packageInfo.js";
|
|
5
6
|
|
|
@@ -7,28 +8,54 @@ export async function scanProject(options = {}) {
|
|
|
7
8
|
const startedAt = new Date();
|
|
8
9
|
const context = await createContext(options);
|
|
9
10
|
const findings = [];
|
|
11
|
+
const checkResults = [];
|
|
10
12
|
const warnings = [];
|
|
11
13
|
|
|
12
14
|
for (const check of checks) {
|
|
13
|
-
if (context.config.checks[check.id] === false)
|
|
15
|
+
if (context.config.checks[check.id] === false) {
|
|
16
|
+
checkResults.push(normalizeCheckResult(check, {
|
|
17
|
+
status: "skip",
|
|
18
|
+
summary: "Disabled by configuration."
|
|
19
|
+
}));
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
14
22
|
|
|
15
23
|
try {
|
|
16
|
-
const
|
|
24
|
+
const rawResult = await check.run(context);
|
|
25
|
+
const checkFindings = Array.isArray(rawResult) ? rawResult : rawResult?.findings;
|
|
26
|
+
const explicitCheckResult = Array.isArray(rawResult) ? null : rawResult?.result || rawResult?.checkResult;
|
|
27
|
+
|
|
17
28
|
if (!Array.isArray(checkFindings)) {
|
|
18
29
|
warnings.push({
|
|
19
30
|
checkId: check.id,
|
|
20
31
|
message: "Check returned a non-array result and was ignored."
|
|
21
32
|
});
|
|
33
|
+
checkResults.push(normalizeCheckResult(check, {
|
|
34
|
+
status: "fail",
|
|
35
|
+
summary: "Check returned an invalid result.",
|
|
36
|
+
details: [{ message: "Check returned a non-array result and was ignored." }]
|
|
37
|
+
}));
|
|
22
38
|
continue;
|
|
23
39
|
}
|
|
40
|
+
|
|
41
|
+
const normalizedFindings = [];
|
|
24
42
|
for (const finding of checkFindings) {
|
|
25
|
-
|
|
43
|
+
const normalizedFinding = normalizeFinding(check, finding);
|
|
44
|
+
normalizedFindings.push(normalizedFinding);
|
|
45
|
+
findings.push(normalizedFinding);
|
|
26
46
|
}
|
|
47
|
+
checkResults.push(normalizeCheckResult(check, explicitCheckResult || {}, normalizedFindings));
|
|
27
48
|
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28
50
|
warnings.push({
|
|
29
51
|
checkId: check.id,
|
|
30
|
-
message
|
|
52
|
+
message
|
|
31
53
|
});
|
|
54
|
+
checkResults.push(normalizeCheckResult(check, {
|
|
55
|
+
status: "fail",
|
|
56
|
+
summary: message,
|
|
57
|
+
details: [{ message }]
|
|
58
|
+
}));
|
|
32
59
|
}
|
|
33
60
|
}
|
|
34
61
|
|
|
@@ -40,12 +67,14 @@ export async function scanProject(options = {}) {
|
|
|
40
67
|
|
|
41
68
|
return {
|
|
42
69
|
findings,
|
|
70
|
+
checks: checkResults,
|
|
43
71
|
warnings,
|
|
44
72
|
config: context.config,
|
|
45
73
|
meta: {
|
|
46
74
|
tool: "ItWorksBut",
|
|
47
75
|
version: packageInfo.version,
|
|
48
76
|
rootPath: context.rootPath,
|
|
77
|
+
packageName: context.packageJson?.name,
|
|
49
78
|
packageManager: context.packageManager,
|
|
50
79
|
gitAvailable: context.gitAvailable,
|
|
51
80
|
filesScanned: context.allFiles.length,
|
|
@@ -12,15 +12,17 @@ import {
|
|
|
12
12
|
|
|
13
13
|
export function reportConsole(result, options = {}) {
|
|
14
14
|
const { findings, warnings, config, meta } = result;
|
|
15
|
+
const checks = result.checks || [];
|
|
15
16
|
const counts = countBySeverity(findings);
|
|
16
17
|
const colors = getChalk(options);
|
|
17
18
|
const rich = isFancyOutputEnabled(options);
|
|
19
|
+
const hasReviewCheck = checks.some(check => check.status === 'warn' || check.status === 'fail');
|
|
18
20
|
|
|
19
21
|
if (!options.quiet && !rich) {
|
|
20
22
|
process.stdout.write(`${colors.bold('ItWorksBut receipts')}\n\n`);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
if (!options.quiet && findings.length === 0) {
|
|
25
|
+
if (!options.quiet && findings.length === 0 && !hasReviewCheck) {
|
|
24
26
|
process.stdout.write(
|
|
25
27
|
`${colors.green ? colors.green('Suspiciously clean. No findings.') : 'Suspiciously clean. No findings.'}\n\n`,
|
|
26
28
|
);
|
|
@@ -36,6 +38,10 @@ export function reportConsole(result, options = {}) {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
if (!options.quiet) {
|
|
42
|
+
writeOutdatedPackagesCheck(checks, options);
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
if (options.verbose && warnings.length > 0) {
|
|
40
46
|
process.stdout.write('WARNINGS\n');
|
|
41
47
|
for (const warning of warnings) {
|
|
@@ -55,6 +61,41 @@ export function reportConsole(result, options = {}) {
|
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
function writeOutdatedPackagesCheck(checks, options) {
|
|
65
|
+
const check = checks.find(candidate => candidate.id === 'dependencies.outdated-packages');
|
|
66
|
+
if (!check) return;
|
|
67
|
+
|
|
68
|
+
const colors = getChalk(options);
|
|
69
|
+
const title = colors.bold('Outdated packages');
|
|
70
|
+
|
|
71
|
+
if (check.status === 'pass') {
|
|
72
|
+
process.stdout.write(`${colors.green('✓')} ${title}: ${check.summary}\n\n`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (check.status === 'skip') {
|
|
77
|
+
process.stdout.write(`- ${title}: ${check.summary}\n\n`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (check.status === 'fail') {
|
|
82
|
+
process.stdout.write(`${colors.red('✖')} ${title}: ${check.summary}\n\n`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
process.stdout.write(`${colors.yellow('⚠')} ${title}: ${check.summary}\n`);
|
|
87
|
+
const packages = check.details.filter(detail => detail?.name).slice(0, 10);
|
|
88
|
+
for (const detail of packages) {
|
|
89
|
+
process.stdout.write(
|
|
90
|
+
` - ${detail.name}: ${detail.current} → ${detail.wanted} wanted, ${detail.latest} latest\n`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (check.details.length > packages.length) {
|
|
94
|
+
process.stdout.write(` - and ${check.details.length - packages.length} more\n`);
|
|
95
|
+
}
|
|
96
|
+
process.stdout.write('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
58
99
|
function writeFinding(finding, options) {
|
|
59
100
|
const colors = getChalk(options);
|
|
60
101
|
const severity = formatSeverity(finding.severity, options);
|
|
@@ -26,6 +26,10 @@ const EDGY_TITLES = {
|
|
|
26
26
|
'api.idor-risk': 'It works, but this ID lookup may belong to someone else.',
|
|
27
27
|
'auth.jwt-secret-weak-or-fallback': 'It works, but your JWT secret has a fallback key.',
|
|
28
28
|
'auth.password-hashing-missing': 'It works, but your passwords may be stored too honestly.',
|
|
29
|
+
'auth.missing-csrf-protection': 'It works, but your browser may be submitting forms behind your back.',
|
|
30
|
+
'api.missing-method-guard': 'It works, but your API does not care how it gets called.',
|
|
31
|
+
'api.mass-assignment-risk': 'It works, but your users may be editing fields they should never touch.',
|
|
32
|
+
'api.no-schema-validation': 'It works, but your API believes whatever the request says.',
|
|
29
33
|
'database.raw-sql-interpolation': 'It works, but your SQL query is one template string away from pain.',
|
|
30
34
|
'database.no-migrations': 'It works, but your database schema has no paper trail.',
|
|
31
35
|
'cookies.insecure-session-cookie': 'It works, but your session cookie is dressed for localhost.',
|
|
@@ -33,10 +37,16 @@ const EDGY_TITLES = {
|
|
|
33
37
|
'webhooks.missing-raw-body': 'It works, but your webhook signature check may be checking the wrong body.',
|
|
34
38
|
'llm.prompt-injection-risk': 'It works, but your AI output has admin energy.',
|
|
35
39
|
'frontend.sourcemaps-production': 'It works, but your source code may be shipping with the app.',
|
|
40
|
+
'frontend.localstorage-token': 'It works, but your auth token lives where XSS can read it.',
|
|
41
|
+
'files.path-traversal-risk': 'It works, but your file path may be taking requests too literally.',
|
|
42
|
+
'ssrf.user-controlled-fetch': 'It works, but your server is fetching whatever strangers ask for.',
|
|
43
|
+
'next.public-server-code-risk': 'It works, but your client component is carrying server baggage.',
|
|
36
44
|
'config.debug-production': 'It works, but production still thinks it is a dev server.',
|
|
37
45
|
'electron.node-integration-enabled': 'It works, but Electron is holding the Node.js door open.',
|
|
38
46
|
'electron.context-isolation-disabled': 'It works, but your renderer and backend are sharing a room.',
|
|
47
|
+
'electron.remote-content-with-node': 'It works, but Electron is letting the internet sit next to Node.js.',
|
|
39
48
|
'tauri.dangerous-allowlist-or-capabilities': 'It works, but your Tauri permissions look too generous.',
|
|
49
|
+
'tauri.remote-url-permissions-risk': 'It works, but your Tauri app is trusting too much surface area.',
|
|
40
50
|
};
|
|
41
51
|
|
|
42
52
|
const SEVERITY_META = {
|
|
@@ -100,6 +110,14 @@ const FIX_PROMPT_ACTIONS = {
|
|
|
100
110
|
'Require a strong JWT secret from the environment in production and fail startup if it is missing.',
|
|
101
111
|
'auth.password-hashing-missing':
|
|
102
112
|
'Hash passwords with argon2, bcrypt, scrypt or PBKDF2 before storage. Never store raw passwords.',
|
|
113
|
+
'auth.missing-csrf-protection':
|
|
114
|
+
'Use SameSite cookies, CSRF tokens or another explicit CSRF mitigation for state-changing routes.',
|
|
115
|
+
'api.missing-method-guard':
|
|
116
|
+
'Restrict API routes to the intended HTTP methods and return 405 Method Not Allowed for unsupported methods.',
|
|
117
|
+
'api.mass-assignment-risk':
|
|
118
|
+
'Whitelist allowed fields explicitly. Never pass req.body directly into database create/update calls.',
|
|
119
|
+
'api.no-schema-validation':
|
|
120
|
+
'Validate request body, query and params with a schema library such as Zod, Joi, Valibot, AJV or equivalent.',
|
|
103
121
|
'database.raw-sql-interpolation':
|
|
104
122
|
'Replace SQL string interpolation or concatenation with parameterized queries, prepared statements, or a safe ORM query builder.',
|
|
105
123
|
'database.no-migrations': 'Add versioned database migrations that match the detected ORM or database stack.',
|
|
@@ -113,14 +131,26 @@ const FIX_PROMPT_ACTIONS = {
|
|
|
113
131
|
'Treat model output as untrusted input. Validate with schemas, use allowlists, require human approval for dangerous actions, and never execute raw model output.',
|
|
114
132
|
'frontend.sourcemaps-production':
|
|
115
133
|
'Disable public production source maps unless intentionally needed. If needed, upload them privately to error tracking instead of serving them publicly.',
|
|
134
|
+
'frontend.localstorage-token':
|
|
135
|
+
'Prefer secure, httpOnly cookies for session tokens where appropriate. If browser storage is unavoidable, minimize token lifetime and harden XSS protections.',
|
|
136
|
+
'files.path-traversal-risk':
|
|
137
|
+
'Normalize and validate paths, use allowlists, reject traversal sequences, and ensure resolved paths stay inside an intended base directory.',
|
|
138
|
+
'ssrf.user-controlled-fetch':
|
|
139
|
+
'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.',
|
|
140
|
+
'next.public-server-code-risk':
|
|
141
|
+
'Move database, filesystem, secret and server-only logic into Server Components, API routes or server actions. Keep Client Components free of backend dependencies.',
|
|
116
142
|
'config.debug-production':
|
|
117
143
|
'Disable verbose errors and debug flags in production. Avoid exposing stack traces, internal paths or development tooling.',
|
|
118
144
|
'electron.node-integration-enabled':
|
|
119
145
|
'Set nodeIntegration to false and expose only narrowly scoped APIs through preload.',
|
|
120
146
|
'electron.context-isolation-disabled':
|
|
121
147
|
'Enable contextIsolation and review preload boundaries for renderer-to-main communication.',
|
|
148
|
+
'electron.remote-content-with-node':
|
|
149
|
+
'Avoid loading remote content with Node.js integration. Use nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true and a minimal preload bridge.',
|
|
122
150
|
'tauri.dangerous-allowlist-or-capabilities':
|
|
123
151
|
'Tighten Tauri allowlists, capabilities, scopes, shell access, filesystem access, remote URLs, and CSP.',
|
|
152
|
+
'tauri.remote-url-permissions-risk':
|
|
153
|
+
'Use least-privilege capabilities, restrict shell/fs/http permissions, avoid broad wildcards, and configure a strict CSP.',
|
|
124
154
|
};
|
|
125
155
|
|
|
126
156
|
export function getConsoleFindingTitle(finding) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { countBySeverity, getExitCode } from "../core/findings.js";
|
|
2
|
+
import { countByStatus } from "../core/checkResults.js";
|
|
2
3
|
|
|
3
4
|
export function reportJson(result) {
|
|
4
5
|
return {
|
|
@@ -8,9 +9,11 @@ export function reportJson(result) {
|
|
|
8
9
|
summary: {
|
|
9
10
|
total: result.findings.length,
|
|
10
11
|
bySeverity: countBySeverity(result.findings),
|
|
12
|
+
byStatus: countByStatus(result.checks || []),
|
|
11
13
|
failOn: result.config.failOn,
|
|
12
14
|
exitCode: getExitCode(result.findings, result.config.failOn)
|
|
13
15
|
},
|
|
16
|
+
checks: result.checks || [],
|
|
14
17
|
findings: result.findings,
|
|
15
18
|
warnings: result.warnings
|
|
16
19
|
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { countByStatus } from "../core/checkResults.js";
|
|
4
|
+
|
|
5
|
+
const ANSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
|
6
|
+
|
|
7
|
+
export async function writeMarkdownReport(result, options = {}) {
|
|
8
|
+
const filePath = options.filePath || path.join(options.directoryPath || process.cwd(), "report.md");
|
|
9
|
+
let overwritten = false;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
await fs.access(filePath);
|
|
13
|
+
overwritten = true;
|
|
14
|
+
} catch {
|
|
15
|
+
overwritten = false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
await fs.writeFile(filePath, reportMarkdown(result), "utf8");
|
|
19
|
+
return { filePath, overwritten };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function reportMarkdown(result) {
|
|
23
|
+
const checks = result.checks || checksFromFindings(result.findings || []);
|
|
24
|
+
const counts = countByStatus(checks);
|
|
25
|
+
const projectName = result.meta?.packageName || path.basename(result.meta?.rootPath || "") || "unknown";
|
|
26
|
+
const generatedAt = formatTimestamp(result.meta?.completedAt || new Date());
|
|
27
|
+
|
|
28
|
+
return stripAnsi(`${[
|
|
29
|
+
"# ItWorksBut Scan Report",
|
|
30
|
+
"",
|
|
31
|
+
`Generated: ${generatedAt}`,
|
|
32
|
+
"",
|
|
33
|
+
`Project: ${projectName}`,
|
|
34
|
+
`Path: ${result.meta?.rootPath || "unknown"}`,
|
|
35
|
+
"",
|
|
36
|
+
"## Summary",
|
|
37
|
+
"",
|
|
38
|
+
"| Status | Count |",
|
|
39
|
+
"|---|---:|",
|
|
40
|
+
`| Pass | ${counts.pass} |`,
|
|
41
|
+
`| Warn | ${counts.warn} |`,
|
|
42
|
+
`| Fail | ${counts.fail} |`,
|
|
43
|
+
`| Skip | ${counts.skip} |`,
|
|
44
|
+
"",
|
|
45
|
+
"## Checks",
|
|
46
|
+
"",
|
|
47
|
+
renderChecks(checks),
|
|
48
|
+
renderScannerWarnings(result.warnings || []),
|
|
49
|
+
renderRecommendations(checks, result.findings || []),
|
|
50
|
+
].filter((line) => line !== null && line !== undefined).join("\n")}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderChecks(checks) {
|
|
54
|
+
if (!checks.length) return "No checks were recorded.\n";
|
|
55
|
+
|
|
56
|
+
return checks.map(renderCheck).join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderCheck(check) {
|
|
60
|
+
return [
|
|
61
|
+
`### ${check.title || check.id}`,
|
|
62
|
+
"",
|
|
63
|
+
`Status: ${check.status || "unknown"}`,
|
|
64
|
+
"",
|
|
65
|
+
"Summary:",
|
|
66
|
+
check.summary || "No summary available.",
|
|
67
|
+
"",
|
|
68
|
+
"Details:",
|
|
69
|
+
renderDetails(check),
|
|
70
|
+
""
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderDetails(check) {
|
|
75
|
+
const details = Array.isArray(check.details) ? check.details : [];
|
|
76
|
+
if (!details.length) return "None.";
|
|
77
|
+
|
|
78
|
+
if (isOutdatedPackageCheck(check)) {
|
|
79
|
+
return renderOutdatedPackageTable(details);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return details.map(renderDetailBullet).join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderOutdatedPackageTable(details) {
|
|
86
|
+
const packageDetails = details.filter((detail) => detail?.name);
|
|
87
|
+
if (!packageDetails.length) return details.map(renderDetailBullet).join("\n");
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
"| Package | Current | Wanted | Latest | Type |",
|
|
91
|
+
"|---|---:|---:|---:|---|",
|
|
92
|
+
...packageDetails.map((detail) => (
|
|
93
|
+
`| ${escapeTable(detail.name)} | ${escapeTable(detail.current)} | ${escapeTable(detail.wanted)} | ${escapeTable(detail.latest)} | ${escapeTable(detail.type)} |`
|
|
94
|
+
))
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderDetailBullet(detail) {
|
|
99
|
+
if (typeof detail === "string") return `- ${detail}`;
|
|
100
|
+
if (!detail || typeof detail !== "object") return `- ${String(detail)}`;
|
|
101
|
+
|
|
102
|
+
if (detail.message) return `- ${detail.message}`;
|
|
103
|
+
return `- ${Object.entries(detail).map(([key, value]) => `${key}: ${String(value)}`).join(", ")}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderScannerWarnings(warnings) {
|
|
107
|
+
if (!warnings.length) return "";
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
"## Scanner Warnings",
|
|
111
|
+
"",
|
|
112
|
+
...warnings.map((warning) => `- ${warning.checkId}: ${warning.message}`),
|
|
113
|
+
""
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderRecommendations(checks, findings) {
|
|
118
|
+
const needsReview = checks.some((check) => check.status === "warn" || check.status === "fail");
|
|
119
|
+
if (!needsReview && findings.length === 0) return "";
|
|
120
|
+
|
|
121
|
+
const recommendations = [];
|
|
122
|
+
if (checks.some((check) => isOutdatedPackageCheck(check) && (check.status === "warn" || check.status === "fail"))) {
|
|
123
|
+
recommendations.push("Update outdated packages carefully.");
|
|
124
|
+
recommendations.push("Review major-version upgrades manually.");
|
|
125
|
+
recommendations.push("Run tests after dependency updates.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const finding of findings) {
|
|
129
|
+
if (finding.recommendation) recommendations.push(finding.recommendation);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (checks.some((check) => check.status === "fail")) {
|
|
133
|
+
recommendations.push("Review failed checks and scanner warnings before shipping.");
|
|
134
|
+
}
|
|
135
|
+
if (recommendations.length === 0) {
|
|
136
|
+
recommendations.push("Review warning and failure details before shipping.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [
|
|
140
|
+
"## Recommendations",
|
|
141
|
+
"",
|
|
142
|
+
...unique(recommendations).map((recommendation) => `- ${recommendation}`),
|
|
143
|
+
""
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function checksFromFindings(findings) {
|
|
148
|
+
const byCheck = new Map();
|
|
149
|
+
for (const finding of findings) {
|
|
150
|
+
const existing = byCheck.get(finding.checkId) || {
|
|
151
|
+
id: finding.checkId,
|
|
152
|
+
title: finding.title,
|
|
153
|
+
category: finding.category,
|
|
154
|
+
status: finding.severity === "critical" || finding.severity === "high" ? "fail" : "warn",
|
|
155
|
+
summary: "Finding reported.",
|
|
156
|
+
details: []
|
|
157
|
+
};
|
|
158
|
+
existing.details.push({
|
|
159
|
+
message: finding.message,
|
|
160
|
+
file: finding.file,
|
|
161
|
+
line: finding.line,
|
|
162
|
+
severity: finding.severity
|
|
163
|
+
});
|
|
164
|
+
byCheck.set(finding.checkId, existing);
|
|
165
|
+
}
|
|
166
|
+
return [...byCheck.values()];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isOutdatedPackageCheck(check) {
|
|
170
|
+
return check.id === "dependencies.outdated-packages" || check.title === "Outdated packages";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatTimestamp(value) {
|
|
174
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
175
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
176
|
+
|
|
177
|
+
const pad = (number) => String(number).padStart(2, "0");
|
|
178
|
+
return [
|
|
179
|
+
date.getFullYear(),
|
|
180
|
+
"-",
|
|
181
|
+
pad(date.getMonth() + 1),
|
|
182
|
+
"-",
|
|
183
|
+
pad(date.getDate()),
|
|
184
|
+
" ",
|
|
185
|
+
pad(date.getHours()),
|
|
186
|
+
":",
|
|
187
|
+
pad(date.getMinutes()),
|
|
188
|
+
":",
|
|
189
|
+
pad(date.getSeconds())
|
|
190
|
+
].join("");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function escapeTable(value) {
|
|
194
|
+
return stripAnsi(String(value ?? "unknown")).replace(/\|/g, "\\|");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stripAnsi(value) {
|
|
198
|
+
return String(value).replace(ANSI_PATTERN, "");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function unique(values) {
|
|
202
|
+
return [...new Set(values.filter(Boolean))];
|
|
203
|
+
}
|