sherscan 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NightWiing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # ๐Ÿ” sherscan
2
+
3
+ **AI-aware security scanner**, finds exposed secrets in your codebase and checks whether your AI coding agent (Cursor, Copilot, Windsurf, Claude Code, etc.) can actually read your sensitive files.
4
+
5
+ Most secret scanners stop at "you have a `.env` file." sherscan goes one step further: it checks whether that file is actually **blocked from AI agents** via `.cursorignore`, `.aiexclude`, `.clineignore`, Claude Code's deny rules.
6
+
7
+ ## Why
8
+
9
+ AI coding assistants can read almost any file in your project by default. If your `.env`, private keys, or credentials aren't explicitly excluded, your AI agent (and anything it shares context with) can see them. sherscan audits this blind spot in seconds.
10
+
11
+ ## Features
12
+
13
+ - ๐Ÿ”Ž **Sensitive file detection**, flags `.env` files, private keys (`.pem`, `.key`, `id_rsa`), cloud credentials (`.aws/credentials`, `.npmrc`), keystores, and more.
14
+ - ๐Ÿ”‘ **Secret scanning**, greps file contents for real leaked credentials: AWS keys, GitHub tokens, Stripe keys, Supabase keys, Anthropic keys, SendGrid keys, private key blocks, JWTs, and generic hardcoded secrets.
15
+ - ๐Ÿค– **AI exposure check**, cross-references every sensitive file against the ignore files of 9+ AI tools (Cursor, Windsurf, Cline, Gemini, Codeium, Copilot, Claude Code, and generic `.aiignore`) to see if it's actually protected.
16
+ - ๐Ÿ›ก๏ธ **Masked previews**, secrets are shown masked (e.g. `AKIA********MNOP`) so reports are safe to share/paste.
17
+ - ๐Ÿ“Š **Readable or machine output**, pretty colored terminal report by default, or `--json` for scripting.
18
+ - ๐Ÿšฆ **CI-friendly**, `--ci` flag exits with code `1` when exposed secrets are found, so you can fail a pipeline on real risk.
19
+
20
+ ## Installation
21
+
22
+ Run it directly without installing anything (recommended):
23
+
24
+ ```bash
25
+ npx sherscan
26
+ ```
27
+
28
+ Or install globally:
29
+
30
+ ```bash
31
+ npm install -g sherscan
32
+ ```
33
+
34
+ Or add it as a dev dependency to run via npm scripts:
35
+
36
+ ```bash
37
+ npm install --save-dev sherscan
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ Scan the current directory:
43
+
44
+ ```bash
45
+ npx sherscan
46
+ ```
47
+
48
+ Scan a specific project path:
49
+
50
+ ```bash
51
+ npx sherscan ./path/to/project
52
+ ```
53
+
54
+ Output JSON (for scripting or piping into other tools):
55
+
56
+ ```bash
57
+ npx sherscan --json
58
+ ```
59
+
60
+ Fail CI when a sensitive file with no AI-ignore coverage is found:
61
+
62
+ ```bash
63
+ npx sherscan --ci
64
+ ```
65
+
66
+ ### Options
67
+
68
+ | Flag | Description |
69
+ | --------------- | ------------------------------------------------------------- |
70
+ | `[path]` | Project directory to scan (defaults to `.`) |
71
+ | `--json` | Output results as JSON instead of a colored report |
72
+ | `--ci` | Exit with code `1` if any fully-exposed findings are detected |
73
+ | `-V, --version` | Print the version number |
74
+ | `-h, --help` | Print usage help |
75
+
76
+ ## Example output
77
+
78
+ ```
79
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
80
+ ๐Ÿ” sherscan ยท AI-aware security scanner
81
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
82
+
83
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ SUMMARY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
84
+
85
+ Sensitive files found 1
86
+ Secrets in content 1
87
+ Files exposed to AI agents 1
88
+
89
+ HIGH 1 file(s)
90
+
91
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ SECRETS DETECTED โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
92
+
93
+ HIGH .env
94
+ Line 1 ยท AWS Access Key
95
+ Preview: SECRET_KEY=AKIA********MNOP
96
+
97
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ AI EXPOSURE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
98
+
99
+ HIGH .env
100
+ Status fully exposed has secret
101
+
102
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ RECOMMENDATIONS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
103
+
104
+ โœ– No AI ignore files found in this project
105
+
106
+ โ†’ Create a .aiignore or .cursorignore and add:
107
+ .env
108
+
109
+ โš  Rotate these credentials immediately:
110
+ .env
111
+
112
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
113
+ โœ– Issues found โ€” review recommendations above
114
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
115
+ ```
116
+
117
+ ## What it checks
118
+
119
+ ### Sensitive files
120
+
121
+ sherscan flags files matching common sensitive-file patterns, including:
122
+
123
+ - `.env`, `.env.*` (any environment file)
124
+ - `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*` (private keys)
125
+ - `*.p12`, `*.pfx`, `*.keystore` (certificates/keystores)
126
+ - `credentials.json`, `secrets.*`
127
+ - `.npmrc`
128
+ - `.aws/credentials`, `.aws/config`
129
+ - Supabase's `supabase/.temp/**` directory
130
+
131
+ ### Secret patterns
132
+
133
+ File contents (for common source/config file types under 500 KB, skipping binaries) are scanned for:
134
+
135
+ - AWS Access Keys
136
+ - GitHub Tokens
137
+ - Stripe Live/Test Keys
138
+ - Supabase Keys
139
+ - Anthropic API Keys
140
+ - SendGrid Keys
141
+ - Private key blocks (`-----BEGIN ... PRIVATE KEY-----`)
142
+ - Generic JWTs
143
+ - Hardcoded `SECRET` / `API_KEY` / `PRIVATE_KEY` / `AUTH_TOKEN` assignments
144
+
145
+ Matches inside comments are skipped, and all detected secrets are masked in the report.
146
+
147
+ ### AI exposure
148
+
149
+ For every sensitive file found, sherscan checks whether it's covered by any of these AI-tool ignore mechanisms:
150
+
151
+ | Tool | Ignore file |
152
+ | ----------- | -------------------------------------------------- |
153
+ | Cursor | `.cursorignore`, `.cursorindexingignore` |
154
+ | Windsurf | `.windsurfignore` |
155
+ | Cline | `.clineignore` |
156
+ | Gemini | `.aiexclude`, `.geminiignore` |
157
+ | Codeium | `.codeiumignore` |
158
+ | Copilot | `.copilotignore` |
159
+ | Claude Code | `.claude/settings.json` (`permissions.deny` rules) |
160
+ | Generic | `.aiignore` |
161
+
162
+ A file is marked **fully exposed** if none of the detected AI tools' ignore rules cover it โ€” meaning an AI agent working in that project could read it.
163
+
164
+ ## Using in CI
165
+
166
+ Add sherscan to your pipeline to catch exposed secrets before they ship:
167
+
168
+ ```yaml
169
+ # .github/workflows/security.yml
170
+ name: Security check
171
+ on: [push, pull_request]
172
+ jobs:
173
+ sherscan:
174
+ runs-on: ubuntu-latest
175
+ steps:
176
+ - uses: actions/checkout@v4
177
+ - run: npx sherscan --ci
178
+ ```
179
+
180
+ The command exits with code `1` if any sensitive file is fully exposed to an AI agent, failing the build.
181
+
182
+ ## Requirements
183
+
184
+ - Node.js >= 18
185
+
186
+ ## License
187
+
188
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import path from "node:path";
4
+ import fs from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { scan } from "../src/scanner.js";
7
+ import { findSensitiveFiles } from "../src/sensitive-files.js";
8
+ import { findSecrets } from "../src/secrets.js";
9
+ import { checkAiExposure } from "../src/ai-exposure.js";
10
+ import { printJsonReport, printReport } from "../src/report.js";
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const pkg = JSON.parse(
14
+ await fs.readFile(path.join(__dirname, "..", "package.json"), "utf-8"),
15
+ );
16
+
17
+ const program = new Command();
18
+
19
+ program
20
+ .name("sherscan")
21
+ .description("A tool to check the integrity of your files")
22
+ .version(pkg.version)
23
+ .argument("[path]", "project directory to scan", ".")
24
+ .option("--json", "output results as JSON instead of colored text")
25
+ .option("--ci", "exit with code 1 if any findings are detected")
26
+ .action((targetPath, options) => {
27
+ const resolvedPath = path.resolve(targetPath);
28
+ runScan(resolvedPath, options);
29
+ });
30
+
31
+ program.parse();
32
+
33
+ async function runScan(resolvedPath, options) {
34
+ const files = await scan(resolvedPath);
35
+ const sensitive = findSensitiveFiles(files);
36
+ const secrets = await findSecrets(files, resolvedPath);
37
+ const aiExposure = await checkAiExposure(sensitive, secrets, resolvedPath);
38
+
39
+ if (options.json) {
40
+ printJsonReport(sensitive, secrets, aiExposure);
41
+ } else {
42
+ printReport(sensitive, secrets, aiExposure);
43
+ }
44
+
45
+ // exit code 1 for CI pipelines
46
+ if (options.ci && aiExposure.some((e) => e.fullyExposed)) {
47
+ process.exit(1);
48
+ }
49
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "sherscan",
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
+ "sherscan": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "dependencies": {
14
+ "commander": "^15.0.0",
15
+ "fast-glob": "^3.3.3",
16
+ "ignore": "^7.0.5",
17
+ "micromatch": "^4.0.8",
18
+ "picocolors": "^1.1.1"
19
+ },
20
+ "keywords": [
21
+ "security",
22
+ "secrets",
23
+ "ai",
24
+ "cursor",
25
+ "copilot",
26
+ "env",
27
+ "scanner",
28
+ "cli"
29
+ ],
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/nightwiing/sherscan.git"
34
+ },
35
+ "homepage": "https://github.com/nightwiing/sherscan#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/nightwiing/sherscan/issues"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ }
42
+ }
@@ -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(" ๐Ÿ” sherscan") + 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
+ }