shercheck 0.1.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/bin/cli.js ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import path from "node:path";
4
+ import { scan } from "../src/scanner.js";
5
+ import { findSensitiveFiles } from "../src/sensitive-files.js";
6
+ import { findSecrets } from "../src/secrets.js";
7
+ import { checkAiExposure } from "../src/ai-exposure.js";
8
+ import { printJsonReport, printReport } from "../src/report.js";
9
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .name("shercheck")
14
+ .description("A tool to check the integrity of your files")
15
+ .version("1.0.0")
16
+ .argument("[path]", "project directory to scan", ".")
17
+ .option("--json", "output results as JSON instead of colored text")
18
+ .option(
19
+ "--fix",
20
+ "auto-create/update AI-ignore files for unprotected sensitive files",
21
+ )
22
+ .option("--ci", "exit with code 1 if any findings are detected")
23
+ .action((targetPath, options) => {
24
+ const resolvedPath = path.resolve(targetPath);
25
+ runScan(resolvedPath, options);
26
+ });
27
+
28
+ program.parse();
29
+
30
+ async function runScan(resolvedPath, options) {
31
+ const files = await scan(resolvedPath);
32
+ const sensitive = findSensitiveFiles(files);
33
+ const secrets = await findSecrets(files, resolvedPath);
34
+ const aiExposure = await checkAiExposure(sensitive, secrets, resolvedPath);
35
+
36
+ if (options.json) {
37
+ printJsonReport(sensitive, secrets, aiExposure);
38
+ } else {
39
+ printReport(sensitive, secrets, aiExposure);
40
+ }
41
+
42
+ // exit code 1 for CI pipelines
43
+ if (options.ci && aiExposure.some((e) => e.fullyExposed)) {
44
+ process.exit(1);
45
+ }
46
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "shercheck",
3
+ "version": "0.1.0",
4
+ "description": "AI-aware security scanner — finds exposed secrets and checks if your AI agent can read sensitive files",
5
+ "type": "module",
6
+ "bin": {
7
+ "shercheck": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "keywords": [
14
+ "security",
15
+ "secrets",
16
+ "ai",
17
+ "cursor",
18
+ "copilot",
19
+ "env",
20
+ "scanner",
21
+ "cli"
22
+ ],
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ }
27
+ }
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import ignore from "ignore";
4
+
5
+ // ─── Config ──────────────────────────────────────────────────────────────────
6
+
7
+ const AI_IGNORE_FILES = [
8
+ { file: ".cursorignore", tool: "Cursor" },
9
+ { file: ".cursorindexingignore", tool: "Cursor" },
10
+ { file: ".aiexclude", tool: "Gemini" },
11
+ { file: ".aiignore", tool: "Generic" },
12
+ { file: ".clineignore", tool: "Cline" },
13
+ { file: ".windsurfignore", tool: "Windsurf" },
14
+ { file: ".geminiignore", tool: "Gemini" },
15
+ { file: ".codeiumignore", tool: "Codeium" },
16
+ { file: ".copilotignore", tool: "Copilot" },
17
+ ];
18
+
19
+ const AI_TOOL_DIRS = [
20
+ { dir: ".cursor", tool: "Cursor" },
21
+ { dir: ".windsurf", tool: "Windsurf" },
22
+ { dir: ".claude", tool: "Claude Code" },
23
+ { dir: ".codeium", tool: "Codeium" },
24
+ { dir: ".gemini", tool: "Gemini" },
25
+ { dir: ".vscode", tool: "VS Code / Copilot" },
26
+ ];
27
+
28
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
29
+
30
+ // detect which AI tools are actually being used in the project
31
+ async function detectTools(rootPath) {
32
+ const detected = [];
33
+
34
+ await Promise.all(
35
+ AI_TOOL_DIRS.map(async ({ dir, tool }) => {
36
+ try {
37
+ await fs.access(path.join(rootPath, dir));
38
+ detected.push(tool);
39
+ } catch {
40
+ // directory doesn't exist, tool not in use
41
+ }
42
+ }),
43
+ );
44
+
45
+ return detected;
46
+ }
47
+
48
+ // parse a single gitignore-style AI ignore file
49
+ // returns { tool, ig } where ig is an ignore instance, or null if file doesn't exist
50
+ async function parseIgnoreFile(rootPath, { file, tool }) {
51
+ try {
52
+ const content = await fs.readFile(path.join(rootPath, file), "utf-8");
53
+ const ig = ignore().add(content);
54
+ return { tool, file, ig };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // parse Claude Code's settings.json separately — it uses JSON, not gitignore syntax
61
+ async function parseClaudeSettings(rootPath) {
62
+ try {
63
+ const content = await fs.readFile(
64
+ path.join(rootPath, ".claude", "settings.json"),
65
+ "utf-8",
66
+ );
67
+ const settings = JSON.parse(content);
68
+ const denyRules = settings?.permissions?.deny ?? [];
69
+ return denyRules;
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ // check if a Claude Code deny rule covers a given file
76
+ // deny rules look like: "Read(.env)" or "Read(secrets/**)"
77
+ function isClaudeDenied(file, denyRules) {
78
+ return denyRules.some((rule) => {
79
+ const match = rule.match(/^Read\((.+)\)$/);
80
+ if (!match) return false;
81
+ const pattern = match[1];
82
+ // exact match or simple wildcard
83
+ if (pattern === file) return true;
84
+ if (pattern.endsWith("/**") && file.startsWith(pattern.slice(0, -3)))
85
+ return true;
86
+ if (pattern.startsWith("*.") && file.endsWith(pattern.slice(1)))
87
+ return true;
88
+ return false;
89
+ });
90
+ }
91
+
92
+ // decide severity based on whether a real secret was found inside this file
93
+ function getSeverity(file, secrets, isTempFile) {
94
+ if (isTempFile) return "low";
95
+ const hasSecret = secrets.some((s) => s.file === file);
96
+ return hasSecret ? "high" : "medium";
97
+ }
98
+
99
+ // ─── Main ────────────────────────────────────────────────────────────────────
100
+
101
+ export async function checkAiExposure(sensitiveFiles, secrets, rootPath) {
102
+ // 1. detect which AI tools are present in this project
103
+ const detectedTools = await detectTools(rootPath);
104
+
105
+ // 2. parse all AI ignore files that exist
106
+ const parsedIgnoreFiles = (
107
+ await Promise.all(
108
+ AI_IGNORE_FILES.map((entry) => parseIgnoreFile(rootPath, entry)),
109
+ )
110
+ ).filter(Boolean); // drop nulls (files that don't exist)
111
+
112
+ // 3. parse Claude Code settings separately
113
+ const claudeDenyRules = await parseClaudeSettings(rootPath);
114
+
115
+ // 4. cross-check each sensitive file
116
+ const exposure = sensitiveFiles.map(({ file }) => {
117
+ const coveredBy = [];
118
+ const notCoveredBy = [];
119
+
120
+ // check each gitignore-style ignore file
121
+ for (const { tool, file: ignoreFile, ig } of parsedIgnoreFiles) {
122
+ try {
123
+ if (ig.ignores(file)) {
124
+ coveredBy.push(ignoreFile);
125
+ } else {
126
+ notCoveredBy.push(ignoreFile);
127
+ }
128
+ } catch {
129
+ // ignore package throws on some edge case paths — treat as not covered
130
+ notCoveredBy.push(ignoreFile);
131
+ }
132
+ }
133
+
134
+ // check Claude Code deny rules
135
+ if (claudeDenyRules.length > 0) {
136
+ if (isClaudeDenied(file, claudeDenyRules)) {
137
+ coveredBy.push(".claude/settings.json");
138
+ } else {
139
+ notCoveredBy.push(".claude/settings.json");
140
+ }
141
+ }
142
+
143
+ const isTempFile = file.includes(".temp/");
144
+ const hasSecret = secrets.some((s) => s.file === file);
145
+
146
+ return {
147
+ file,
148
+ severity: getSeverity(file, secrets, isTempFile),
149
+ coveredBy,
150
+ notCoveredBy,
151
+ detectedTools,
152
+ hasSecret,
153
+ // fully exposed = no AI ignore file covers it at all
154
+ fullyExposed: coveredBy.length === 0,
155
+ };
156
+ });
157
+
158
+ return exposure;
159
+ }
package/src/report.js ADDED
@@ -0,0 +1,216 @@
1
+ import pc from "picocolors";
2
+
3
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
4
+
5
+ function divider(char = "─", length = 50) {
6
+ return pc.dim(char.repeat(length));
7
+ }
8
+
9
+ function severityBadge(severity) {
10
+ if (severity === "high") return pc.bgRed(pc.white(" HIGH "));
11
+ if (severity === "medium") return pc.bgYellow(pc.black(" MEDIUM "));
12
+ return pc.bgBlackBright(pc.white(" LOW "));
13
+ }
14
+
15
+ function sectionHeader(title) {
16
+ console.log(`\n${divider()} ${pc.bold(title)} ${divider()}\n`);
17
+ }
18
+
19
+ // ─── Sections ────────────────────────────────────────────────────────────────
20
+
21
+ function printBanner() {
22
+ console.log("\n" + divider("─", 52));
23
+ console.log(
24
+ pc.bold(" 🔍 SHERCHECK") + pc.dim(" · AI-aware security scanner"),
25
+ );
26
+ console.log(divider("─", 52));
27
+ }
28
+
29
+ function printSummary(sensitive, secrets, exposure) {
30
+ sectionHeader("SUMMARY");
31
+
32
+ const exposed = exposure.filter((e) => e.fullyExposed).length;
33
+ const highCount = exposure.filter((e) => e.severity === "high").length;
34
+ const mediumCount = exposure.filter((e) => e.severity === "medium").length;
35
+ const lowCount = exposure.filter((e) => e.severity === "low").length;
36
+
37
+ const sensitiveLabel =
38
+ sensitive.length > 0
39
+ ? pc.yellow(sensitive.length)
40
+ : pc.green(sensitive.length);
41
+
42
+ const secretsLabel =
43
+ secrets.length > 0
44
+ ? pc.red(pc.bold(secrets.length))
45
+ : pc.green(secrets.length);
46
+
47
+ const exposedLabel =
48
+ exposed > 0 ? pc.red(pc.bold(exposed)) : pc.green(exposed);
49
+
50
+ console.log(` Sensitive files found ${sensitiveLabel}`);
51
+ console.log(` Secrets in content ${secretsLabel}`);
52
+ console.log(` Files exposed to AI agents ${exposedLabel}`);
53
+ console.log();
54
+
55
+ if (highCount > 0)
56
+ console.log(` ${severityBadge("high")} ${highCount} file(s)`);
57
+ if (mediumCount > 0)
58
+ console.log(` ${severityBadge("medium")} ${mediumCount} file(s)`);
59
+ if (lowCount > 0)
60
+ console.log(` ${severityBadge("low")} ${lowCount} file(s)`);
61
+
62
+ if (highCount === 0 && mediumCount === 0 && lowCount === 0) {
63
+ console.log(` ${pc.green("✔")} No exposure issues found`);
64
+ }
65
+ }
66
+
67
+ function printSecrets(secrets) {
68
+ if (secrets.length === 0) return;
69
+
70
+ sectionHeader("SECRETS DETECTED");
71
+
72
+ for (const secret of secrets) {
73
+ console.log(` ${severityBadge("high")} ${pc.bold(pc.red(secret.file))}`);
74
+ console.log(
75
+ ` ${pc.dim("Line " + secret.line + " · " + secret.rule)}`,
76
+ );
77
+ console.log(
78
+ ` ${pc.dim("Preview: ")}${pc.yellow(secret.preview)}`,
79
+ );
80
+ console.log();
81
+ }
82
+ }
83
+
84
+ function printExposure(exposure) {
85
+ sectionHeader("AI EXPOSURE");
86
+
87
+ if (exposure.length === 0) {
88
+ console.log(` ${pc.green("✔")} No sensitive files found\n`);
89
+ return;
90
+ }
91
+
92
+ for (const item of exposure) {
93
+ const badge = severityBadge(item.severity);
94
+
95
+ const statusText = item.fullyExposed
96
+ ? pc.red("fully exposed")
97
+ : pc.green("protected");
98
+
99
+ const secretTag = item.hasSecret
100
+ ? " " + pc.bgRed(pc.white(" has secret "))
101
+ : "";
102
+
103
+ console.log(` ${badge} ${pc.bold(item.file)}`);
104
+ console.log(` Status ${statusText}${secretTag}`);
105
+
106
+ if (item.detectedTools.length > 0) {
107
+ console.log(
108
+ ` Tools ${pc.dim(item.detectedTools.join(", "))}`,
109
+ );
110
+ }
111
+
112
+ if (item.coveredBy.length > 0) {
113
+ console.log(` Covered ${pc.green(item.coveredBy.join(", "))}`);
114
+ }
115
+
116
+ if (item.notCoveredBy.length > 0) {
117
+ console.log(
118
+ ` Missing ${pc.red(item.notCoveredBy.join(", "))}`,
119
+ );
120
+ }
121
+
122
+ console.log();
123
+ }
124
+ }
125
+
126
+ function printRecommendations(exposure) {
127
+ sectionHeader("RECOMMENDATIONS");
128
+
129
+ const exposed = exposure.filter((e) => e.fullyExposed);
130
+
131
+ if (exposed.length === 0) {
132
+ console.log(` ${pc.green("✔")} All sensitive files are protected\n`);
133
+ return;
134
+ }
135
+
136
+ // detect which tools are present across all findings
137
+ const detectedTools = [...new Set(exposure.flatMap((e) => e.detectedTools))];
138
+
139
+ // check if the project has zero AI ignore files at all
140
+ const noIgnoreFiles = exposure.every(
141
+ (e) => e.coveredBy.length === 0 && e.notCoveredBy.length === 0,
142
+ );
143
+
144
+ if (noIgnoreFiles) {
145
+ console.log(` ${pc.red("✖")} No AI ignore files found in this project\n`);
146
+ }
147
+
148
+ // tool-specific suggestions
149
+ const toolIgnoreMap = {
150
+ Cursor: ".cursorignore",
151
+ Windsurf: ".windsurfignore",
152
+ Cline: ".clineignore",
153
+ Gemini: ".aiexclude",
154
+ Codeium: ".codeiumignore",
155
+ "VS Code / Copilot": ".copilotignore",
156
+ "Claude Code": ".claude/settings.json (permissions.deny)",
157
+ Generic: ".aiignore",
158
+ };
159
+
160
+ const exposedFiles = exposed.map((e) => e.file);
161
+
162
+ for (const tool of detectedTools) {
163
+ const ignoreFile = toolIgnoreMap[tool];
164
+ if (!ignoreFile) continue;
165
+
166
+ console.log(` ${pc.cyan("→")} Add these to your ${pc.bold(ignoreFile)}:`);
167
+ exposedFiles.forEach((f) => console.log(` ${pc.yellow(f)}`));
168
+ console.log();
169
+ }
170
+
171
+ // if no specific tool detected, give a generic recommendation
172
+ if (detectedTools.length === 0) {
173
+ console.log(
174
+ ` ${pc.cyan("→")} Create a ${pc.bold(".aiignore")} or ${pc.bold(".cursorignore")} and add:`,
175
+ );
176
+ exposedFiles.forEach((f) => console.log(` ${pc.yellow(f)}`));
177
+ console.log();
178
+ }
179
+
180
+ // rotate credentials warning
181
+ const highExposed = exposed.filter((e) => e.severity === "high");
182
+ if (highExposed.length > 0) {
183
+ console.log(pc.red(pc.bold(" ⚠ Rotate these credentials immediately:")));
184
+ highExposed.forEach((e) => console.log(` ${pc.red(pc.bold(e.file))}`));
185
+ console.log();
186
+ }
187
+ }
188
+
189
+ function printFooter(exposure) {
190
+ const hasIssues = exposure.some((e) => e.fullyExposed);
191
+
192
+ console.log(divider("─", 52));
193
+ if (hasIssues) {
194
+ console.log(pc.red(" ✖ Issues found — review recommendations above"));
195
+ } else {
196
+ console.log(pc.green(" ✔ Project looks clean"));
197
+ }
198
+ console.log(divider("─", 52) + "\n");
199
+ }
200
+
201
+ // ─── Main export ─────────────────────────────────────────────────────────────
202
+
203
+ export function printReport(sensitive, secrets, exposure) {
204
+ printBanner();
205
+ printSummary(sensitive, secrets, exposure);
206
+ printSecrets(secrets);
207
+ printExposure(exposure);
208
+ printRecommendations(exposure);
209
+ printFooter(exposure);
210
+ }
211
+
212
+ // ─── JSON mode export ─────────────────────────────────────────────────────────
213
+
214
+ export function printJsonReport(sensitive, secrets, exposure) {
215
+ console.log(JSON.stringify({ sensitive, secrets, exposure }, null, 2));
216
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,27 @@
1
+ import fg from "fast-glob";
2
+
3
+ export async function scan(path) {
4
+ const files = await fg("**/*", {
5
+ cwd: path,
6
+ dot: true,
7
+ onlyFiles: true,
8
+ ignore: [
9
+ ".DS_Store",
10
+ "node_modules",
11
+ ".git",
12
+ "dist",
13
+ "build",
14
+ "out",
15
+ "target",
16
+ "tmp",
17
+ "package-lock.json",
18
+ "yarn.lock",
19
+ "pnpm-lock.yaml",
20
+ "bun.lockb",
21
+ ".output",
22
+ ".nuxt",
23
+ "*/.nuxt/**",
24
+ ],
25
+ });
26
+ return files;
27
+ }
package/src/secrets.js ADDED
@@ -0,0 +1,230 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+
4
+ const ALLOWED_FILENAMES = new Set([
5
+ "Dockerfile",
6
+ "Makefile",
7
+ "Procfile",
8
+ "Jenkinsfile",
9
+ "Brewfile",
10
+ ]);
11
+
12
+ const ALLOWED_EXTENSIONS = new Set([
13
+ // JavaScript / TypeScript
14
+ ".js",
15
+ ".mjs",
16
+ ".cjs",
17
+ ".ts",
18
+ ".mts",
19
+ ".cts",
20
+ ".jsx",
21
+ ".tsx",
22
+ // Frameworks
23
+ ".vue",
24
+ ".svelte",
25
+ // Config formats
26
+ ".json",
27
+ ".yaml",
28
+ ".yml",
29
+ ".toml",
30
+ ".ini",
31
+ ".cfg",
32
+ ".conf",
33
+ ".xml",
34
+ ".properties",
35
+ // Shell
36
+ ".sh",
37
+ ".bash",
38
+ ".zsh",
39
+ ".fish",
40
+ // Other languages
41
+ ".py",
42
+ ".rb",
43
+ ".php",
44
+ ".go",
45
+ ".rs",
46
+ ".java",
47
+ ".cs",
48
+ // Docs / text
49
+ ".md",
50
+ ".mdx",
51
+ ".txt",
52
+ // Web
53
+ ".env",
54
+ ]);
55
+
56
+ const SECRET_PATTERNS = [
57
+ { name: "AWS Access Key", regex: /AKIA[0-9A-Z]{16}/g },
58
+ { name: "GitHub Token", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g },
59
+ { name: "Stripe Live Key", regex: /sk_live_[0-9a-zA-Z]{24,}/g },
60
+ { name: "Stripe Test Key", regex: /sk_test_[0-9a-zA-Z]{24,}/g },
61
+ { name: "Supabase Key", regex: /sbp_[a-zA-Z0-9]{40,}/g },
62
+ { name: "Anthropic Key", regex: /sk-ant-[a-zA-Z0-9\-]{32,}/g },
63
+ { name: "SendGrid Key", regex: /SG\.[a-zA-Z0-9]{22}\.[a-zA-Z0-9]{43}/g },
64
+ {
65
+ name: "Private Key Block",
66
+ regex: /-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g,
67
+ },
68
+ {
69
+ name: "Generic JWT",
70
+ regex: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
71
+ },
72
+ {
73
+ name: "Hardcoded Secret",
74
+ regex:
75
+ /(SECRET|API_KEY|PRIVATE_KEY|AUTH_TOKEN)\s*[:=]\s*["']?[A-Za-z0-9_\-]{16,}["']?/gi,
76
+ },
77
+ ];
78
+
79
+ const MAX_FILE_SIZE = 500 * 1024;
80
+ const SNIFF_BYTES = 512;
81
+
82
+ async function isBinaryFile(filePath) {
83
+ const handle = await fs.open(filePath, "r");
84
+ try {
85
+ const buffer = Buffer.alloc(SNIFF_BYTES);
86
+ const { bytesRead } = await handle.read(buffer, 0, SNIFF_BYTES, 0);
87
+ return buffer.subarray(0, bytesRead).includes(0);
88
+ } finally {
89
+ await handle.close();
90
+ }
91
+ }
92
+
93
+ const MASK_VISIBLE_CHARS = 4;
94
+ const MASK_FILLER = "*".repeat(8);
95
+
96
+ function maskSecret(secret) {
97
+ if (secret.length <= MASK_VISIBLE_CHARS * 2) {
98
+ return "*".repeat(secret.length);
99
+ }
100
+ return `${secret.slice(0, MASK_VISIBLE_CHARS)}${MASK_FILLER}${secret.slice(-MASK_VISIBLE_CHARS)}`;
101
+ }
102
+
103
+ function maskLine(line, matches) {
104
+ let masked = line;
105
+ for (const match of matches) {
106
+ masked = masked.split(match).join(maskSecret(match));
107
+ }
108
+ return masked.trim();
109
+ }
110
+
111
+ const COMMENT_PREFIXES = ["#", "//", ";"];
112
+ const BLOCK_COMMENT_MARKERS = [
113
+ { start: "/*", end: "*/" },
114
+ { start: "<!--", end: "-->" },
115
+ ];
116
+
117
+ function isCommentLine(line) {
118
+ const trimmed = line.trim();
119
+ return COMMENT_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
120
+ }
121
+
122
+ async function scanContent(content, file) {
123
+ const lines = content.split("\n");
124
+ const findings = [];
125
+
126
+ const isEnvFile = path.basename(file).startsWith(".env");
127
+ const patterns = isEnvFile
128
+ ? SECRET_PATTERNS.filter((p) => p.name !== "Hardcoded Secret")
129
+ : SECRET_PATTERNS;
130
+
131
+ let activeBlockCommentEnd = null;
132
+
133
+ lines.forEach((lineText, index) => {
134
+ const trimmed = lineText.trim();
135
+
136
+ // still inside a multi-line block comment (e.g. /* ... */, <!-- ... -->)
137
+ if (activeBlockCommentEnd) {
138
+ if (trimmed.includes(activeBlockCommentEnd)) {
139
+ activeBlockCommentEnd = null;
140
+ }
141
+ return;
142
+ }
143
+
144
+ // line opens a block comment — skip it, and keep skipping until it closes
145
+ const opener = BLOCK_COMMENT_MARKERS.find((marker) =>
146
+ trimmed.startsWith(marker.start),
147
+ );
148
+ if (opener) {
149
+ const closesOnSameLine = trimmed
150
+ .slice(opener.start.length)
151
+ .includes(opener.end);
152
+ if (!closesOnSameLine) {
153
+ activeBlockCommentEnd = opener.end;
154
+ }
155
+ return;
156
+ }
157
+
158
+ if (isCommentLine(lineText)) return;
159
+
160
+ for (const pattern of patterns) {
161
+ const matches = lineText.match(pattern.regex);
162
+ if (!matches) continue;
163
+
164
+ findings.push({
165
+ file,
166
+ line: index + 1,
167
+ rule: pattern.name,
168
+ match: matches.map(maskSecret),
169
+ preview: maskLine(lineText, matches),
170
+ });
171
+ }
172
+ });
173
+
174
+ return findings;
175
+ }
176
+
177
+ function getExtension(file) {
178
+ const basename = path.basename(file);
179
+ if (basename === ".env" || basename.startsWith(".env.")) {
180
+ return ".env";
181
+ }
182
+ return path.extname(basename).toLowerCase();
183
+ }
184
+
185
+ export async function findSecrets(files, rootPath) {
186
+ // Gate 1 — extension/filename check
187
+ const matchingExtension = files.filter(
188
+ (file) =>
189
+ ALLOWED_EXTENSIONS.has(getExtension(file)) ||
190
+ ALLOWED_FILENAMES.has(path.basename(file)),
191
+ );
192
+
193
+ // Gate 2 — size check
194
+ const sizeChecked = await Promise.all(
195
+ matchingExtension.map(async (file) => {
196
+ try {
197
+ const stat = await fs.stat(path.join(rootPath, file));
198
+ return stat.isFile() && stat.size <= MAX_FILE_SIZE ? file : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }),
203
+ );
204
+ const withinSizeLimit = sizeChecked.filter(Boolean);
205
+
206
+ // Gate 3 — binary sniff (first 512 bytes, null-byte heuristic)
207
+ const binaryChecked = await Promise.all(
208
+ withinSizeLimit.map(async (file) => {
209
+ try {
210
+ const binary = await isBinaryFile(path.join(rootPath, file));
211
+ return binary ? null : file;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }),
216
+ );
217
+
218
+ const findings = await Promise.all(
219
+ binaryChecked.map(async (file) => {
220
+ try {
221
+ const content = await fs.readFile(path.join(rootPath, file), "utf8");
222
+ return scanContent(content, file);
223
+ } catch {
224
+ return [];
225
+ }
226
+ }),
227
+ );
228
+
229
+ return findings.flat();
230
+ }
@@ -0,0 +1,32 @@
1
+ import mm from "micromatch";
2
+
3
+ const SENSITIVE_PATTERNS = [
4
+ ".env",
5
+ ".env.*",
6
+ "**/.env",
7
+ "**/.env.*",
8
+ "*.pem",
9
+ "*.key",
10
+ "id_rsa*",
11
+ "id_ed25519*",
12
+ "credentials.json",
13
+ "**/credentials.json",
14
+ "*.p12",
15
+ "*.pfx",
16
+ ".npmrc",
17
+ "**/.aws/credentials",
18
+ "**/.aws/config",
19
+ "*.keystore",
20
+ "secrets.*",
21
+ "**/secrets.*",
22
+ "**/supabase/.temp/**",
23
+ ];
24
+
25
+ export function findSensitiveFiles(files) {
26
+ return files
27
+ .filter((file) => mm.isMatch(file, SENSITIVE_PATTERNS, { dot: true }))
28
+ .map((file) => ({
29
+ file,
30
+ reason: "matched sensitive filename pattern",
31
+ }));
32
+ }