pi-lens 2.1.0 → 2.2.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/CHANGELOG.md +27 -0
- package/README.md +70 -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/index.js +3 -1
- package/clients/dispatch/runners/index.ts +3 -1
- package/clients/dispatch/runners/pyright.js +68 -0
- package/clients/dispatch/runners/pyright.test.js +84 -0
- package/clients/dispatch/runners/pyright.test.ts +109 -0
- package/clients/dispatch/runners/pyright.ts +102 -0
- 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/rate.js +285 -0
- package/commands/rate.test.js +119 -0
- package/commands/rate.test.ts +131 -0
- package/commands/rate.ts +348 -0
- package/commands/refactor.js +33 -9
- package/commands/refactor.ts +44 -11
- package/default-architect.yaml +7 -0
- package/index.ts +58 -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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
|
|
16
|
+
interface SecretPattern {
|
|
17
|
+
pattern: RegExp;
|
|
18
|
+
name: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Patterns ordered by specificity - first match wins per line
|
|
23
|
+
const SECRET_PATTERNS: SecretPattern[] = [
|
|
24
|
+
// High-confidence: specific key prefixes
|
|
25
|
+
{
|
|
26
|
+
pattern: /sk-[a-zA-Z0-9-]{20,}/g,
|
|
27
|
+
name: "stripe-openai-key",
|
|
28
|
+
message: "Possible Stripe or OpenAI API key (sk-*)",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
pattern: /ghp_[a-zA-Z0-9]{36}/g,
|
|
32
|
+
name: "github-personal-token",
|
|
33
|
+
message: "GitHub personal access token (ghp_*)",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /gho_[a-zA-Z0-9]{36}/g,
|
|
37
|
+
name: "github-oauth-token",
|
|
38
|
+
message: "GitHub OAuth token (gho_*)",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
pattern: /github_pat_[a-zA-Z_]{82}/g,
|
|
42
|
+
name: "github-fine-grained-pat",
|
|
43
|
+
message: "GitHub fine-grained PAT (github_pat_*)",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
pattern: /AKIA[0-9A-Z]{16}/g,
|
|
47
|
+
name: "aws-access-key",
|
|
48
|
+
message: "AWS access key ID (AKIA*)",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
pattern: /xox[bp]-[a-zA-Z0-9]{10,}/g,
|
|
52
|
+
name: "slack-token",
|
|
53
|
+
message: "Slack token (xoxb-*/xoxp-*)",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE KEY-----/g,
|
|
57
|
+
name: "private-key",
|
|
58
|
+
message: "Private key material detected",
|
|
59
|
+
},
|
|
60
|
+
// Medium-confidence: quoted credentials
|
|
61
|
+
{
|
|
62
|
+
pattern: /password\s*[:=]\s*["'][^"']{4,}["']/gi,
|
|
63
|
+
name: "hardcoded-password",
|
|
64
|
+
message: "Possible hardcoded password",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
pattern:
|
|
68
|
+
/(?:secret|api_?key|token|access_?key)\s*[:=]\s*["'][a-zA-Z0-9_\-/.]{8,}["']/gi,
|
|
69
|
+
name: "hardcoded-secret",
|
|
70
|
+
message: "Possible hardcoded secret or API key",
|
|
71
|
+
},
|
|
72
|
+
// .env format: KEY=VALUE (no quotes)
|
|
73
|
+
{
|
|
74
|
+
pattern:
|
|
75
|
+
/^(?:API_?KEY|SECRET|TOKEN|PASSWORD|AWS_?ACCESS_?KEY)\s*=\s*\S{8,}/gim,
|
|
76
|
+
name: "env-file-secret",
|
|
77
|
+
message: "Possible secret in .env format",
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export interface SecretFinding {
|
|
82
|
+
line: number;
|
|
83
|
+
message: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scan content for potential secrets
|
|
88
|
+
* Returns findings with line numbers
|
|
89
|
+
*/
|
|
90
|
+
export function scanForSecrets(content: string): SecretFinding[] {
|
|
91
|
+
const findings: SecretFinding[] = [];
|
|
92
|
+
const lines = content.split("\n");
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
let matched = false;
|
|
97
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
98
|
+
// Reset lastIndex before each test (important for global regex)
|
|
99
|
+
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
|
|
100
|
+
if (regex.test(line)) {
|
|
101
|
+
findings.push({
|
|
102
|
+
line: i + 1,
|
|
103
|
+
message: pattern.message,
|
|
104
|
+
});
|
|
105
|
+
matched = true;
|
|
106
|
+
break; // One finding per line
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return findings;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Format secrets findings for terminal output
|
|
116
|
+
*/
|
|
117
|
+
export function formatSecrets(
|
|
118
|
+
findings: SecretFinding[],
|
|
119
|
+
filePath: string,
|
|
120
|
+
): string {
|
|
121
|
+
if (findings.length === 0) return "";
|
|
122
|
+
|
|
123
|
+
const lines = [
|
|
124
|
+
`🔴 STOP — ${findings.length} potential secret(s) in ${filePath}:`,
|
|
125
|
+
];
|
|
126
|
+
for (const f of findings.slice(0, 5)) {
|
|
127
|
+
lines.push(` L${f.line}: ${f.message}`);
|
|
128
|
+
}
|
|
129
|
+
if (findings.length > 5) {
|
|
130
|
+
lines.push(` ... and ${findings.length - 5} more`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(" → Remove before continuing. Use env vars instead.");
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
package/clients/sg-runner.js
CHANGED
|
@@ -126,9 +126,15 @@ export class SgRunner {
|
|
|
126
126
|
/**
|
|
127
127
|
* Format matches for display
|
|
128
128
|
*/
|
|
129
|
-
formatMatches(matches, isDryRun = false, maxItems = 50) {
|
|
130
|
-
if (matches.length === 0)
|
|
129
|
+
formatMatches(matches, isDryRun = false, maxItems = 50, showModeIndicator = false) {
|
|
130
|
+
if (matches.length === 0) {
|
|
131
|
+
if (showModeIndicator) {
|
|
132
|
+
return isDryRun
|
|
133
|
+
? "[DRY-RUN] No matches found."
|
|
134
|
+
: "[APPLIED] No changes made (no matches found).";
|
|
135
|
+
}
|
|
131
136
|
return "No matches found";
|
|
137
|
+
}
|
|
132
138
|
const shown = matches.slice(0, maxItems);
|
|
133
139
|
const lines = shown.map((m) => {
|
|
134
140
|
const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
|
|
@@ -140,6 +146,13 @@ export class SgRunner {
|
|
|
140
146
|
if (matches.length > maxItems) {
|
|
141
147
|
lines.unshift(`Found ${matches.length} matches (showing first ${maxItems}):`);
|
|
142
148
|
}
|
|
149
|
+
if (showModeIndicator) {
|
|
150
|
+
const prefix = isDryRun ? "[DRY-RUN]" : "[APPLIED]";
|
|
151
|
+
const suffix = isDryRun
|
|
152
|
+
? "\n\n(Dry run — use apply=true to apply changes)"
|
|
153
|
+
: "";
|
|
154
|
+
return `${prefix} ${matches.length} replacement(s):\n\n${lines.join("\n")}${suffix}`;
|
|
155
|
+
}
|
|
143
156
|
return lines.join("\n");
|
|
144
157
|
}
|
|
145
158
|
}
|
package/clients/sg-runner.ts
CHANGED
|
@@ -162,8 +162,21 @@ export class SgRunner {
|
|
|
162
162
|
/**
|
|
163
163
|
* Format matches for display
|
|
164
164
|
*/
|
|
165
|
-
formatMatches(
|
|
166
|
-
|
|
165
|
+
formatMatches(
|
|
166
|
+
matches: SgMatch[],
|
|
167
|
+
isDryRun = false,
|
|
168
|
+
maxItems = 50,
|
|
169
|
+
showModeIndicator = false,
|
|
170
|
+
): string {
|
|
171
|
+
if (matches.length === 0) {
|
|
172
|
+
if (showModeIndicator) {
|
|
173
|
+
return isDryRun
|
|
174
|
+
? "[DRY-RUN] No matches found."
|
|
175
|
+
: "[APPLIED] No changes made (no matches found).";
|
|
176
|
+
}
|
|
177
|
+
return "No matches found";
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
const shown = matches.slice(0, maxItems);
|
|
168
181
|
const lines = shown.map((m) => {
|
|
169
182
|
const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
|
|
@@ -172,11 +185,21 @@ export class SgRunner {
|
|
|
172
185
|
? `${loc}\n - ${text}\n + ${m.replacement}`
|
|
173
186
|
: `${loc}: ${text}`;
|
|
174
187
|
});
|
|
188
|
+
|
|
175
189
|
if (matches.length > maxItems) {
|
|
176
190
|
lines.unshift(
|
|
177
191
|
`Found ${matches.length} matches (showing first ${maxItems}):`,
|
|
178
192
|
);
|
|
179
193
|
}
|
|
194
|
+
|
|
195
|
+
if (showModeIndicator) {
|
|
196
|
+
const prefix = isDryRun ? "[DRY-RUN]" : "[APPLIED]";
|
|
197
|
+
const suffix = isDryRun
|
|
198
|
+
? "\n\n(Dry run — use apply=true to apply changes)"
|
|
199
|
+
: "";
|
|
200
|
+
return `${prefix} ${matches.length} replacement(s):\n\n${lines.join("\n")}${suffix}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
return lines.join("\n");
|
|
181
204
|
}
|
|
182
205
|
}
|