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.
Files changed (53) hide show
  1. package/README.md +241 -0
  2. package/bin/itworksbut.js +63 -0
  3. package/itworksbut.config.json +5 -0
  4. package/package.json +46 -0
  5. package/src/checks/auth/idor-risk.js +45 -0
  6. package/src/checks/auth/missing-auth-on-routes.js +55 -0
  7. package/src/checks/ci/no-build-step.js +23 -0
  8. package/src/checks/ci/no-ci-config.js +20 -0
  9. package/src/checks/ci/no-test-step.js +23 -0
  10. package/src/checks/ci/npm-install-instead-of-npm-ci.js +29 -0
  11. package/src/checks/database/no-migrations.js +33 -0
  12. package/src/checks/database/raw-sql-interpolation.js +41 -0
  13. package/src/checks/dependencies/audit-script-missing.js +23 -0
  14. package/src/checks/dependencies/install-scripts-risk.js +18 -0
  15. package/src/checks/dependencies/lockfile-missing.js +21 -0
  16. package/src/checks/dependencies/multiple-lockfiles.js +21 -0
  17. package/src/checks/electron/context-isolation-disabled.js +51 -0
  18. package/src/checks/electron/node-integration-enabled.js +26 -0
  19. package/src/checks/env/env-example-missing.js +28 -0
  20. package/src/checks/env/env-file-tracked.js +20 -0
  21. package/src/checks/env/frontend-secret-exposure.js +44 -0
  22. package/src/checks/env/possible-secret-in-code.js +72 -0
  23. package/src/checks/git/gitignore-incomplete.js +47 -0
  24. package/src/checks/git/gitignore-missing.js +16 -0
  25. package/src/checks/git/ignored-files-tracked.js +38 -0
  26. package/src/checks/helpers.js +122 -0
  27. package/src/checks/index.js +63 -0
  28. package/src/checks/node/cors-wildcard.js +35 -0
  29. package/src/checks/node/express-json-limit-missing.js +30 -0
  30. package/src/checks/node/helmet-missing.js +22 -0
  31. package/src/checks/node/rate-limit-missing.js +30 -0
  32. package/src/checks/package/scripts-missing.js +30 -0
  33. package/src/checks/tauri/dangerous-allowlist-or-capabilities.js +142 -0
  34. package/src/checks/web/client-side-auth-only.js +40 -0
  35. package/src/checks/web/dangerous-inner-html.js +33 -0
  36. package/src/checks/web/missing-output-sanitization.js +34 -0
  37. package/src/cli/output.js +29 -0
  38. package/src/cli/parseArgs.js +75 -0
  39. package/src/cli/terminal.js +112 -0
  40. package/src/core/config.js +51 -0
  41. package/src/core/context.js +87 -0
  42. package/src/core/fileWalker.js +44 -0
  43. package/src/core/findings.js +39 -0
  44. package/src/core/git.js +92 -0
  45. package/src/core/scanner.js +56 -0
  46. package/src/reporters/consoleReporter.js +107 -0
  47. package/src/reporters/consoleStyle.js +155 -0
  48. package/src/reporters/jsonReporter.js +17 -0
  49. package/src/reporters/sarifReporter.js +82 -0
  50. package/src/utils/fs.js +57 -0
  51. package/src/utils/mask.js +14 -0
  52. package/src/utils/packageJson.js +31 -0
  53. package/src/utils/path.js +71 -0
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isLikelyTextFile } from "../utils/fs.js";
4
+ import { matchesAnyGlob, normalizeRelativePath, relativePath } from "../utils/path.js";
5
+
6
+ export async function walkProject(rootPath, ignorePatterns) {
7
+ const allFiles = [];
8
+ const textFiles = [];
9
+
10
+ async function visit(directory) {
11
+ let entries;
12
+ try {
13
+ entries = await fs.readdir(directory, { withFileTypes: true });
14
+ } catch {
15
+ return;
16
+ }
17
+
18
+ for (const entry of entries) {
19
+ const absolutePath = path.join(directory, entry.name);
20
+ const relPath = relativePath(rootPath, absolutePath);
21
+ if (!relPath || matchesAnyGlob(relPath, ignorePatterns)) continue;
22
+
23
+ if (entry.isSymbolicLink()) continue;
24
+
25
+ if (entry.isDirectory()) {
26
+ await visit(absolutePath);
27
+ continue;
28
+ }
29
+
30
+ if (!entry.isFile()) continue;
31
+ const normalized = normalizeRelativePath(relPath);
32
+ allFiles.push(normalized);
33
+ if (await isLikelyTextFile(absolutePath)) {
34
+ textFiles.push(normalized);
35
+ }
36
+ }
37
+ }
38
+
39
+ await visit(rootPath);
40
+
41
+ allFiles.sort();
42
+ textFiles.sort();
43
+ return { allFiles, textFiles };
44
+ }
@@ -0,0 +1,39 @@
1
+ import { SEVERITIES } from "./config.js";
2
+
3
+ export const severityRank = new Map(SEVERITIES.map((severity, index) => [severity, SEVERITIES.length - index]));
4
+
5
+ export function normalizeFinding(check, finding) {
6
+ return {
7
+ checkId: finding.checkId || check.id,
8
+ title: finding.title || check.title,
9
+ category: finding.category || check.category,
10
+ severity: normalizeFindingSeverity(finding.severity || check.severity),
11
+ message: finding.message || check.title,
12
+ file: finding.file,
13
+ line: finding.line,
14
+ column: finding.column,
15
+ recommendation: finding.recommendation,
16
+ tags: finding.tags || check.tags || [],
17
+ heuristic: Boolean(finding.heuristic),
18
+ metadata: finding.metadata || undefined
19
+ };
20
+ }
21
+
22
+ export function normalizeFindingSeverity(value) {
23
+ const normalized = String(value || "info").toLowerCase();
24
+ return SEVERITIES.includes(normalized) ? normalized : "info";
25
+ }
26
+
27
+ export function isAtOrAbove(severity, threshold) {
28
+ return severityRank.get(severity) >= severityRank.get(threshold);
29
+ }
30
+
31
+ export function getExitCode(findings, failOn) {
32
+ return findings.some((finding) => isAtOrAbove(finding.severity, failOn)) ? 1 : 0;
33
+ }
34
+
35
+ export function countBySeverity(findings) {
36
+ return Object.fromEntries(
37
+ SEVERITIES.map((severity) => [severity, findings.filter((finding) => finding.severity === severity).length])
38
+ );
39
+ }
@@ -0,0 +1,92 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { normalizeRelativePath } from "../utils/path.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export async function collectGitInfo(rootPath) {
8
+ const gitAvailable = await isInsideGitWorkTree(rootPath);
9
+ if (!gitAvailable) {
10
+ return {
11
+ available: false,
12
+ trackedFiles: [],
13
+ ignoredFiles: [],
14
+ ignoredTrackedFiles: [],
15
+ statusShort: []
16
+ };
17
+ }
18
+
19
+ const [trackedFiles, ignoredFiles, ignoredTrackedFiles, statusShort] = await Promise.all([
20
+ gitLsFiles(rootPath, []),
21
+ gitLsFiles(rootPath, ["--others", "-i", "--exclude-standard"]),
22
+ gitLsFiles(rootPath, ["-ci", "--exclude-standard"]),
23
+ gitStatusShort(rootPath)
24
+ ]);
25
+
26
+ return {
27
+ available: true,
28
+ trackedFiles,
29
+ ignoredFiles,
30
+ ignoredTrackedFiles,
31
+ statusShort
32
+ };
33
+ }
34
+
35
+ export async function checkIgnored(rootPath, files) {
36
+ const ignored = new Set();
37
+ const candidates = files.filter(Boolean);
38
+ for (let index = 0; index < candidates.length; index += 100) {
39
+ const chunk = candidates.slice(index, index + 100);
40
+ const result = await runGit(rootPath, ["check-ignore", "--no-index", "-z", "--", ...chunk], [0, 1]);
41
+ if (result.stdout) {
42
+ for (const file of parseNullSeparated(result.stdout)) ignored.add(file);
43
+ }
44
+ }
45
+ return [...ignored];
46
+ }
47
+
48
+ async function isInsideGitWorkTree(rootPath) {
49
+ const result = await runGit(rootPath, ["rev-parse", "--is-inside-work-tree"], [0, 128]);
50
+ return result.exitCode === 0 && result.stdout.trim() === "true";
51
+ }
52
+
53
+ async function gitLsFiles(rootPath, args) {
54
+ const result = await runGit(rootPath, ["ls-files", "-z", ...args], [0]);
55
+ return parseNullSeparated(result.stdout);
56
+ }
57
+
58
+ async function gitStatusShort(rootPath) {
59
+ const result = await runGit(rootPath, ["status", "--short"], [0]);
60
+ return result.stdout
61
+ .split(/\r?\n/)
62
+ .map((line) => line.trimEnd())
63
+ .filter(Boolean);
64
+ }
65
+
66
+ async function runGit(rootPath, args, allowedExitCodes) {
67
+ try {
68
+ const { stdout, stderr } = await execFileAsync("git", args, {
69
+ cwd: rootPath,
70
+ encoding: "buffer",
71
+ maxBuffer: 10 * 1024 * 1024
72
+ });
73
+ return { exitCode: 0, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") };
74
+ } catch (error) {
75
+ const exitCode = typeof error.code === "number" ? error.code : 1;
76
+ if (allowedExitCodes.includes(exitCode)) {
77
+ return {
78
+ exitCode,
79
+ stdout: Buffer.isBuffer(error.stdout) ? error.stdout.toString("utf8") : String(error.stdout || ""),
80
+ stderr: Buffer.isBuffer(error.stderr) ? error.stderr.toString("utf8") : String(error.stderr || "")
81
+ };
82
+ }
83
+ return { exitCode, stdout: "", stderr: error.message };
84
+ }
85
+ }
86
+
87
+ function parseNullSeparated(output) {
88
+ return output
89
+ .split("\0")
90
+ .filter(Boolean)
91
+ .map((file) => normalizeRelativePath(file));
92
+ }
@@ -0,0 +1,56 @@
1
+ import checks from "../checks/index.js";
2
+ import { createContext } from "./context.js";
3
+ import { normalizeFinding, severityRank } from "./findings.js";
4
+
5
+ export async function scanProject(options = {}) {
6
+ const startedAt = new Date();
7
+ const context = await createContext(options);
8
+ const findings = [];
9
+ const warnings = [];
10
+
11
+ for (const check of checks) {
12
+ if (context.config.checks[check.id] === false) continue;
13
+
14
+ try {
15
+ const checkFindings = await check.run(context);
16
+ if (!Array.isArray(checkFindings)) {
17
+ warnings.push({
18
+ checkId: check.id,
19
+ message: "Check returned a non-array result and was ignored."
20
+ });
21
+ continue;
22
+ }
23
+ for (const finding of checkFindings) {
24
+ findings.push(normalizeFinding(check, finding));
25
+ }
26
+ } catch (error) {
27
+ warnings.push({
28
+ checkId: check.id,
29
+ message: error instanceof Error ? error.message : String(error)
30
+ });
31
+ }
32
+ }
33
+
34
+ findings.sort((a, b) => {
35
+ const bySeverity = severityRank.get(b.severity) - severityRank.get(a.severity);
36
+ if (bySeverity !== 0) return bySeverity;
37
+ return `${a.checkId}:${a.file || ""}:${a.line || 0}`.localeCompare(`${b.checkId}:${b.file || ""}:${b.line || 0}`);
38
+ });
39
+
40
+ return {
41
+ findings,
42
+ warnings,
43
+ config: context.config,
44
+ meta: {
45
+ tool: "ItWorksBut",
46
+ version: "0.1.0",
47
+ rootPath: context.rootPath,
48
+ packageManager: context.packageManager,
49
+ gitAvailable: context.gitAvailable,
50
+ filesScanned: context.allFiles.length,
51
+ textFilesScanned: context.textFiles.length,
52
+ startedAt: startedAt.toISOString(),
53
+ completedAt: new Date().toISOString()
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,107 @@
1
+ import { SEVERITIES } from "../core/config.js";
2
+ import { countBySeverity, getExitCode } from "../core/findings.js";
3
+ import { isFancyOutputEnabled, getChalk } from "../cli/terminal.js";
4
+ import { formatSeverity, getConsoleFindingTitle, getShipStatus, renderSummaryBox, renderSummaryTable } from "./consoleStyle.js";
5
+
6
+ export function reportConsole(result, options = {}) {
7
+ const { findings, warnings, config, meta } = result;
8
+ const counts = countBySeverity(findings);
9
+ const colors = getChalk(options);
10
+ const rich = isFancyOutputEnabled(options);
11
+
12
+ if (!options.quiet && !rich) {
13
+ process.stdout.write(`${colors.bold("ItWorksBut receipts")}\n\n`);
14
+ }
15
+
16
+ if (!options.quiet && findings.length === 0) {
17
+ process.stdout.write(`${colors.green ? colors.green("Suspiciously clean. No findings.") : "Suspiciously clean. No findings."}\n\n`);
18
+ } else if (!options.quiet) {
19
+ for (const severity of SEVERITIES) {
20
+ const group = findings.filter((finding) => finding.severity === severity);
21
+ if (group.length === 0) continue;
22
+
23
+ for (const finding of group) {
24
+ writeFinding(finding, options);
25
+ }
26
+ process.stdout.write("\n");
27
+ }
28
+ }
29
+
30
+ if (options.verbose && warnings.length > 0) {
31
+ process.stdout.write("WARNINGS\n");
32
+ for (const warning of warnings) {
33
+ process.stdout.write(`- [${warning.checkId}] ${warning.message}\n`);
34
+ }
35
+ process.stdout.write("\n");
36
+ }
37
+
38
+ const exitCode = getExitCode(findings, config.failOn);
39
+ writeSummary({ counts, total: findings.length, failOn: config.failOn, exitCode }, options);
40
+
41
+ if (options.verbose) {
42
+ process.stdout.write(`- files scanned: ${meta.filesScanned}\n`);
43
+ process.stdout.write(`- text files scanned: ${meta.textFilesScanned}\n`);
44
+ process.stdout.write(`- git available: ${meta.gitAvailable}\n`);
45
+ process.stdout.write(`- warnings: ${warnings.length}\n`);
46
+ }
47
+ }
48
+
49
+ function writeFinding(finding, options) {
50
+ const colors = getChalk(options);
51
+ const severity = formatSeverity(finding.severity, options);
52
+ const title = getConsoleFindingTitle(finding);
53
+ const location = finding.file ? (finding.line ? `${finding.file}:${finding.line}` : finding.file) : "";
54
+
55
+ if (options.compact) {
56
+ const where = location ? `${location} - ` : "";
57
+ process.stdout.write(`${severity.compactText} ${finding.checkId} ${where}${title}\n`);
58
+ return;
59
+ }
60
+
61
+ process.stdout.write(`${severity.text} ${colors.bold(title)}${finding.heuristic ? colors.gray(" (heuristic)") : ""}\n`);
62
+ process.stdout.write(` Check: ${finding.checkId}\n`);
63
+ if (location) process.stdout.write(` File: ${location}\n`);
64
+ process.stdout.write(` Why: ${finding.message}\n`);
65
+ if (finding.recommendation) process.stdout.write(` Fix: ${finding.recommendation}\n`);
66
+
67
+ if (options.verbose) {
68
+ process.stdout.write(` Category: ${finding.category || "unknown"}\n`);
69
+ if (finding.tags?.length) process.stdout.write(` Tags: ${finding.tags.join(", ")}\n`);
70
+ if (finding.line) process.stdout.write(` Line: ${finding.line}\n`);
71
+ const evidence = safeEvidence(finding);
72
+ if (evidence) process.stdout.write(` Evidence: ${evidence}\n`);
73
+ }
74
+
75
+ process.stdout.write("\n");
76
+ }
77
+
78
+ function writeSummary({ counts, total, failOn, exitCode }, options) {
79
+ const colors = getChalk(options);
80
+ const ship = getShipStatus(counts);
81
+
82
+ if (isFancyOutputEnabled(options)) {
83
+ process.stdout.write(`${renderSummaryBox(counts, options)}\n`);
84
+ process.stdout.write(`${renderSummaryTable(counts, options)}\n`);
85
+ process.stdout.write(`\nFail-on: ${failOn} | Exit decision: ${exitCode}\n`);
86
+ return;
87
+ }
88
+
89
+ process.stdout.write("SUMMARY\n");
90
+ process.stdout.write(`- ship status: ${colors.bold(ship.status)}\n`);
91
+ process.stdout.write(`- ${ship.tone}\n`);
92
+ process.stdout.write(`- total findings: ${total}\n`);
93
+ for (const severity of SEVERITIES) {
94
+ process.stdout.write(`- ${severity}: ${counts[severity]}\n`);
95
+ }
96
+ process.stdout.write(`- fail-on: ${failOn}\n`);
97
+ process.stdout.write(`- exit decision: ${exitCode}\n`);
98
+ }
99
+
100
+ function safeEvidence(finding) {
101
+ const metadata = finding.metadata || {};
102
+ if (metadata.secretType) return `secret type: ${metadata.secretType}; value redacted`;
103
+ if (metadata.pattern) return `pattern: ${metadata.pattern}`;
104
+ if (metadata.routePath) return `route: ${metadata.routePath}`;
105
+ if (metadata.envName) return `environment variable name: ${metadata.envName}`;
106
+ return "";
107
+ }
@@ -0,0 +1,155 @@
1
+ import boxen from "boxen";
2
+ import Table from "cli-table3";
3
+ import { SEVERITIES } from "../core/config.js";
4
+ import { getChalk, normalizeTheme } from "../cli/terminal.js";
5
+
6
+ const EDGY_TITLES = {
7
+ "env.env-file-tracked": "It works, but your .env is tracked.",
8
+ "env.possible-secret-in-code": "It works, but your repo may be leaking secrets.",
9
+ "env.frontend-secret-exposure": "It works, but your frontend env variable smells like a backend secret.",
10
+ "git.gitignore-missing": "It works, but your repo forgot what not to commit.",
11
+ "git.gitignore-incomplete": "It works, but your .gitignore has holes.",
12
+ "git.ignored-files-tracked": "It works, but Git is already tracking files you meant to ignore.",
13
+ "dependencies.lockfile-missing": "It works on your machine, but your dependency tree is not locked.",
14
+ "dependencies.multiple-lockfiles": "It works, but your package managers are fighting.",
15
+ "ci.no-ci-config": "It works, but nobody checks it before it ships.",
16
+ "ci.npm-install-instead-of-npm-ci": "It works, but your CI is installing instead of reproducing.",
17
+ "ci.no-test-step": "It works, but your CI is basically decorative.",
18
+ "node.express-json-limit-missing": "It works, but your API accepts oversized bodies.",
19
+ "node.rate-limit-missing": "It works, but your endpoints have no brakes.",
20
+ "node.helmet-missing": "It works, but your HTTP headers are underdressed.",
21
+ "node.cors-wildcard": "It works, but CORS is holding the door open.",
22
+ "web.dangerous-inner-html": "It works, but your frontend is injecting HTML with sharp edges.",
23
+ "api.missing-auth-on-routes": "It works, but this API route appears to trust strangers.",
24
+ "api.idor-risk": "It works, but this ID lookup may belong to someone else.",
25
+ "database.raw-sql-interpolation": "It works, but your SQL query is one template string away from pain.",
26
+ "database.no-migrations": "It works, but your database schema has no paper trail.",
27
+ "electron.node-integration-enabled": "It works, but Electron is holding the Node.js door open.",
28
+ "electron.context-isolation-disabled": "It works, but your renderer and backend are sharing a room.",
29
+ "tauri.dangerous-allowlist-or-capabilities": "It works, but your Tauri permissions look too generous."
30
+ };
31
+
32
+ const SEVERITY_META = {
33
+ critical: { symbol: "✖", label: "CRITICAL" },
34
+ high: { symbol: "▲", label: "HIGH" },
35
+ medium: { symbol: "◆", label: "MEDIUM" },
36
+ low: { symbol: "•", label: "LOW" },
37
+ info: { symbol: "i", label: "INFO" }
38
+ };
39
+
40
+ export function getConsoleFindingTitle(finding) {
41
+ if (EDGY_TITLES[finding.checkId]) return EDGY_TITLES[finding.checkId];
42
+ if (finding.heuristic) return `It works, but this pattern may be risky: ${finding.title || finding.checkId}.`;
43
+ return `It works, but ${lowercaseFirst(finding.title || finding.message || finding.checkId)}.`;
44
+ }
45
+
46
+ export function formatSeverity(severity, options = {}) {
47
+ const colors = getChalk(options);
48
+ const meta = SEVERITY_META[severity] || SEVERITY_META.info;
49
+ const raw = `${meta.symbol} ${meta.label}`;
50
+
51
+ if (normalizeTheme(options.theme) === "mono") {
52
+ return {
53
+ ...meta,
54
+ text: colors.bold(raw),
55
+ compactText: colors.bold(`${meta.symbol} ${meta.label}`)
56
+ };
57
+ }
58
+
59
+ const stylers = {
60
+ critical: (value) => colors.bgRed.white.bold(value),
61
+ high: (value) => colors.red.bold(value),
62
+ medium: (value) => colors.yellow.bold(value),
63
+ low: (value) => colors.blue(value),
64
+ info: (value) => colors.gray(value)
65
+ };
66
+
67
+ const style = stylers[severity] || stylers.info;
68
+ return {
69
+ ...meta,
70
+ text: style(raw),
71
+ compactText: style(`${meta.symbol} ${meta.label}`)
72
+ };
73
+ }
74
+
75
+ export function getShipStatus(counts) {
76
+ if (counts.critical > 0) {
77
+ return {
78
+ status: "DO NOT SHIP",
79
+ tone: "Fix the red stuff before production.",
80
+ severity: "critical"
81
+ };
82
+ }
83
+ if (counts.high > 0) {
84
+ return {
85
+ status: "FIX BEFORE SHIP",
86
+ tone: "Close the obvious holes before shipping.",
87
+ severity: "high"
88
+ };
89
+ }
90
+ if (counts.medium > 0) {
91
+ return {
92
+ status: "SHIP WITH CAUTION",
93
+ tone: "You can ship, but future-you will ask questions.",
94
+ severity: "medium"
95
+ };
96
+ }
97
+ return {
98
+ status: "SHIP IT, BUT STAY PARANOID",
99
+ tone: "Suspiciously clean. Ship it, but stay paranoid.",
100
+ severity: "info"
101
+ };
102
+ }
103
+
104
+ export function renderSummaryBox(counts, options = {}) {
105
+ const colors = getChalk(options);
106
+ const ship = getShipStatus(counts);
107
+ const severity = formatSeverity(ship.severity, options);
108
+ const content = [
109
+ colors.bold("It works, but..."),
110
+ "",
111
+ `Ship status: ${severity.label === "INFO" ? colors.bold(ship.status) : severityColor(ship.status, ship.severity, colors)}`,
112
+ `Critical: ${counts.critical}`,
113
+ `High: ${counts.high}`,
114
+ `Medium: ${counts.medium}`,
115
+ "",
116
+ ship.tone
117
+ ].join("\n");
118
+
119
+ return boxen(content, {
120
+ padding: 1,
121
+ margin: 1,
122
+ borderStyle: "round",
123
+ borderColor: ship.severity === "critical" || ship.severity === "high" ? "red" : ship.severity === "medium" ? "yellow" : "green"
124
+ });
125
+ }
126
+
127
+ export function renderSummaryTable(counts, options = {}) {
128
+ const table = new Table({
129
+ head: ["Severity", "Count"],
130
+ style: {
131
+ head: [],
132
+ border: []
133
+ }
134
+ });
135
+
136
+ for (const severity of SEVERITIES) {
137
+ const formatted = formatSeverity(severity, options);
138
+ table.push([formatted.compactText, counts[severity]]);
139
+ }
140
+
141
+ return table.toString();
142
+ }
143
+
144
+ function severityColor(value, severity, colors) {
145
+ if (severity === "critical") return colors.bgRed.white.bold(value);
146
+ if (severity === "high") return colors.red.bold(value);
147
+ if (severity === "medium") return colors.yellow.bold(value);
148
+ return colors.bold(value);
149
+ }
150
+
151
+ function lowercaseFirst(value) {
152
+ if (!value) return value;
153
+ const normalized = String(value).replace(/\.$/, "");
154
+ return `${normalized.charAt(0).toLowerCase()}${normalized.slice(1)}`;
155
+ }
@@ -0,0 +1,17 @@
1
+ import { countBySeverity, getExitCode } from "../core/findings.js";
2
+
3
+ export function reportJson(result) {
4
+ return {
5
+ tool: result.meta.tool,
6
+ version: result.meta.version,
7
+ meta: result.meta,
8
+ summary: {
9
+ total: result.findings.length,
10
+ bySeverity: countBySeverity(result.findings),
11
+ failOn: result.config.failOn,
12
+ exitCode: getExitCode(result.findings, result.config.failOn)
13
+ },
14
+ findings: result.findings,
15
+ warnings: result.warnings
16
+ };
17
+ }
@@ -0,0 +1,82 @@
1
+ const SARIF_VERSION = "2.1.0";
2
+
3
+ export function reportSarif(result) {
4
+ const rules = buildRules(result.findings);
5
+
6
+ return {
7
+ version: SARIF_VERSION,
8
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
9
+ runs: [
10
+ {
11
+ tool: {
12
+ driver: {
13
+ name: "ItWorksBut",
14
+ informationUri: "https://github.com/itworksbut/itworksbut",
15
+ rules
16
+ }
17
+ },
18
+ results: result.findings.map(toSarifResult),
19
+ invocations: [
20
+ {
21
+ executionSuccessful: true,
22
+ toolExecutionNotifications: result.warnings.map((warning) => ({
23
+ level: "warning",
24
+ message: { text: `[${warning.checkId}] ${warning.message}` }
25
+ }))
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ };
31
+ }
32
+
33
+ function buildRules(findings) {
34
+ const byId = new Map();
35
+ for (const finding of findings) {
36
+ if (byId.has(finding.checkId)) continue;
37
+ byId.set(finding.checkId, {
38
+ id: finding.checkId,
39
+ name: finding.checkId,
40
+ shortDescription: { text: finding.title },
41
+ fullDescription: { text: finding.message },
42
+ help: { text: finding.recommendation || finding.title },
43
+ properties: {
44
+ category: finding.category,
45
+ tags: finding.tags || [],
46
+ precision: finding.heuristic ? "low" : "medium"
47
+ }
48
+ });
49
+ }
50
+ return [...byId.values()];
51
+ }
52
+
53
+ function toSarifResult(finding) {
54
+ return {
55
+ ruleId: finding.checkId,
56
+ level: sarifLevel(finding.severity),
57
+ message: { text: finding.message },
58
+ locations: [
59
+ {
60
+ physicalLocation: {
61
+ artifactLocation: { uri: finding.file || "." },
62
+ region: {
63
+ startLine: finding.line || 1,
64
+ startColumn: finding.column || 1
65
+ }
66
+ }
67
+ }
68
+ ],
69
+ properties: {
70
+ severity: finding.severity,
71
+ category: finding.category,
72
+ recommendation: finding.recommendation,
73
+ heuristic: finding.heuristic
74
+ }
75
+ };
76
+ }
77
+
78
+ function sarifLevel(severity) {
79
+ if (severity === "critical" || severity === "high") return "error";
80
+ if (severity === "medium" || severity === "low") return "warning";
81
+ return "note";
82
+ }
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export const MAX_TEXT_FILE_BYTES = 1024 * 1024;
5
+
6
+ export async function fileExists(absolutePath) {
7
+ try {
8
+ await fs.access(absolutePath);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export async function readFileSafe(absolutePath, maxBytes = MAX_TEXT_FILE_BYTES) {
16
+ try {
17
+ const stat = await fs.stat(absolutePath);
18
+ if (!stat.isFile() || stat.size > maxBytes) return null;
19
+ return await fs.readFile(absolutePath, "utf8");
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export async function readJsonSafe(absolutePath) {
26
+ const content = await readFileSafe(absolutePath);
27
+ if (!content) return null;
28
+ try {
29
+ return JSON.parse(content);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function resolveInside(rootPath, relativePath) {
36
+ return path.resolve(rootPath, relativePath);
37
+ }
38
+
39
+ export async function isLikelyTextFile(absolutePath, maxBytes = MAX_TEXT_FILE_BYTES) {
40
+ try {
41
+ const stat = await fs.stat(absolutePath);
42
+ if (!stat.isFile() || stat.size > maxBytes) return false;
43
+
44
+ const handle = await fs.open(absolutePath, "r");
45
+ try {
46
+ const length = Math.min(8192, stat.size);
47
+ const buffer = Buffer.alloc(length);
48
+ await handle.read(buffer, 0, length, 0);
49
+ if (buffer.includes(0)) return false;
50
+ return true;
51
+ } finally {
52
+ await handle.close();
53
+ }
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
@@ -0,0 +1,14 @@
1
+ export function maskSecret(value) {
2
+ if (!value) return "";
3
+ const text = String(value);
4
+ if (text.length <= 8) return "*".repeat(text.length);
5
+ const prefixLength = Math.min(8, Math.ceil(text.length / 4));
6
+ const suffixLength = Math.min(4, Math.ceil(text.length / 5));
7
+ return `${text.slice(0, prefixLength)}${"*".repeat(Math.max(8, text.length - prefixLength - suffixLength))}${text.slice(-suffixLength)}`;
8
+ }
9
+
10
+ export function redactLine(line) {
11
+ return line.replace(/([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|DATABASE_URL)[A-Z0-9_]*\s*[:=]\s*["']?)([^"'\s]+)/gi, (_match, prefix, value) => {
12
+ return `${prefix}${maskSecret(value)}`;
13
+ });
14
+ }