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 +21 -0
- package/README.md +188 -0
- package/bin/cli.js +49 -0
- package/package.json +42 -0
- package/src/ai-exposure.js +159 -0
- package/src/report.js +216 -0
- package/src/scanner.js +27 -0
- package/src/secrets.js +230 -0
- package/src/sensitive-files.js +32 -0
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
|
+
}
|