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,30 @@
1
+ import { hasText } from "../helpers.js";
2
+
3
+ const RATE_LIMIT_PACKAGES = ["express-rate-limit", "@fastify/rate-limit", "rate-limiter-flexible"];
4
+
5
+ export default {
6
+ id: "node.rate-limit-missing",
7
+ title: "API servers should include rate limiting",
8
+ category: "node",
9
+ severity: "medium",
10
+ tags: ["node", "api", "availability"],
11
+ run: async (context) => {
12
+ const serverDetected =
13
+ context.hasDependency("express") ||
14
+ context.hasDependency("fastify") ||
15
+ context.hasDependency("@fastify/fastify") ||
16
+ (await hasText(context, /\b(app|router)\.(get|post|put|patch|delete)\s*\(|\bfastify\.(get|post|put|patch|delete)\s*\(/g));
17
+
18
+ if (!serverDetected) return [];
19
+ if (RATE_LIMIT_PACKAGES.some((name) => context.hasDependency(name) || context.hasDevDependency(name))) return [];
20
+ if (await hasText(context, /\brateLimit\b|rate-limit|RateLimiter/g)) return [];
21
+
22
+ return [
23
+ {
24
+ message: "An API server appears to exist, but no rate-limit dependency or middleware was detected.",
25
+ recommendation: "Add route-appropriate rate limiting, for example express-rate-limit or @fastify/rate-limit, especially for auth and write endpoints.",
26
+ heuristic: true
27
+ }
28
+ ];
29
+ }
30
+ };
@@ -0,0 +1,30 @@
1
+ const REQUIRED_GROUPS = [
2
+ { label: "test", names: ["test"] },
3
+ { label: "lint", names: ["lint"] },
4
+ { label: "build", names: ["build"] },
5
+ { label: "start or dev", names: ["start", "dev"] },
6
+ { label: "check", names: ["check"] }
7
+ ];
8
+
9
+ export default {
10
+ id: "package.scripts-missing",
11
+ title: "package.json should expose standard project scripts",
12
+ category: "package",
13
+ severity: "low",
14
+ tags: ["node", "ci", "developer-experience"],
15
+ run: async (context) => {
16
+ if (!context.packageJson) return [];
17
+ const scripts = context.packageJson.scripts || {};
18
+ const missing = REQUIRED_GROUPS.filter((group) => !group.names.some((name) => scripts[name])).map((group) => group.label);
19
+ if (missing.length === 0) return [];
20
+
21
+ return [
22
+ {
23
+ message: `package.json appears to be missing standard scripts: ${missing.join(", ")}.`,
24
+ file: "package.json",
25
+ recommendation: "Add predictable scripts so contributors and CI can run tests, linting, builds, startup, and aggregate checks consistently.",
26
+ metadata: { missing }
27
+ }
28
+ ];
29
+ }
30
+ };
@@ -0,0 +1,142 @@
1
+ import { parseJsonWithComments } from "../helpers.js";
2
+
3
+ export default {
4
+ id: "tauri.dangerous-allowlist-or-capabilities",
5
+ title: "Tauri allowlists and capabilities should be narrowly scoped",
6
+ category: "tauri",
7
+ severity: "high",
8
+ tags: ["tauri", "desktop", "capabilities"],
9
+ run: async (context) => {
10
+ const findings = [];
11
+ await inspectTauriConfig(context, findings);
12
+ await inspectCapabilities(context, findings);
13
+ return findings;
14
+ }
15
+ };
16
+
17
+ async function inspectTauriConfig(context, findings) {
18
+ const configFiles = ["src-tauri/tauri.conf.json", "src-tauri/tauri.conf.json5"].filter((file) => context.allFiles.includes(file));
19
+ for (const file of configFiles) {
20
+ const content = await context.readFileSafe(file);
21
+ if (!content) continue;
22
+
23
+ let config;
24
+ try {
25
+ config = parseJsonWithComments(content);
26
+ } catch {
27
+ findings.push({
28
+ severity: "medium",
29
+ message: "Tauri config could not be parsed as JSON. Dangerous allowlist checks may be incomplete.",
30
+ file,
31
+ recommendation: "Keep Tauri config valid and review permissions manually."
32
+ });
33
+ continue;
34
+ }
35
+
36
+ const allowlist = config.tauri?.allowlist || config.allowlist || {};
37
+ if (allowlist.all === true) {
38
+ findings.push(broadFinding(file, "Tauri allowlist all=true grants broad API access.", "Set allowlist.all to false and enable only the APIs required by the app."));
39
+ }
40
+ if (allowlist.shell?.all === true || allowlist.shell?.open === true) {
41
+ findings.push(broadFinding(file, "Tauri shell permissions appear broadly enabled.", "Restrict shell/open permissions to explicit commands and scopes."));
42
+ }
43
+ if (allowlist.fs?.all === true || allowlist.fs?.scope === true || includesBroadScope(allowlist.fs?.scope)) {
44
+ findings.push(broadFinding(file, "Tauri filesystem permissions appear broadly scoped.", "Restrict filesystem scopes to app-specific directories and exact file patterns."));
45
+ }
46
+
47
+ const security = config.tauri?.security || config.app?.security || {};
48
+ if (!security.csp || /\b(unsafe-inline|unsafe-eval|\*)\b/i.test(String(security.csp))) {
49
+ findings.push({
50
+ severity: "medium",
51
+ message: "Tauri CSP is missing or appears overly permissive.",
52
+ file,
53
+ recommendation: "Define a strict CSP without unsafe-inline, unsafe-eval, or wildcard sources unless there is a documented exception.",
54
+ heuristic: true
55
+ });
56
+ }
57
+
58
+ const windows = config.tauri?.windows || config.app?.windows || [];
59
+ for (const windowConfig of Array.isArray(windows) ? windows : []) {
60
+ const url = String(windowConfig.url || "");
61
+ if (/^https?:\/\//i.test(url)) {
62
+ findings.push({
63
+ severity: "medium",
64
+ message: "Tauri window loads a remote URL. This increases the impact of remote content compromise.",
65
+ file,
66
+ recommendation: "Prefer bundled local assets. If remote URLs are required, restrict navigation, enforce strict CSP, and audit permissions carefully.",
67
+ heuristic: true,
68
+ metadata: { remoteUrl: url.replace(/[?#].*$/, "") }
69
+ });
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ async function inspectCapabilities(context, findings) {
76
+ const capabilityFiles = context.findFiles("src-tauri/capabilities/*.json");
77
+ for (const file of capabilityFiles) {
78
+ const content = await context.readFileSafe(file);
79
+ if (!content) continue;
80
+
81
+ let capability;
82
+ try {
83
+ capability = parseJsonWithComments(content);
84
+ } catch {
85
+ findings.push({
86
+ severity: "medium",
87
+ message: "Tauri capability file could not be parsed as JSON.",
88
+ file,
89
+ recommendation: "Keep capability files valid and review permissions manually."
90
+ });
91
+ continue;
92
+ }
93
+
94
+ const permissions = flattenPermissions(capability.permissions || []);
95
+ for (const permission of permissions) {
96
+ if (isBroadPermission(permission)) {
97
+ findings.push(broadFinding(file, `Tauri capability includes broad permission ${permission}.`, "Replace broad permissions with the narrowest command and path scopes required."));
98
+ }
99
+ }
100
+
101
+ if (includesBroadScope(capability.scope) || includesBroadScope(capability.fs?.scope)) {
102
+ findings.push(broadFinding(file, "Tauri capability appears to include a broad filesystem scope.", "Limit scopes to app-owned directories and exact files."));
103
+ }
104
+ }
105
+ }
106
+
107
+ function broadFinding(file, message, recommendation) {
108
+ return {
109
+ severity: "high",
110
+ message,
111
+ file,
112
+ recommendation,
113
+ heuristic: true
114
+ };
115
+ }
116
+
117
+ function flattenPermissions(permissions) {
118
+ const result = [];
119
+ for (const permission of permissions) {
120
+ if (typeof permission === "string") result.push(permission);
121
+ else if (permission && typeof permission.identifier === "string") result.push(permission.identifier);
122
+ }
123
+ return result;
124
+ }
125
+
126
+ function isBroadPermission(permission) {
127
+ return (
128
+ permission === "*" ||
129
+ permission.endsWith(":*") ||
130
+ permission.includes("allow-all") ||
131
+ permission.includes("shell:allow-open") ||
132
+ permission.includes("fs:allow")
133
+ );
134
+ }
135
+
136
+ function includesBroadScope(scope) {
137
+ const values = Array.isArray(scope) ? scope : scope ? [scope] : [];
138
+ return values.some((value) => {
139
+ const text = typeof value === "string" ? value : JSON.stringify(value);
140
+ return /\*\*|^\*$|^\/$|\$HOME|\$APPDATA|\$RESOURCE|\.\./i.test(text);
141
+ });
142
+ }
@@ -0,0 +1,40 @@
1
+ import { hasAuthKeyword, isFrontendFile, isServerOrApiFile } from "../helpers.js";
2
+
3
+ const CLIENT_AUTH_RE = /\b(role\s*={2,3}\s*["']admin["']|isAdmin|user\.role|roles\.includes|hasRole)\b/i;
4
+
5
+ export default {
6
+ id: "web.client-side-auth-only",
7
+ title: "Authorization should not appear to exist only in frontend code",
8
+ category: "web",
9
+ severity: "medium",
10
+ tags: ["web", "auth", "heuristic"],
11
+ run: async (context) => {
12
+ const clientMatches = [];
13
+ let serverAuthDetected = false;
14
+
15
+ for (const file of context.textFiles) {
16
+ const content = await context.readFileSafe(file);
17
+ if (!content) continue;
18
+
19
+ if (isFrontendFile(file) && CLIENT_AUTH_RE.test(content)) {
20
+ clientMatches.push(file);
21
+ }
22
+
23
+ if (isServerOrApiFile(file) && (CLIENT_AUTH_RE.test(content) || hasAuthKeyword(content))) {
24
+ serverAuthDetected = true;
25
+ }
26
+ }
27
+
28
+ if (clientMatches.length === 0 || serverAuthDetected) return [];
29
+
30
+ return [
31
+ {
32
+ message: "Possible role or admin checks appear in frontend files, but matching server/API authorization checks were not detected.",
33
+ file: clientMatches[0],
34
+ recommendation: "Enforce authorization on the server/API side. Treat frontend checks as UI hints only.",
35
+ heuristic: true,
36
+ metadata: { frontendFiles: clientMatches.slice(0, 10) }
37
+ }
38
+ ];
39
+ }
40
+ };
@@ -0,0 +1,33 @@
1
+ const PATTERNS = [
2
+ { regex: /dangerouslySetInnerHTML/g, label: "dangerouslySetInnerHTML" },
3
+ { regex: /\.innerHTML\s*=/g, label: "innerHTML assignment" },
4
+ { regex: /insertAdjacentHTML\s*\(/g, label: "insertAdjacentHTML" }
5
+ ];
6
+
7
+ export default {
8
+ id: "web.dangerous-inner-html",
9
+ title: "Direct HTML injection APIs should be reviewed",
10
+ category: "web",
11
+ severity: "high",
12
+ tags: ["web", "xss", "frontend"],
13
+ run: async (context) => {
14
+ const findings = [];
15
+ for (const pattern of PATTERNS) {
16
+ const matches = await context.grep(pattern.regex, {
17
+ include: ["*.js", "*.ts", "*.jsx", "*.tsx", "*.vue", "*.svelte", "*.html"],
18
+ maxMatches: 100
19
+ });
20
+ for (const match of matches) {
21
+ findings.push({
22
+ message: `${pattern.label} appears to be used. This can create XSS risk if any input is attacker-controlled.`,
23
+ file: match.file,
24
+ line: match.line,
25
+ column: match.column,
26
+ recommendation: "Avoid raw HTML insertion when possible. If HTML is required, sanitize with a proven sanitizer and keep trusted and untrusted content separate.",
27
+ heuristic: true
28
+ });
29
+ }
30
+ }
31
+ return findings;
32
+ }
33
+ };
@@ -0,0 +1,34 @@
1
+ import { lineFromOffset } from "../helpers.js";
2
+
3
+ const UNSAFE_HTML_RESPONSE_RE = /\b(?:res\.(?:send|end|write)|reply\.send|new Response)\s*\(\s*`[^`]*<[^`]*\$\{[^}]*(?:req\.(?:body|query|params)|request|searchParams|params)[^}]*\}[^`]*`/gis;
4
+
5
+ export default {
6
+ id: "web.missing-output-sanitization",
7
+ title: "HTML built from request data should be sanitized or escaped",
8
+ category: "web",
9
+ severity: "medium",
10
+ tags: ["web", "xss", "heuristic"],
11
+ run: async (context) => {
12
+ const findings = [];
13
+
14
+ for (const file of context.textFiles) {
15
+ if (!/\.[cm]?[jt]sx?$/.test(file)) continue;
16
+ const content = await context.readFileSafe(file);
17
+ if (!content) continue;
18
+
19
+ let match;
20
+ UNSAFE_HTML_RESPONSE_RE.lastIndex = 0;
21
+ while ((match = UNSAFE_HTML_RESPONSE_RE.exec(content)) !== null) {
22
+ findings.push({
23
+ message: "Possible HTML response construction from request-controlled data appears without obvious escaping.",
24
+ file,
25
+ line: lineFromOffset(content, match.index),
26
+ recommendation: "Escape output by context or use a templating/rendering layer that escapes by default. Sanitize only when raw HTML is explicitly required.",
27
+ heuristic: true
28
+ });
29
+ }
30
+ }
31
+
32
+ return findings.slice(0, 100);
33
+ }
34
+ };
@@ -0,0 +1,29 @@
1
+ export function printUsage() {
2
+ process.stdout.write(`ItWorksBut
3
+
4
+ Usage:
5
+ itworksbut scan [options]
6
+ node ./bin/itworksbut.js scan [options]
7
+
8
+ Options:
9
+ --path <path> Project path to scan. Defaults to current directory.
10
+ --config <path> Optional itworksbut.config.json path.
11
+ --fail-on <level> Exit 1 when findings meet or exceed this severity.
12
+ Levels: critical, high, medium, low, info. Default: low.
13
+ --json Print machine-readable JSON.
14
+ --sarif Print SARIF for GitHub Code Scanning.
15
+ --no-color Disable color styling.
16
+ --no-banner Disable the intro banner.
17
+ --no-spinner Disable scan spinner.
18
+ --compact Print one-line findings.
19
+ --quiet Print only the summary.
20
+ --theme <theme> Console theme: default, toxic, mono.
21
+ --verbose Include scanner warnings and extra metadata.
22
+ --help Show this help.
23
+ `);
24
+ }
25
+
26
+ export function printRuntimeError(error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ process.stderr.write(`ItWorksBut runtime error: ${message}\n`);
29
+ }
@@ -0,0 +1,75 @@
1
+ const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path", "--theme"]);
2
+ const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--verbose", "--help", "-h", "--no-color", "--no-banner", "--no-spinner", "--compact", "--quiet"]);
3
+
4
+ export function parseArgs(argv) {
5
+ const args = {
6
+ command: "scan",
7
+ path: ".",
8
+ config: undefined,
9
+ failOn: undefined,
10
+ json: false,
11
+ sarif: false,
12
+ verbose: false,
13
+ noColor: false,
14
+ noBanner: false,
15
+ noSpinner: false,
16
+ compact: false,
17
+ quiet: false,
18
+ theme: "default",
19
+ help: false
20
+ };
21
+
22
+ const tokens = [...argv];
23
+ if (tokens[0] && !tokens[0].startsWith("-")) {
24
+ args.command = tokens.shift();
25
+ }
26
+
27
+ for (let index = 0; index < tokens.length; index += 1) {
28
+ const token = tokens[index];
29
+
30
+ if (token.includes("=") && token.startsWith("--")) {
31
+ const [flag, ...rest] = token.split("=");
32
+ assignValue(args, flag, rest.join("="));
33
+ continue;
34
+ }
35
+
36
+ if (FLAG_WITH_VALUE.has(token)) {
37
+ const value = tokens[index + 1];
38
+ if (!value || value.startsWith("-")) {
39
+ throw new Error(`Missing value for ${token}`);
40
+ }
41
+ assignValue(args, token, value);
42
+ index += 1;
43
+ continue;
44
+ }
45
+
46
+ if (BOOLEAN_FLAGS.has(token)) {
47
+ if (token === "--help" || token === "-h") args.help = true;
48
+ if (token === "--json") args.json = true;
49
+ if (token === "--sarif") args.sarif = true;
50
+ if (token === "--verbose") args.verbose = true;
51
+ if (token === "--no-color") args.noColor = true;
52
+ if (token === "--no-banner") args.noBanner = true;
53
+ if (token === "--no-spinner") args.noSpinner = true;
54
+ if (token === "--compact") args.compact = true;
55
+ if (token === "--quiet") args.quiet = true;
56
+ continue;
57
+ }
58
+
59
+ throw new Error(`Unknown argument: ${token}`);
60
+ }
61
+
62
+ if (args.json && args.sarif) {
63
+ throw new Error("Use only one output format: --json or --sarif");
64
+ }
65
+
66
+ return args;
67
+ }
68
+
69
+ function assignValue(args, flag, value) {
70
+ if (flag === "--fail-on") args.failOn = value;
71
+ else if (flag === "--config") args.config = value;
72
+ else if (flag === "--path") args.path = value;
73
+ else if (flag === "--theme") args.theme = value;
74
+ else throw new Error(`Unknown argument: ${flag}`);
75
+ }
@@ -0,0 +1,112 @@
1
+ import boxen from "boxen";
2
+ import chalk, { Chalk } from "chalk";
3
+ import figlet from "figlet";
4
+ import gradient from "gradient-string";
5
+ import ora from "ora";
6
+
7
+ const THEMES = new Set(["default", "toxic", "mono"]);
8
+
9
+ const SPINNER_TEXT = {
10
+ git: "Checking git hygiene",
11
+ env: "Sniffing for secrets",
12
+ dependencies: "Interrogating package.json",
13
+ ci: "Inspecting CI rituals",
14
+ node: "Poking the Node.js backend",
15
+ web: "Looking for frontend footguns",
16
+ api: "Testing the API trust issues",
17
+ database: "Watching SQL strings misbehave",
18
+ electron: "Opening the Electron danger drawer",
19
+ tauri: "Reading Tauri permissions",
20
+ default: "Looking for things that work but should not ship"
21
+ };
22
+
23
+ export function normalizeTheme(theme) {
24
+ const normalized = String(theme || "default").toLowerCase();
25
+ if (!THEMES.has(normalized)) {
26
+ throw new Error(`Invalid theme "${theme}". Expected one of: default, toxic, mono`);
27
+ }
28
+ return normalized;
29
+ }
30
+
31
+ export function isFancyOutputEnabled(options = {}, env = process.env, stdout = process.stdout) {
32
+ return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.noColor && normalizeTheme(options.theme) !== "mono";
33
+ }
34
+
35
+ export function isColorEnabled(options = {}, env = process.env, stdout = process.stdout) {
36
+ if (options.noColor || options.json || options.sarif) return false;
37
+ if (normalizeTheme(options.theme) === "mono") return false;
38
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return true;
39
+ if (env.CI) return false;
40
+ return Boolean(stdout.isTTY);
41
+ }
42
+
43
+ export function getChalk(options = {}) {
44
+ if (!isColorEnabled(options)) return new Chalk({ level: 0 });
45
+ return chalk;
46
+ }
47
+
48
+ export function shouldUseSpinner(options = {}, env = process.env, stdout = process.stdout) {
49
+ return (
50
+ Boolean(stdout.isTTY) &&
51
+ !env.CI &&
52
+ !options.json &&
53
+ !options.sarif &&
54
+ !options.noSpinner &&
55
+ !options.quiet
56
+ );
57
+ }
58
+
59
+ export function createScanSpinner(options = {}) {
60
+ if (!shouldUseSpinner(options)) return null;
61
+ return ora({
62
+ text: SPINNER_TEXT.default,
63
+ stream: process.stderr,
64
+ color: normalizeTheme(options.theme) === "toxic" ? "green" : "cyan"
65
+ });
66
+ }
67
+
68
+ export function printIntro(options = {}) {
69
+ if (options.json || options.sarif || options.noBanner || options.quiet || process.env.CI || !process.stdout.isTTY) {
70
+ return;
71
+ }
72
+
73
+ const theme = normalizeTheme(options.theme);
74
+ const colors = getChalk(options);
75
+ const renderTheme = options.noColor ? "mono" : theme;
76
+ const title = renderTitle(renderTheme);
77
+ const claim =
78
+ theme === "toxic"
79
+ ? `${colors.bold("Green builds. Red flags.")}\n${colors.green("Let's see what breaks before production.")}`
80
+ : `${colors.bold("AI-built? Nice.")}\n${colors.yellow("Now let's see what breaks before production.")}`;
81
+
82
+ process.stdout.write(`${title}\n`);
83
+ process.stdout.write(
84
+ `${boxen(claim, {
85
+ padding: 1,
86
+ margin: 1,
87
+ borderStyle: "round",
88
+ borderColor: renderTheme === "mono" ? undefined : renderTheme === "toxic" ? "green" : "cyan"
89
+ })}\n`
90
+ );
91
+ }
92
+
93
+ function renderTitle(theme) {
94
+ let title = "ItWorksBut";
95
+ try {
96
+ title = figlet.textSync("ItWorksBut", {
97
+ font: "ANSI Shadow",
98
+ horizontalLayout: "default",
99
+ verticalLayout: "default"
100
+ });
101
+ } catch {
102
+ title = "ItWorksBut";
103
+ }
104
+
105
+ try {
106
+ if (theme === "mono") return title;
107
+ if (theme === "toxic") return gradient(["#faff00", "#39ff14", "#00f5ff"])(title);
108
+ return gradient.rainbow(title);
109
+ } catch {
110
+ return title;
111
+ }
112
+ }
@@ -0,0 +1,51 @@
1
+ import path from "node:path";
2
+ import { fileExists, readJsonSafe } from "../utils/fs.js";
3
+
4
+ export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
5
+
6
+ export const DEFAULT_IGNORE = [
7
+ "node_modules/**",
8
+ "dist/**",
9
+ "build/**",
10
+ ".next/**",
11
+ ".nuxt/**",
12
+ "coverage/**",
13
+ ".git/**",
14
+ "target/**",
15
+ "src-tauri/target/**",
16
+ "out/**",
17
+ "release/**",
18
+ ".vite/**"
19
+ ];
20
+
21
+ export async function loadConfig(rootPath, configPath, overrides = {}) {
22
+ const resolvedConfigPath = configPath
23
+ ? path.resolve(rootPath, configPath)
24
+ : path.join(rootPath, "itworksbut.config.json");
25
+
26
+ let userConfig = {};
27
+ if (await fileExists(resolvedConfigPath)) {
28
+ userConfig = await readJsonSafe(resolvedConfigPath);
29
+ if (!userConfig || typeof userConfig !== "object" || Array.isArray(userConfig)) {
30
+ throw new Error(`Invalid config JSON: ${resolvedConfigPath}`);
31
+ }
32
+ }
33
+
34
+ const failOn = normalizeSeverity(overrides.failOn || userConfig.failOn || "low");
35
+
36
+ return {
37
+ ignore: [...DEFAULT_IGNORE, ...(Array.isArray(userConfig.ignore) ? userConfig.ignore : [])],
38
+ failOn,
39
+ checks: userConfig.checks && typeof userConfig.checks === "object" ? userConfig.checks : {},
40
+ configPath: await fileExists(resolvedConfigPath) ? resolvedConfigPath : null
41
+ };
42
+ }
43
+
44
+ export function normalizeSeverity(value) {
45
+ if (!value) return "low";
46
+ const normalized = String(value).toLowerCase();
47
+ if (!SEVERITIES.includes(normalized)) {
48
+ throw new Error(`Invalid severity "${value}". Expected one of: ${SEVERITIES.join(", ")}`);
49
+ }
50
+ return normalized;
51
+ }
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+ import { loadConfig } from "./config.js";
3
+ import { walkProject } from "./fileWalker.js";
4
+ import { checkIgnored, collectGitInfo } from "./git.js";
5
+ import { fileExists as fileExistsAbsolute, readFileSafe as readFileSafeAbsolute, resolveInside } from "../utils/fs.js";
6
+ import { detectPackageManager, hasDependency as packageHasDependency, hasDevDependency as packageHasDevDependency, readPackageJson } from "../utils/packageJson.js";
7
+ import { matchesGlob, normalizeRelativePath } from "../utils/path.js";
8
+ import { maskSecret } from "../utils/mask.js";
9
+
10
+ export async function createContext(options = {}) {
11
+ const rootPath = path.resolve(options.rootPath || ".");
12
+ const config = await loadConfig(rootPath, options.configPath, { failOn: options.failOn });
13
+ const { allFiles, textFiles } = await walkProject(rootPath, config.ignore);
14
+ const packageJson = await readPackageJson(rootPath);
15
+ const gitInfo = await collectGitInfo(rootPath);
16
+ const ignoredTrackedByCheckIgnore = gitInfo.available ? await checkIgnored(rootPath, gitInfo.trackedFiles) : [];
17
+
18
+ const context = {
19
+ rootPath,
20
+ packageJson,
21
+ packageManager: detectPackageManager(packageJson, allFiles),
22
+ allFiles,
23
+ textFiles,
24
+ gitTrackedFiles: gitInfo.trackedFiles,
25
+ gitIgnoredFiles: gitInfo.ignoredFiles,
26
+ gitIgnoredTrackedFiles: unique([...gitInfo.ignoredTrackedFiles, ...ignoredTrackedByCheckIgnore]),
27
+ gitStatusShort: gitInfo.statusShort,
28
+ gitAvailable: gitInfo.available,
29
+ config,
30
+ maskSecret,
31
+ readFileSafe: async (relativePath) => await readFileSafeAbsolute(resolveInside(rootPath, relativePath)),
32
+ fileExists: async (relativePath) => await fileExistsAbsolute(resolveInside(rootPath, relativePath)),
33
+ hasDependency: (name) => packageHasDependency(packageJson, name),
34
+ hasDevDependency: (name) => packageHasDevDependency(packageJson, name),
35
+ findFiles: (pattern) => allFiles.filter((file) => matchesGlob(file, pattern)),
36
+ grep: async (pattern, grepOptions = {}) => grep(context, pattern, grepOptions)
37
+ };
38
+
39
+ return context;
40
+ }
41
+
42
+ async function grep(context, pattern, options) {
43
+ const results = [];
44
+ const regex = pattern instanceof RegExp ? ensureGlobal(pattern) : new RegExp(escapeRegExp(String(pattern)), "g");
45
+ const include = options.include || ["**"];
46
+ const exclude = options.exclude || [];
47
+ const maxMatches = options.maxMatches || 500;
48
+
49
+ for (const file of context.textFiles) {
50
+ if (!include.some((glob) => glob === "**" || matchesGlob(file, glob))) continue;
51
+ if (exclude.some((glob) => matchesGlob(file, glob))) continue;
52
+
53
+ const content = await context.readFileSafe(file);
54
+ if (!content) continue;
55
+ const lines = content.split(/\r?\n/);
56
+
57
+ for (let index = 0; index < lines.length; index += 1) {
58
+ regex.lastIndex = 0;
59
+ let match;
60
+ while ((match = regex.exec(lines[index])) !== null) {
61
+ results.push({
62
+ file: normalizeRelativePath(file),
63
+ line: index + 1,
64
+ column: match.index + 1,
65
+ match,
66
+ text: lines[index]
67
+ });
68
+ if (results.length >= maxMatches) return results;
69
+ if (match.index === regex.lastIndex) regex.lastIndex += 1;
70
+ }
71
+ }
72
+ }
73
+
74
+ return results;
75
+ }
76
+
77
+ function ensureGlobal(regex) {
78
+ return regex.global ? regex : new RegExp(regex.source, `${regex.flags}g`);
79
+ }
80
+
81
+ function escapeRegExp(value) {
82
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
83
+ }
84
+
85
+ function unique(values) {
86
+ return [...new Set(values)];
87
+ }