pi-lens 2.1.0 → 2.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.
- package/CHANGELOG.md +17 -0
- package/README.md +32 -1
- package/clients/ast-grep-client.js +12 -12
- package/clients/ast-grep-client.ts +21 -11
- package/clients/dispatch/dispatcher.js +2 -2
- package/clients/dispatch/dispatcher.ts +2 -2
- package/clients/dispatch/runners/secrets.js +109 -0
- package/clients/secrets-scanner.js +113 -0
- package/clients/secrets-scanner.test.js +100 -0
- package/clients/secrets-scanner.test.ts +113 -0
- package/clients/secrets-scanner.ts +134 -0
- package/clients/sg-runner.js +15 -2
- package/clients/sg-runner.ts +25 -2
- package/commands/fix.js +48 -50
- package/commands/fix.ts +71 -61
- package/commands/refactor.js +33 -9
- package/commands/refactor.ts +44 -11
- package/default-architect.yaml +7 -0
- package/index.ts +43 -10
- package/package.json +1 -1
- package/rules/ast-grep-rules/rules/no-default-export.yml +19 -0
- package/rules/ast-grep-rules/rules/no-hardcoded-secrets.yml +9 -6
- package/rules/ast-grep-rules/rules/no-process-env.yml +12 -12
- package/rules/ast-grep-rules/rules/no-relative-imports.yml +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-lens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.1.1] - 2026-03-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Content-level secret scanning**: Catches secrets in ANY file type on write/edit (`.env`, `.yaml`, `.json`, not just TypeScript). Blocks before save with patterns for `sk-*`, `ghp_*`, `AKIA*`, private keys, hardcoded passwords.
|
|
9
|
+
- **Project rules integration**: Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` at session start and surfaces in system prompt.
|
|
10
|
+
- **Grep-ability rules**: New ast-grep rules for `no-default-export` and `no-relative-cross-package-import` to improve agent searchability.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Inline feedback stripped to blocking only**: Warnings no longer shown inline (noise). Only blocking violations and test failures interrupt the agent.
|
|
14
|
+
- **booboo-fix output compacted**: Summary in terminal, full plan in `.pi-lens/reports/fix-plan.tsv`.
|
|
15
|
+
- **booboo-refactor output compacted**: Top 5 worst offenders in terminal, full ranked list in `.pi-lens/reports/refactor-ranked.tsv`.
|
|
16
|
+
- **`ast_grep_search` new params**: Added `selector` (extract specific AST node) and `context` (show surrounding lines).
|
|
17
|
+
- **`ast_grep_replace` mode indicator**: Shows `[DRY-RUN]` or `[APPLIED]` prefix.
|
|
18
|
+
- **no-hardcoded-secrets**: Fixed to only flag actual hardcoded strings (not `process.env` assignments).
|
|
19
|
+
- **no-process-env**: Now only flags secret-related env vars (not PORT, NODE_ENV, etc.).
|
|
20
|
+
- **Removed Factory AI article reference** from architect.yaml.
|
|
21
|
+
|
|
5
22
|
## [2.0.40] - 2026-03-27
|
|
6
23
|
|
|
7
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -16,6 +16,37 @@ pi install git:github.com/apmantza/pi-lens
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## What's New (v2.1)
|
|
20
|
+
|
|
21
|
+
### Content-Level Secret Scanning
|
|
22
|
+
|
|
23
|
+
Secrets are now blocked **before** they're saved, on **all file types**:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
🔴 STOP — 1 potential secret(s) in src/config.ts:
|
|
27
|
+
L12: Possible Stripe or OpenAI API key (sk-*)
|
|
28
|
+
→ Remove before continuing. Use env vars instead.
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Works on `.env`, `.yaml`, `.json`, `.md` — not just TypeScript. Catches `sk-*`, `ghp_*`, `AKIA*`, private keys, hardcoded passwords.
|
|
32
|
+
|
|
33
|
+
### Compact Output
|
|
34
|
+
|
|
35
|
+
Inline feedback stripped to **blocking only** — no more warning noise:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
🔴 STOP — 2 issue(s) must fixed:
|
|
39
|
+
L23: var total = sum(items); — use 'let' or 'const'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Warnings are tracked and surfaced via `/lens-booboo`. booboo-fix and booboo-refactor output compacted to summaries with TSV files for full details.
|
|
43
|
+
|
|
44
|
+
### Project Rules Integration
|
|
45
|
+
|
|
46
|
+
Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` at session start. Project-specific rules are surfaced in the system prompt — the agent knows to read them when relevant. Works alongside pi-lens architect rules.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
19
50
|
## What's New (v2.0)
|
|
20
51
|
|
|
21
52
|
### Declarative Dispatch System
|
|
@@ -184,7 +215,7 @@ These files provide **general project guidance** (coding conventions, workflow r
|
|
|
184
215
|
|
|
185
216
|
| Tool | Description |
|
|
186
217
|
|---|---|
|
|
187
|
-
| **`ast_grep_search`** | Search code patterns using AST-aware matching. Supports meta-variables: `$VAR` (single node), `$$$` (multiple). Example: `console.log($MSG)` |
|
|
218
|
+
| **`ast_grep_search`** | Search code patterns using AST-aware matching. Supports meta-variables: `$VAR` (single node), `$$$` (multiple). Optional: `selector` (extract specific AST node), `context` (show surrounding lines). Example: `console.log($MSG)` |
|
|
188
219
|
| **`ast_grep_replace`** | Replace code patterns with AST-aware rewriting. Dry-run by default, use `apply=true` to apply changes. Example: `pattern='console.log($MSG)' rewrite='logger.info($MSG)'` |
|
|
189
220
|
|
|
190
221
|
Supported languages: c, cpp, csharp, css, dart, elixir, go, haskell, html, java, javascript, json, kotlin, lua, php, python, ruby, rust, scala, sql, swift, tsx, typescript, yaml
|
|
@@ -45,16 +45,16 @@ export class AstGrepClient {
|
|
|
45
45
|
/**
|
|
46
46
|
* Search for AST patterns in files
|
|
47
47
|
*/
|
|
48
|
-
async search(pattern, lang, paths) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
async search(pattern, lang, paths, options) {
|
|
49
|
+
const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
|
|
50
|
+
if (options?.selector) {
|
|
51
|
+
args.push("--selector", options.selector);
|
|
52
|
+
}
|
|
53
|
+
if (options?.context !== undefined) {
|
|
54
|
+
args.push("--context", String(options.context));
|
|
55
|
+
}
|
|
56
|
+
args.push(...paths);
|
|
57
|
+
return this.runner.exec(args);
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
60
|
* Search and replace AST patterns
|
|
@@ -165,8 +165,8 @@ message: found
|
|
|
165
165
|
}
|
|
166
166
|
return exports;
|
|
167
167
|
}
|
|
168
|
-
formatMatches(matches, isDryRun = false) {
|
|
169
|
-
return this.runner.formatMatches(matches, isDryRun);
|
|
168
|
+
formatMatches(matches, isDryRun = false, showModeIndicator = false) {
|
|
169
|
+
return this.runner.formatMatches(matches, isDryRun, 50, showModeIndicator);
|
|
170
170
|
}
|
|
171
171
|
/**
|
|
172
172
|
* Scan a file against all rules
|
|
@@ -92,16 +92,17 @@ export class AstGrepClient {
|
|
|
92
92
|
pattern: string,
|
|
93
93
|
lang: string,
|
|
94
94
|
paths: string[],
|
|
95
|
+
options?: { selector?: string; context?: number },
|
|
95
96
|
): Promise<{ matches: AstGrepMatch[]; error?: string }> {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
|
|
98
|
+
if (options?.selector) {
|
|
99
|
+
args.push("--selector", options.selector);
|
|
100
|
+
}
|
|
101
|
+
if (options?.context !== undefined) {
|
|
102
|
+
args.push("--context", String(options.context));
|
|
103
|
+
}
|
|
104
|
+
args.push(...paths);
|
|
105
|
+
return this.runner.exec(args);
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
/**
|
|
@@ -257,8 +258,17 @@ message: found
|
|
|
257
258
|
return exports;
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
formatMatches(
|
|
261
|
-
|
|
261
|
+
formatMatches(
|
|
262
|
+
matches: AstGrepMatch[],
|
|
263
|
+
isDryRun = false,
|
|
264
|
+
showModeIndicator = false,
|
|
265
|
+
): string {
|
|
266
|
+
return this.runner.formatMatches(
|
|
267
|
+
matches as SgMatch[],
|
|
268
|
+
isDryRun,
|
|
269
|
+
50,
|
|
270
|
+
showModeIndicator,
|
|
271
|
+
);
|
|
262
272
|
}
|
|
263
273
|
|
|
264
274
|
/**
|
|
@@ -191,9 +191,9 @@ export async function dispatchForFile(ctx, groups) {
|
|
|
191
191
|
const blockers = allDiagnostics.filter((d) => d.semantic === "blocking");
|
|
192
192
|
const warnings = allDiagnostics.filter((d) => d.semantic === "warning" || d.semantic === "none");
|
|
193
193
|
const fixedItems = allDiagnostics.filter((d) => d.semantic === "fixed");
|
|
194
|
-
// Format output
|
|
194
|
+
// Format output — only blocking issues shown inline
|
|
195
|
+
// Warnings tracked but not shown (noise) — surfaced via /lens-booboo
|
|
195
196
|
let output = formatDiagnostics(blockers, "blocking");
|
|
196
|
-
output += formatDiagnostics(warnings, "warning");
|
|
197
197
|
output += formatDiagnostics(fixedItems, "fixed");
|
|
198
198
|
return {
|
|
199
199
|
diagnostics: allDiagnostics,
|
|
@@ -266,9 +266,9 @@ export async function dispatchForFile(
|
|
|
266
266
|
);
|
|
267
267
|
const fixedItems = allDiagnostics.filter((d) => d.semantic === "fixed");
|
|
268
268
|
|
|
269
|
-
// Format output
|
|
269
|
+
// Format output — only blocking issues shown inline
|
|
270
|
+
// Warnings tracked but not shown (noise) — surfaced via /lens-booboo
|
|
270
271
|
let output = formatDiagnostics(blockers, "blocking");
|
|
271
|
-
output += formatDiagnostics(warnings, "warning");
|
|
272
272
|
output += formatDiagnostics(fixedItems, "fixed");
|
|
273
273
|
|
|
274
274
|
return {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets scanner runner
|
|
3
|
+
*
|
|
4
|
+
* Scans file content for potential secret patterns before write.
|
|
5
|
+
* Works on all file types via regex matching.
|
|
6
|
+
*
|
|
7
|
+
* Patterns detected:
|
|
8
|
+
* - Stripe/OpenAI keys (sk-*)
|
|
9
|
+
* - GitHub tokens (ghp_*, gho_*, github_pat_*)
|
|
10
|
+
* - AWS keys (AKIA*)
|
|
11
|
+
* - Slack tokens (xoxp-*, xoxb-*)
|
|
12
|
+
* - Private keys (BEGIN PRIVATE KEY)
|
|
13
|
+
* - Generic API key patterns
|
|
14
|
+
*/
|
|
15
|
+
const SECRET_PATTERNS = [
|
|
16
|
+
{
|
|
17
|
+
pattern: /sk-[a-zA-Z0-9]{20,}/g,
|
|
18
|
+
name: "stripe-openai-key",
|
|
19
|
+
message: "Possible Stripe or OpenAI API key (sk-*)",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
pattern: /ghp_[a-zA-Z0-9]{36}/g,
|
|
23
|
+
name: "github-personal-token",
|
|
24
|
+
message: "GitHub personal access token (ghp_*)",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
pattern: /gho_[a-zA-Z0-9]{36}/g,
|
|
28
|
+
name: "github-oauth-token",
|
|
29
|
+
message: "GitHub OAuth token (gho_*)",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
pattern: /github_pat_[a-zA-Z_]{82}/g,
|
|
33
|
+
name: "github-fine-grained-pat",
|
|
34
|
+
message: "GitHub fine-grained PAT (github_pat_*)",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
pattern: /AKIA[0-9A-Z]{16}/g,
|
|
38
|
+
name: "aws-access-key",
|
|
39
|
+
message: "AWS access key ID (AKIA*)",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
pattern: /xox[bp]-[a-zA-Z0-9]{10,}/g,
|
|
43
|
+
name: "slack-token",
|
|
44
|
+
message: "Slack token (xoxb-*/xoxp-*)",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE KEY-----/g,
|
|
48
|
+
name: "private-key",
|
|
49
|
+
message: "Private key material detected",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
pattern: /password\s*[:=]\s*["'][^"']{8,}["']/gi,
|
|
53
|
+
name: "hardcoded-password",
|
|
54
|
+
message: "Possible hardcoded password",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pattern: /(?:secret|api_?key|token)\s*[:=]\s*["'][a-zA-Z0-9]{20,}["']/gi,
|
|
58
|
+
name: "generic-api-secret",
|
|
59
|
+
message: "Possible hardcoded secret, API key, or token",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
function scanContent(content) {
|
|
63
|
+
const findings = [];
|
|
64
|
+
const lines = content.split("\n");
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i];
|
|
67
|
+
for (const secret of SECRET_PATTERNS) {
|
|
68
|
+
secret.pattern.lastIndex = 0; // Reset regex state
|
|
69
|
+
const match = secret.pattern.exec(line);
|
|
70
|
+
if (match) {
|
|
71
|
+
findings.push({
|
|
72
|
+
line: i + 1,
|
|
73
|
+
pattern: secret,
|
|
74
|
+
match: match[0].slice(0, 20) + "...",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
async function run(ctx) {
|
|
82
|
+
const diagnostics = [];
|
|
83
|
+
// Get the content being written - check both full content and the diff
|
|
84
|
+
const content = ctx.content;
|
|
85
|
+
if (!content)
|
|
86
|
+
return { diagnostics };
|
|
87
|
+
const findings = scanContent(content);
|
|
88
|
+
for (const finding of findings) {
|
|
89
|
+
diagnostics.push({
|
|
90
|
+
id: `secrets-${finding.pattern.name}-${finding.line}`,
|
|
91
|
+
message: `${finding.pattern.message} at line ${finding.line}. Remove before committing.`,
|
|
92
|
+
filePath: ctx.filePath,
|
|
93
|
+
line: finding.line,
|
|
94
|
+
severity: "error",
|
|
95
|
+
semantic: "blocking", // Always blocking - secrets should never be committed
|
|
96
|
+
tool: "secrets",
|
|
97
|
+
rule: finding.pattern.name,
|
|
98
|
+
fixable: false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { diagnostics };
|
|
102
|
+
}
|
|
103
|
+
const runner = {
|
|
104
|
+
id: "secrets",
|
|
105
|
+
appliesTo: ["*"], // All file types
|
|
106
|
+
priority: 0, // Run first - block before other checks
|
|
107
|
+
run,
|
|
108
|
+
};
|
|
109
|
+
export default runner;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-level secrets scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans file content for potential secret patterns before write.
|
|
5
|
+
* Works on all file types via regex matching.
|
|
6
|
+
*
|
|
7
|
+
* Detected patterns:
|
|
8
|
+
* - Stripe/OpenAI keys (sk-*)
|
|
9
|
+
* - GitHub tokens (ghp_*, gho_*, github_pat_*)
|
|
10
|
+
* - AWS keys (AKIA*)
|
|
11
|
+
* - Slack tokens (xoxp-*, xoxb-*)
|
|
12
|
+
* - Private keys (BEGIN PRIVATE KEY)
|
|
13
|
+
* - Generic API key/password patterns
|
|
14
|
+
*/
|
|
15
|
+
// Patterns ordered by specificity - first match wins per line
|
|
16
|
+
const SECRET_PATTERNS = [
|
|
17
|
+
// High-confidence: specific key prefixes
|
|
18
|
+
{
|
|
19
|
+
pattern: /sk-[a-zA-Z0-9-]{20,}/g,
|
|
20
|
+
name: "stripe-openai-key",
|
|
21
|
+
message: "Possible Stripe or OpenAI API key (sk-*)",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pattern: /ghp_[a-zA-Z0-9]{36}/g,
|
|
25
|
+
name: "github-personal-token",
|
|
26
|
+
message: "GitHub personal access token (ghp_*)",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
pattern: /gho_[a-zA-Z0-9]{36}/g,
|
|
30
|
+
name: "github-oauth-token",
|
|
31
|
+
message: "GitHub OAuth token (gho_*)",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
pattern: /github_pat_[a-zA-Z_]{82}/g,
|
|
35
|
+
name: "github-fine-grained-pat",
|
|
36
|
+
message: "GitHub fine-grained PAT (github_pat_*)",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
pattern: /AKIA[0-9A-Z]{16}/g,
|
|
40
|
+
name: "aws-access-key",
|
|
41
|
+
message: "AWS access key ID (AKIA*)",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
pattern: /xox[bp]-[a-zA-Z0-9]{10,}/g,
|
|
45
|
+
name: "slack-token",
|
|
46
|
+
message: "Slack token (xoxb-*/xoxp-*)",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE KEY-----/g,
|
|
50
|
+
name: "private-key",
|
|
51
|
+
message: "Private key material detected",
|
|
52
|
+
},
|
|
53
|
+
// Medium-confidence: quoted credentials
|
|
54
|
+
{
|
|
55
|
+
pattern: /password\s*[:=]\s*["'][^"']{4,}["']/gi,
|
|
56
|
+
name: "hardcoded-password",
|
|
57
|
+
message: "Possible hardcoded password",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
pattern: /(?:secret|api_?key|token|access_?key)\s*[:=]\s*["'][a-zA-Z0-9_\-/.]{8,}["']/gi,
|
|
61
|
+
name: "hardcoded-secret",
|
|
62
|
+
message: "Possible hardcoded secret or API key",
|
|
63
|
+
},
|
|
64
|
+
// .env format: KEY=VALUE (no quotes)
|
|
65
|
+
{
|
|
66
|
+
pattern: /^(?:API_?KEY|SECRET|TOKEN|PASSWORD|AWS_?ACCESS_?KEY)\s*=\s*\S{8,}/gim,
|
|
67
|
+
name: "env-file-secret",
|
|
68
|
+
message: "Possible secret in .env format",
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* Scan content for potential secrets
|
|
73
|
+
* Returns findings with line numbers
|
|
74
|
+
*/
|
|
75
|
+
export function scanForSecrets(content) {
|
|
76
|
+
const findings = [];
|
|
77
|
+
const lines = content.split("\n");
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i];
|
|
80
|
+
let matched = false;
|
|
81
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
82
|
+
// Reset lastIndex before each test (important for global regex)
|
|
83
|
+
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
|
|
84
|
+
if (regex.test(line)) {
|
|
85
|
+
findings.push({
|
|
86
|
+
line: i + 1,
|
|
87
|
+
message: pattern.message,
|
|
88
|
+
});
|
|
89
|
+
matched = true;
|
|
90
|
+
break; // One finding per line
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return findings;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Format secrets findings for terminal output
|
|
98
|
+
*/
|
|
99
|
+
export function formatSecrets(findings, filePath) {
|
|
100
|
+
if (findings.length === 0)
|
|
101
|
+
return "";
|
|
102
|
+
const lines = [
|
|
103
|
+
`🔴 STOP — ${findings.length} potential secret(s) in ${filePath}:`,
|
|
104
|
+
];
|
|
105
|
+
for (const f of findings.slice(0, 5)) {
|
|
106
|
+
lines.push(` L${f.line}: ${f.message}`);
|
|
107
|
+
}
|
|
108
|
+
if (findings.length > 5) {
|
|
109
|
+
lines.push(` ... and ${findings.length - 5} more`);
|
|
110
|
+
}
|
|
111
|
+
lines.push(" → Remove before continuing. Use env vars instead.");
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatSecrets, scanForSecrets } from "./secrets-scanner.js";
|
|
3
|
+
describe("scanForSecrets", () => {
|
|
4
|
+
it("should detect Stripe/OpenAI keys (sk-*)", () => {
|
|
5
|
+
const content = `const apiKey = "sk-live-1234567890abcdefghij";`;
|
|
6
|
+
const findings = scanForSecrets(content);
|
|
7
|
+
expect(findings.length).toBe(1);
|
|
8
|
+
expect(findings[0].message).toContain("Stripe or OpenAI");
|
|
9
|
+
});
|
|
10
|
+
it("should detect GitHub personal tokens (ghp_*)", () => {
|
|
11
|
+
const content = `token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";`;
|
|
12
|
+
const findings = scanForSecrets(content);
|
|
13
|
+
expect(findings.length).toBe(1);
|
|
14
|
+
expect(findings[0].message).toContain("GitHub personal");
|
|
15
|
+
});
|
|
16
|
+
it("should detect AWS access keys (AKIA*)", () => {
|
|
17
|
+
const content = `const AWS_KEY = "AKIAIOSFODNN7EXAMPLE";`;
|
|
18
|
+
const findings = scanForSecrets(content);
|
|
19
|
+
expect(findings.length).toBe(1);
|
|
20
|
+
expect(findings[0].message).toContain("AWS access key");
|
|
21
|
+
});
|
|
22
|
+
it("should detect private key material", () => {
|
|
23
|
+
const content = `-----BEGIN RSA PRIVATE KEY-----
|
|
24
|
+
MIIEpAIBAAKCAQEA...`;
|
|
25
|
+
const findings = scanForSecrets(content);
|
|
26
|
+
expect(findings.length).toBe(1);
|
|
27
|
+
expect(findings[0].message).toContain("Private key");
|
|
28
|
+
});
|
|
29
|
+
it("should detect hardcoded passwords", () => {
|
|
30
|
+
const content = `const config = { password: "hunter2" };`;
|
|
31
|
+
const findings = scanForSecrets(content);
|
|
32
|
+
expect(findings.length).toBe(1);
|
|
33
|
+
expect(findings[0].message).toContain("password");
|
|
34
|
+
});
|
|
35
|
+
it("should detect secrets in .env format", () => {
|
|
36
|
+
const content = `API_KEY=sk-live-1234567890abcdefghij
|
|
37
|
+
DATABASE_URL=postgres://localhost`;
|
|
38
|
+
const findings = scanForSecrets(content);
|
|
39
|
+
expect(findings.length).toBe(1);
|
|
40
|
+
// sk-* pattern catches this first (more specific)
|
|
41
|
+
expect(findings[0].message).toContain("Stripe or OpenAI");
|
|
42
|
+
});
|
|
43
|
+
it("should NOT flag safe content", () => {
|
|
44
|
+
const content = `
|
|
45
|
+
const name = "test";
|
|
46
|
+
const url = "https://example.com";
|
|
47
|
+
const port = 3000;
|
|
48
|
+
const message = "Hello world";
|
|
49
|
+
`;
|
|
50
|
+
const findings = scanForSecrets(content);
|
|
51
|
+
expect(findings.length).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
it("should NOT flag env var references", () => {
|
|
54
|
+
const content = `const key = process.env.API_KEY;`;
|
|
55
|
+
const findings = scanForSecrets(content);
|
|
56
|
+
expect(findings.length).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
it("should detect multiple secrets", () => {
|
|
59
|
+
const content = `
|
|
60
|
+
const sk = "sk-live-1234567890abcdefghij";
|
|
61
|
+
const gh = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
|
|
62
|
+
`;
|
|
63
|
+
const findings = scanForSecrets(content);
|
|
64
|
+
expect(findings.length).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
it("should report correct line numbers", () => {
|
|
67
|
+
const content = `line 1
|
|
68
|
+
line 2
|
|
69
|
+
const secret = "sk-live-1234567890abcdefghij";
|
|
70
|
+
line 4`;
|
|
71
|
+
const findings = scanForSecrets(content);
|
|
72
|
+
expect(findings.length).toBe(1);
|
|
73
|
+
expect(findings[0].line).toBe(3);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("formatSecrets", () => {
|
|
77
|
+
it("should format findings for terminal output", () => {
|
|
78
|
+
const findings = [
|
|
79
|
+
{ line: 5, message: "Possible Stripe or OpenAI API key (sk-*)" },
|
|
80
|
+
];
|
|
81
|
+
const output = formatSecrets(findings, "src/config.ts");
|
|
82
|
+
expect(output).toContain("STOP");
|
|
83
|
+
expect(output).toContain("1 potential secret(s)");
|
|
84
|
+
expect(output).toContain("L5");
|
|
85
|
+
expect(output).toContain("src/config.ts");
|
|
86
|
+
});
|
|
87
|
+
it("should return empty string for no findings", () => {
|
|
88
|
+
const output = formatSecrets([], "src/config.ts");
|
|
89
|
+
expect(output).toBe("");
|
|
90
|
+
});
|
|
91
|
+
it("should truncate at 5 findings", () => {
|
|
92
|
+
const findings = Array.from({ length: 10 }, (_, i) => ({
|
|
93
|
+
line: i + 1,
|
|
94
|
+
message: "Test secret",
|
|
95
|
+
}));
|
|
96
|
+
const output = formatSecrets(findings, "src/config.ts");
|
|
97
|
+
expect(output).toContain("10 potential secret(s)");
|
|
98
|
+
expect(output).toContain("... and 5 more");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatSecrets, scanForSecrets } from "./secrets-scanner.js";
|
|
3
|
+
|
|
4
|
+
describe("scanForSecrets", () => {
|
|
5
|
+
it("should detect Stripe/OpenAI keys (sk-*)", () => {
|
|
6
|
+
const content = `const apiKey = "sk-live-1234567890abcdefghij";`;
|
|
7
|
+
const findings = scanForSecrets(content);
|
|
8
|
+
expect(findings.length).toBe(1);
|
|
9
|
+
expect(findings[0].message).toContain("Stripe or OpenAI");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should detect GitHub personal tokens (ghp_*)", () => {
|
|
13
|
+
const content = `token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";`;
|
|
14
|
+
const findings = scanForSecrets(content);
|
|
15
|
+
expect(findings.length).toBe(1);
|
|
16
|
+
expect(findings[0].message).toContain("GitHub personal");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should detect AWS access keys (AKIA*)", () => {
|
|
20
|
+
const content = `const AWS_KEY = "AKIAIOSFODNN7EXAMPLE";`;
|
|
21
|
+
const findings = scanForSecrets(content);
|
|
22
|
+
expect(findings.length).toBe(1);
|
|
23
|
+
expect(findings[0].message).toContain("AWS access key");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should detect private key material", () => {
|
|
27
|
+
const content = `-----BEGIN RSA PRIVATE KEY-----
|
|
28
|
+
MIIEpAIBAAKCAQEA...`;
|
|
29
|
+
const findings = scanForSecrets(content);
|
|
30
|
+
expect(findings.length).toBe(1);
|
|
31
|
+
expect(findings[0].message).toContain("Private key");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should detect hardcoded passwords", () => {
|
|
35
|
+
const content = `const config = { password: "hunter2" };`;
|
|
36
|
+
const findings = scanForSecrets(content);
|
|
37
|
+
expect(findings.length).toBe(1);
|
|
38
|
+
expect(findings[0].message).toContain("password");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should detect secrets in .env format", () => {
|
|
42
|
+
const content = `API_KEY=sk-live-1234567890abcdefghij
|
|
43
|
+
DATABASE_URL=postgres://localhost`;
|
|
44
|
+
const findings = scanForSecrets(content);
|
|
45
|
+
expect(findings.length).toBe(1);
|
|
46
|
+
// sk-* pattern catches this first (more specific)
|
|
47
|
+
expect(findings[0].message).toContain("Stripe or OpenAI");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should NOT flag safe content", () => {
|
|
51
|
+
const content = `
|
|
52
|
+
const name = "test";
|
|
53
|
+
const url = "https://example.com";
|
|
54
|
+
const port = 3000;
|
|
55
|
+
const message = "Hello world";
|
|
56
|
+
`;
|
|
57
|
+
const findings = scanForSecrets(content);
|
|
58
|
+
expect(findings.length).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should NOT flag env var references", () => {
|
|
62
|
+
const content = `const key = process.env.API_KEY;`;
|
|
63
|
+
const findings = scanForSecrets(content);
|
|
64
|
+
expect(findings.length).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should detect multiple secrets", () => {
|
|
68
|
+
const content = `
|
|
69
|
+
const sk = "sk-live-1234567890abcdefghij";
|
|
70
|
+
const gh = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
|
|
71
|
+
`;
|
|
72
|
+
const findings = scanForSecrets(content);
|
|
73
|
+
expect(findings.length).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should report correct line numbers", () => {
|
|
77
|
+
const content = `line 1
|
|
78
|
+
line 2
|
|
79
|
+
const secret = "sk-live-1234567890abcdefghij";
|
|
80
|
+
line 4`;
|
|
81
|
+
const findings = scanForSecrets(content);
|
|
82
|
+
expect(findings.length).toBe(1);
|
|
83
|
+
expect(findings[0].line).toBe(3);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("formatSecrets", () => {
|
|
88
|
+
it("should format findings for terminal output", () => {
|
|
89
|
+
const findings = [
|
|
90
|
+
{ line: 5, message: "Possible Stripe or OpenAI API key (sk-*)" },
|
|
91
|
+
];
|
|
92
|
+
const output = formatSecrets(findings, "src/config.ts");
|
|
93
|
+
expect(output).toContain("STOP");
|
|
94
|
+
expect(output).toContain("1 potential secret(s)");
|
|
95
|
+
expect(output).toContain("L5");
|
|
96
|
+
expect(output).toContain("src/config.ts");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should return empty string for no findings", () => {
|
|
100
|
+
const output = formatSecrets([], "src/config.ts");
|
|
101
|
+
expect(output).toBe("");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should truncate at 5 findings", () => {
|
|
105
|
+
const findings = Array.from({ length: 10 }, (_, i) => ({
|
|
106
|
+
line: i + 1,
|
|
107
|
+
message: "Test secret",
|
|
108
|
+
}));
|
|
109
|
+
const output = formatSecrets(findings, "src/config.ts");
|
|
110
|
+
expect(output).toContain("10 potential secret(s)");
|
|
111
|
+
expect(output).toContain("... and 5 more");
|
|
112
|
+
});
|
|
113
|
+
});
|