fixyoursecret 0.3.1-developer-preview.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 +45 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/index.js +116 -0
- package/commands/fix.js +88 -0
- package/commands/hook.js +37 -0
- package/commands/init.js +30 -0
- package/commands/rotate.js +146 -0
- package/commands/scan.js +184 -0
- package/detectors/anthropic.js +16 -0
- package/detectors/aws.js +16 -0
- package/detectors/cohere.js +16 -0
- package/detectors/generic.js +55 -0
- package/detectors/github.js +16 -0
- package/detectors/gitlab.js +16 -0
- package/detectors/google.js +16 -0
- package/detectors/huggingface.js +16 -0
- package/detectors/mailgun.js +16 -0
- package/detectors/npmToken.js +16 -0
- package/detectors/openai.js +16 -0
- package/detectors/privateKey.js +16 -0
- package/detectors/registry.js +37 -0
- package/detectors/sendgrid.js +16 -0
- package/detectors/slack.js +16 -0
- package/detectors/stripe.js +16 -0
- package/detectors/telegram.js +16 -0
- package/detectors/twilio.js +16 -0
- package/fixtures/benchmark/negative.json +12 -0
- package/fixtures/benchmark/positive.json +18 -0
- package/package.json +67 -0
- package/scripts/benchmark.js +76 -0
- package/templates/expressProxy.js +40 -0
- package/utils/config.js +109 -0
- package/utils/detectorRunner.js +13 -0
- package/utils/fileScanner.js +74 -0
- package/utils/gitScanner.js +26 -0
- package/utils/logger.js +32 -0
- package/utils/riskAnalyzer.js +42 -0
- package/utils/sarif.js +55 -0
- package/utils/verifier.js +73 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const TWILIO_KEY_REGEX = /\bSK[0-9a-fA-F]{32}\b/g;
|
|
2
|
+
|
|
3
|
+
export function detectTwilio(content) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
for (const match of content.matchAll(TWILIO_KEY_REGEX)) {
|
|
6
|
+
findings.push({
|
|
7
|
+
rule: "twilio-api-key",
|
|
8
|
+
issue: "Twilio API key exposed",
|
|
9
|
+
index: match.index ?? 0,
|
|
10
|
+
value: match[0],
|
|
11
|
+
type: "twilio",
|
|
12
|
+
confidence: "high",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return findings;
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[
|
|
2
|
+
"build_configuration_identifier_2026_release",
|
|
3
|
+
"component_configuration_identifier_for_docs",
|
|
4
|
+
"http://localhost:3000/api",
|
|
5
|
+
"NEXT_PUBLIC_ANALYTICS_ID=public_demo_id",
|
|
6
|
+
"pk_test_1234567890abcdefghijklmnopqrstuvwxyz",
|
|
7
|
+
"pk_live_1234567890abcdefghijklmnopqrstuvwxyz",
|
|
8
|
+
"const uuid = '550e8400-e29b-41d4-a716-446655440000'",
|
|
9
|
+
"const hash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4'",
|
|
10
|
+
"github_pat_example_not_real",
|
|
11
|
+
"xoxb-example-not-real-token"
|
|
12
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "rule": "openai-key", "parts": ["sk-proj-", "abcdefghijklmnopqrstuvwxyz123456"], "note": "OpenAI" },
|
|
3
|
+
{ "rule": "google-key", "parts": ["AIza", "abcdefghijklmnopqrstuvwxyzABCDE12345"], "note": "Google" },
|
|
4
|
+
{ "rule": "aws-access-key-id", "parts": ["AKIA", "ABCDEFGHIJKLMNOP"], "note": "AWS" },
|
|
5
|
+
{ "rule": "stripe-secret-key", "parts": ["sk_live_", "1234567890abcdefghijklmnop"], "note": "Stripe" },
|
|
6
|
+
{ "rule": "slack-token", "parts": ["xox", "b-1234567890-abcdefghijklmnop"], "note": "Slack" },
|
|
7
|
+
{ "rule": "github-token", "parts": ["ghp_", "abcdefghijklmnopqrstuvwxyzABCD12"], "note": "GitHub" },
|
|
8
|
+
{ "rule": "gitlab-token", "parts": ["glpat-", "abcdefghijklmnopqrstuvwxyz1234"], "note": "GitLab" },
|
|
9
|
+
{ "rule": "twilio-api-key", "parts": ["SK", "0123456789abcdef0123456789abcdef"], "note": "Twilio" },
|
|
10
|
+
{ "rule": "sendgrid-api-key", "parts": ["SG.", "ABCDEFGHIJKLMNOPQRSTUV", ".", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq"], "note": "SendGrid" },
|
|
11
|
+
{ "rule": "mailgun-api-key", "parts": ["key-", "1234567890abcdef1234567890abcdef"], "note": "Mailgun" },
|
|
12
|
+
{ "rule": "anthropic-api-key", "parts": ["sk-ant-", "abcdefghijklmnopqrstuvwxyz123456"], "note": "Anthropic" },
|
|
13
|
+
{ "rule": "cohere-api-key", "parts": ["co_", "abcdefghijklmnopqrstuvwxyz123456"], "note": "Cohere" },
|
|
14
|
+
{ "rule": "huggingface-token", "parts": ["hf_", "abcdefghijklmnopqrstuvwxyz123456"], "note": "HuggingFace" },
|
|
15
|
+
{ "rule": "telegram-bot-token", "parts": ["123456789", ":", "abcdefghijklmnopqrstuvwxyzABCDE1234"], "note": "Telegram" },
|
|
16
|
+
{ "rule": "npm-token", "parts": ["npm_", "abcdefghijklmnopqrstuvwxyz1234567890"], "note": "npm" },
|
|
17
|
+
{ "rule": "private-key-block", "parts": ["-----BEGIN PRIVATE KEY-----", "\\n", "MIIB", "\\n", "-----END PRIVATE KEY-----"], "note": "Private key" }
|
|
18
|
+
]
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fixyoursecret",
|
|
3
|
+
"version": "0.3.1-developer-preview.1",
|
|
4
|
+
"description": "Developer Preview: CLI tool to detect leaked secrets, frontend exposure, and generate safe fixes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fixyoursecret": "bin/index.js",
|
|
8
|
+
"secretlint": "bin/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"commands/",
|
|
13
|
+
"detectors/",
|
|
14
|
+
"templates/",
|
|
15
|
+
"utils/",
|
|
16
|
+
"fixtures/benchmark/",
|
|
17
|
+
"scripts/benchmark.js",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./bin/index.js"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"start": "node ./bin/index.js",
|
|
27
|
+
"scan": "node ./bin/index.js scan",
|
|
28
|
+
"fix": "node ./bin/index.js fix",
|
|
29
|
+
"rotate": "node ./bin/index.js rotate openai",
|
|
30
|
+
"history": "node ./bin/index.js history 20",
|
|
31
|
+
"ci": "node ./bin/index.js ci",
|
|
32
|
+
"benchmark": "node ./scripts/benchmark.js",
|
|
33
|
+
"quality": "npm test && npm run benchmark",
|
|
34
|
+
"test": "node --test",
|
|
35
|
+
"tune:multi": "node ./scripts/multi-repo-tune.js --repos-file ./fixtures/tuning/repos.default.json --output ./docs/tuning/multi-repo-report.json --fp-review ./docs/tuning/false-positive-review.md",
|
|
36
|
+
"tune:weekly": "npm run benchmark && npm run tune:multi"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"security",
|
|
40
|
+
"cli",
|
|
41
|
+
"secrets",
|
|
42
|
+
"lint",
|
|
43
|
+
"devsecops",
|
|
44
|
+
"sast"
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/ssanidhya0407/fixyoursecret.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/ssanidhya0407/fixyoursecret#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/ssanidhya0407/fixyoursecret/issues"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"tag": "preview"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"chalk": "^5.4.1",
|
|
61
|
+
"commander": "^14.0.1",
|
|
62
|
+
"glob": "^11.0.3"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runDetectors } from "../utils/detectorRunner.js";
|
|
5
|
+
import { DEFAULT_CONFIG } from "../utils/config.js";
|
|
6
|
+
import { shouldSkipAsNonSecret } from "../utils/verifier.js";
|
|
7
|
+
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const posPath = path.join(cwd, "fixtures/benchmark/positive.json");
|
|
10
|
+
const negPath = path.join(cwd, "fixtures/benchmark/negative.json");
|
|
11
|
+
|
|
12
|
+
const minRecall = Number(process.env.BENCH_MIN_RECALL || "0.95");
|
|
13
|
+
const minPrecision = Number(process.env.BENCH_MIN_PRECISION || "0.95");
|
|
14
|
+
|
|
15
|
+
const positives = JSON.parse(await fs.readFile(posPath, "utf8"));
|
|
16
|
+
const negatives = JSON.parse(await fs.readFile(negPath, "utf8"));
|
|
17
|
+
|
|
18
|
+
const cfg = {
|
|
19
|
+
...DEFAULT_CONFIG,
|
|
20
|
+
ignoreDetectors: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let tp = 0;
|
|
24
|
+
let fn = 0;
|
|
25
|
+
let fp = 0;
|
|
26
|
+
let tn = 0;
|
|
27
|
+
|
|
28
|
+
const missingByRule = new Map();
|
|
29
|
+
|
|
30
|
+
for (const sample of positives) {
|
|
31
|
+
const value = Array.isArray(sample.parts) ? sample.parts.join("") : String(sample.value || "");
|
|
32
|
+
const content = `const leaked = "${value}";`;
|
|
33
|
+
const matches = runDetectors(content, cfg).filter((m) => !shouldSkipAsNonSecret(m, content, "src/app.js", cfg.ignoreValueHints));
|
|
34
|
+
const matchedExpected = matches.some((m) => m.rule === sample.rule);
|
|
35
|
+
if (matchedExpected) tp += 1;
|
|
36
|
+
else {
|
|
37
|
+
fn += 1;
|
|
38
|
+
missingByRule.set(sample.rule, (missingByRule.get(sample.rule) || 0) + 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const sample of negatives) {
|
|
43
|
+
const content = `const value = "${sample}";`;
|
|
44
|
+
const matches = runDetectors(content, cfg).filter((m) => !shouldSkipAsNonSecret(m, content, "src/app.js", cfg.ignoreValueHints));
|
|
45
|
+
if (matches.length > 0) fp += 1;
|
|
46
|
+
else tn += 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const recall = tp / Math.max(1, tp + fn);
|
|
50
|
+
const precision = tp / Math.max(1, tp + fp);
|
|
51
|
+
const negativePassRate = tn / Math.max(1, tn + fp);
|
|
52
|
+
|
|
53
|
+
console.log("FixYourSecret Benchmark");
|
|
54
|
+
console.log(`- positives: ${positives.length}`);
|
|
55
|
+
console.log(`- negatives: ${negatives.length}`);
|
|
56
|
+
console.log(`- true positives: ${tp}`);
|
|
57
|
+
console.log(`- false negatives: ${fn}`);
|
|
58
|
+
console.log(`- false positives: ${fp}`);
|
|
59
|
+
console.log(`- true negatives: ${tn}`);
|
|
60
|
+
console.log(`- recall: ${recall.toFixed(3)}`);
|
|
61
|
+
console.log(`- precision: ${precision.toFixed(3)}`);
|
|
62
|
+
console.log(`- negative-pass-rate: ${negativePassRate.toFixed(3)}`);
|
|
63
|
+
|
|
64
|
+
if (missingByRule.size > 0) {
|
|
65
|
+
console.log("- missing rules:");
|
|
66
|
+
for (const [rule, count] of missingByRule.entries()) {
|
|
67
|
+
console.log(` - ${rule}: ${count}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (recall < minRecall || precision < minPrecision) {
|
|
72
|
+
console.error(`Benchmark gate failed. Required recall>=${minRecall} precision>=${minPrecision}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log("Benchmark gate passed.");
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function expressProxyTemplate() {
|
|
2
|
+
return `const express = require("express");
|
|
3
|
+
|
|
4
|
+
const app = express();
|
|
5
|
+
app.use(express.json());
|
|
6
|
+
|
|
7
|
+
app.post("/api/ai", async (req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
10
|
+
if (!apiKey) {
|
|
11
|
+
return res.status(500).json({ error: "Missing OPENAI_API_KEY" });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const payload = {
|
|
15
|
+
model: req.body?.model || "gpt-4.1-mini",
|
|
16
|
+
messages: req.body?.messages || []
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Authorization": "Bearer " + apiKey
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(payload)
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const data = await upstream.json();
|
|
29
|
+
return res.status(upstream.status).json(data);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return res.status(500).json({ error: "Proxy request failed", details: error.message });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const port = process.env.PORT || 3001;
|
|
36
|
+
app.listen(port, () => {
|
|
37
|
+
console.log("Secretlint proxy running on http://localhost:" + port);
|
|
38
|
+
});
|
|
39
|
+
`;
|
|
40
|
+
}
|
package/utils/config.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const CONFIG_FILENAMES = [".fixyoursecretrc.json", ".secretlintrc.json"];
|
|
5
|
+
export const BASELINE_FILENAMES = [".fixyoursecret-baseline.json", ".secretlint-baseline.json"];
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = {
|
|
8
|
+
ignorePaths: ["node_modules/**", ".git/**", "dist/**", "build/**", ".next/**", "coverage/**"],
|
|
9
|
+
allowedExtensions: [".js", ".ts", ".jsx", ".tsx", ".env", ".swift"],
|
|
10
|
+
maxFileSizeKB: 256,
|
|
11
|
+
entropyThreshold: 3.8,
|
|
12
|
+
failOn: "high",
|
|
13
|
+
verifyMode: "none",
|
|
14
|
+
suppressions: [
|
|
15
|
+
{ path: "test/" },
|
|
16
|
+
{ path: "tests/" },
|
|
17
|
+
{ path: "__tests__/" },
|
|
18
|
+
{ path: "fixtures/" }
|
|
19
|
+
],
|
|
20
|
+
ignoreDetectors: [],
|
|
21
|
+
ignoreValueHints: ["example", "dummy", "fake", "sample", "replace_in_runtime_only"],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function loadConfig(projectPath, configPath) {
|
|
25
|
+
const candidates = configPath
|
|
26
|
+
? [path.resolve(configPath)]
|
|
27
|
+
: CONFIG_FILENAMES.map((name) => path.join(projectPath, name));
|
|
28
|
+
|
|
29
|
+
for (const candidate of candidates) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await fs.readFile(candidate, "utf8");
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
const config = {
|
|
34
|
+
...DEFAULT_CONFIG,
|
|
35
|
+
...parsed,
|
|
36
|
+
ignorePaths: normalizeStringArray(parsed.ignorePaths, DEFAULT_CONFIG.ignorePaths),
|
|
37
|
+
allowedExtensions: normalizeStringArray(parsed.allowedExtensions, DEFAULT_CONFIG.allowedExtensions),
|
|
38
|
+
suppressions: normalizeSuppressions(parsed.suppressions),
|
|
39
|
+
ignoreDetectors: normalizeStringArray(parsed.ignoreDetectors, DEFAULT_CONFIG.ignoreDetectors),
|
|
40
|
+
ignoreValueHints: normalizeStringArray(parsed.ignoreValueHints, DEFAULT_CONFIG.ignoreValueHints),
|
|
41
|
+
maxFileSizeKB: toPositiveInt(parsed.maxFileSizeKB, DEFAULT_CONFIG.maxFileSizeKB),
|
|
42
|
+
entropyThreshold: toPositiveNumber(parsed.entropyThreshold, DEFAULT_CONFIG.entropyThreshold),
|
|
43
|
+
failOn: normalizeFailOn(parsed.failOn, DEFAULT_CONFIG.failOn),
|
|
44
|
+
verifyMode: normalizeVerifyMode(parsed.verifyMode, DEFAULT_CONFIG.verifyMode),
|
|
45
|
+
};
|
|
46
|
+
return { config, path: candidate, loaded: true };
|
|
47
|
+
} catch {
|
|
48
|
+
// try next config candidate
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { config: { ...DEFAULT_CONFIG }, path: candidates[0], loaded: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveBaselinePath(projectPath, explicitBaselinePath) {
|
|
56
|
+
if (explicitBaselinePath) return path.resolve(projectPath, explicitBaselinePath);
|
|
57
|
+
return path.resolve(projectPath, BASELINE_FILENAMES[0]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isSuppressed(finding, suppressions = [], fileLines = []) {
|
|
61
|
+
if (hasInlineDisable(fileLines, finding.line)) return true;
|
|
62
|
+
return suppressions.some((rule) => {
|
|
63
|
+
if (!rule || typeof rule !== "object") return false;
|
|
64
|
+
if (rule.rule && rule.rule !== finding.rule) return false;
|
|
65
|
+
if (rule.path && !finding.file.includes(String(rule.path))) return false;
|
|
66
|
+
if (rule.line && Number(rule.line) !== finding.line) return false;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasInlineDisable(lines, currentLine) {
|
|
72
|
+
const previous = lines[currentLine - 2] || "";
|
|
73
|
+
const current = lines[currentLine - 1] || "";
|
|
74
|
+
return (
|
|
75
|
+
/secretlint-disable-next-line|fixyoursecret-disable-next-line/.test(previous) ||
|
|
76
|
+
/secretlint-disable-line|fixyoursecret-disable-line/.test(current)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeSuppressions(value) {
|
|
81
|
+
if (!Array.isArray(value)) return DEFAULT_CONFIG.suppressions;
|
|
82
|
+
return value.filter((item) => item && typeof item === "object");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeStringArray(value, fallback) {
|
|
86
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : fallback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toPositiveInt(value, fallback) {
|
|
90
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toPositiveNumber(value, fallback) {
|
|
94
|
+
return typeof value === "number" && value > 0 ? value : fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeFailOn(value, fallback) {
|
|
98
|
+
const safe = String(value || "").toLowerCase();
|
|
99
|
+
return ["low", "medium", "high"].includes(safe) ? safe : fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeVerifyMode(value, fallback) {
|
|
103
|
+
const safe = String(value || "").toLowerCase();
|
|
104
|
+
return ["none", "safe"].includes(safe) ? safe : fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function defaultConfigTemplate() {
|
|
108
|
+
return JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DETECTOR_REGISTRY } from "../detectors/registry.js";
|
|
2
|
+
|
|
3
|
+
export function runDetectors(content, config) {
|
|
4
|
+
const all = [];
|
|
5
|
+
for (const detector of DETECTOR_REGISTRY) {
|
|
6
|
+
if (config.ignoreDetectors.includes(detector.key)) continue;
|
|
7
|
+
const matches = detector.key === "generic"
|
|
8
|
+
? detector.run(content, { entropyThreshold: config.entropyThreshold })
|
|
9
|
+
: detector.run(content);
|
|
10
|
+
all.push(...matches);
|
|
11
|
+
}
|
|
12
|
+
return all;
|
|
13
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_EXTENSIONS = [".js", ".ts", ".jsx", ".tsx", ".env", ".swift"];
|
|
6
|
+
|
|
7
|
+
export async function collectProjectFiles(projectPath, options = {}) {
|
|
8
|
+
const extensions = Array.isArray(options.allowedExtensions) && options.allowedExtensions.length > 0
|
|
9
|
+
? options.allowedExtensions
|
|
10
|
+
: DEFAULT_EXTENSIONS;
|
|
11
|
+
const allowedExtensions = new Set(extensions.map((e) => String(e).toLowerCase()));
|
|
12
|
+
const ignore = options.ignorePaths || ["node_modules/**", ".git/**", "dist/**", "build/**"];
|
|
13
|
+
const includeOnly = normalizeSet(options.includeOnly);
|
|
14
|
+
const maxFileSizeBytes = (options.maxFileSizeKB || 256) * 1024;
|
|
15
|
+
|
|
16
|
+
const entries = await glob("**/*", {
|
|
17
|
+
cwd: projectPath,
|
|
18
|
+
nodir: true,
|
|
19
|
+
dot: true,
|
|
20
|
+
ignore,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const files = [];
|
|
24
|
+
for (const relativePath of entries) {
|
|
25
|
+
const normalizedRelative = normalizePath(relativePath);
|
|
26
|
+
if (includeOnly && !includeOnly.has(normalizedRelative)) continue;
|
|
27
|
+
|
|
28
|
+
const ext = path.extname(normalizedRelative).toLowerCase();
|
|
29
|
+
if (!allowedExtensions.has(ext)) continue;
|
|
30
|
+
|
|
31
|
+
const absolutePath = path.join(projectPath, normalizedRelative);
|
|
32
|
+
let stat;
|
|
33
|
+
try {
|
|
34
|
+
stat = await fs.stat(absolutePath);
|
|
35
|
+
} catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!stat.isFile() || stat.size > maxFileSizeBytes) continue;
|
|
39
|
+
|
|
40
|
+
const content = await fs.readFile(absolutePath, "utf8").catch(() => "");
|
|
41
|
+
if (!content) continue;
|
|
42
|
+
|
|
43
|
+
files.push({
|
|
44
|
+
absolutePath,
|
|
45
|
+
relativePath: normalizedRelative,
|
|
46
|
+
content,
|
|
47
|
+
lines: content.split("\n"),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function lineColFromIndex(content, index) {
|
|
55
|
+
const start = Math.max(0, index);
|
|
56
|
+
const prefix = content.slice(0, start);
|
|
57
|
+
const lines = prefix.split("\n");
|
|
58
|
+
const line = lines.length;
|
|
59
|
+
const column = lines[lines.length - 1].length + 1;
|
|
60
|
+
return { line, column };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizePath(p) {
|
|
64
|
+
return p.split(path.sep).join("/");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeSet(values) {
|
|
68
|
+
if (!Array.isArray(values) || values.length === 0) return null;
|
|
69
|
+
const normalized = values
|
|
70
|
+
.filter((v) => typeof v === "string")
|
|
71
|
+
.map((v) => v.replace(/^\.\//, ""))
|
|
72
|
+
.map((v) => v.split(path.sep).join("/"));
|
|
73
|
+
return new Set(normalized);
|
|
74
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function getStagedFiles(projectPath) {
|
|
7
|
+
return runGitFileList(projectPath, ["diff", "--cached", "--name-only", "--diff-filter=ACMRT"]);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function getTrackedFiles(projectPath) {
|
|
11
|
+
return runGitFileList(projectPath, ["ls-files"]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getRecentChangedFiles(projectPath, commitCount = 20) {
|
|
15
|
+
const safeCount = Number.isInteger(commitCount) && commitCount > 0 ? commitCount : 20;
|
|
16
|
+
return runGitFileList(projectPath, ["log", `-${safeCount}`, "--name-only", "--pretty=format:"]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function runGitFileList(projectPath, args) {
|
|
20
|
+
try {
|
|
21
|
+
const { stdout } = await execFileAsync("git", args, { cwd: projectPath });
|
|
22
|
+
return Array.from(new Set(stdout.split("\n").map((line) => line.trim()).filter(Boolean)));
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
package/utils/logger.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export const logger = {
|
|
4
|
+
log: (...args) => console.log(...args),
|
|
5
|
+
warn: (...args) => console.log(chalk.yellow(...args)),
|
|
6
|
+
error: (...args) => console.error(chalk.red(...args)),
|
|
7
|
+
safe: (...args) => console.log(chalk.green(...args)),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function printFinding(f) {
|
|
11
|
+
const label = f.severity === "HIGH" ? chalk.red("[HIGH]") : f.severity === "MEDIUM" ? chalk.yellow("[WARNING]") : chalk.blue("[LOW]");
|
|
12
|
+
|
|
13
|
+
console.log(label);
|
|
14
|
+
console.log(`File: ${f.file}:${f.line}:${f.column}`);
|
|
15
|
+
console.log(`Issue: ${f.issue}`);
|
|
16
|
+
if (f.rule) console.log(`Rule: ${f.rule}`);
|
|
17
|
+
console.log(`Risk: ${f.severity}`);
|
|
18
|
+
console.log(`Snippet: ${chalk.gray(f.snippet)}`);
|
|
19
|
+
if (f.reason) console.log(`Reason: ${f.reason}`);
|
|
20
|
+
console.log(`Fix: ${f.recommendation}`);
|
|
21
|
+
console.log("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function printSummary(findings) {
|
|
25
|
+
const high = findings.filter((f) => f.severity === "HIGH").length;
|
|
26
|
+
const medium = findings.filter((f) => f.severity === "MEDIUM").length;
|
|
27
|
+
const low = findings.filter((f) => f.severity === "LOW").length;
|
|
28
|
+
const total = findings.length;
|
|
29
|
+
const color = high > 0 ? chalk.red : medium > 0 ? chalk.yellow : chalk.green;
|
|
30
|
+
|
|
31
|
+
console.log(color(`${total} issues found (${high} high risk, ${medium} medium, ${low} low)`));
|
|
32
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const FRONTEND_HINTS = ["/src/", "/components/", "/pages/", "/public/", "/app/"];
|
|
2
|
+
const BACKEND_HINTS = ["/api/", "/server/", "/backend/", "/services/"];
|
|
3
|
+
const PUBLIC_ENV_HINTS = ["NEXT_PUBLIC_", "VITE_", "NUXT_PUBLIC_", "PUBLIC_"];
|
|
4
|
+
|
|
5
|
+
export function analyzeRisk(relativePath, match, snippet) {
|
|
6
|
+
const normalized = `/${relativePath.toLowerCase()}`;
|
|
7
|
+
const isBackendPath = BACKEND_HINTS.some((segment) => normalized.includes(segment));
|
|
8
|
+
const isEnvFile = normalized.endsWith(".env");
|
|
9
|
+
const inFrontendPath = FRONTEND_HINTS.some((segment) => normalized.includes(segment));
|
|
10
|
+
const hasPublicEnvLeak = PUBLIC_ENV_HINTS.some((prefix) => snippet.includes(prefix));
|
|
11
|
+
const inFrontend = !isBackendPath && (inFrontendPath || hasPublicEnvLeak) && !isEnvFile;
|
|
12
|
+
|
|
13
|
+
if (match.type === "private-key") {
|
|
14
|
+
return {
|
|
15
|
+
severity: "HIGH",
|
|
16
|
+
fix: "Delete private key from source immediately, revoke/replace credentials, and move keys to a secure secret manager.",
|
|
17
|
+
reason: "Private key material should never be stored in source code",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (inFrontend && match.type !== "generic") {
|
|
22
|
+
return {
|
|
23
|
+
severity: "HIGH",
|
|
24
|
+
fix: "Move secret to backend proxy and call internal endpoint instead.",
|
|
25
|
+
reason: "Sensitive key appears in likely frontend context",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (match.type === "generic") {
|
|
30
|
+
return {
|
|
31
|
+
severity: "MEDIUM",
|
|
32
|
+
fix: "Verify if token is sensitive; if yes, move it to environment variables.",
|
|
33
|
+
reason: "High entropy token pattern",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
severity: "HIGH",
|
|
39
|
+
fix: "Rotate the key and store it only in backend environment variables.",
|
|
40
|
+
reason: "Known provider key format",
|
|
41
|
+
};
|
|
42
|
+
}
|
package/utils/sarif.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function findingsToSarif(findings, toolName = "fixyoursecret") {
|
|
2
|
+
const rulesMap = new Map();
|
|
3
|
+
|
|
4
|
+
for (const finding of findings) {
|
|
5
|
+
const id = finding.rule || "unknown-rule";
|
|
6
|
+
if (!rulesMap.has(id)) {
|
|
7
|
+
rulesMap.set(id, {
|
|
8
|
+
id,
|
|
9
|
+
shortDescription: { text: finding.issue },
|
|
10
|
+
fullDescription: { text: finding.recommendation || finding.issue },
|
|
11
|
+
defaultConfiguration: {
|
|
12
|
+
level: sarifLevel(finding.severity),
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
20
|
+
version: "2.1.0",
|
|
21
|
+
runs: [
|
|
22
|
+
{
|
|
23
|
+
tool: {
|
|
24
|
+
driver: {
|
|
25
|
+
name: toolName,
|
|
26
|
+
version: "0.3.0-developer-preview.1",
|
|
27
|
+
rules: Array.from(rulesMap.values()),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
results: findings.map((finding) => ({
|
|
31
|
+
ruleId: finding.rule || "unknown-rule",
|
|
32
|
+
level: sarifLevel(finding.severity),
|
|
33
|
+
message: { text: `${finding.issue}. ${finding.recommendation || ""}`.trim() },
|
|
34
|
+
locations: [
|
|
35
|
+
{
|
|
36
|
+
physicalLocation: {
|
|
37
|
+
artifactLocation: { uri: finding.file },
|
|
38
|
+
region: {
|
|
39
|
+
startLine: finding.line,
|
|
40
|
+
startColumn: finding.column,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
})),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sarifLevel(severity) {
|
|
52
|
+
if (severity === "HIGH") return "error";
|
|
53
|
+
if (severity === "MEDIUM") return "warning";
|
|
54
|
+
return "note";
|
|
55
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function verifyFinding(match, fileContent = "", snippet = "") {
|
|
2
|
+
switch (match.rule) {
|
|
3
|
+
case "openai-key":
|
|
4
|
+
return formatResult(/^(?:sk-(?:proj-)?)?[A-Za-z0-9_-]{24,}$/.test(match.value) && /\d/.test(match.value), "format");
|
|
5
|
+
case "google-key":
|
|
6
|
+
return formatResult(/^AIza[0-9A-Za-z_-]{35}$/.test(match.value), "format");
|
|
7
|
+
case "aws-access-key-id":
|
|
8
|
+
return formatResult(/^(AKIA|ASIA)[A-Z0-9]{16}$/.test(match.value), "format");
|
|
9
|
+
case "stripe-secret-key":
|
|
10
|
+
return formatResult(/^sk_live_[0-9A-Za-z]{16,}$/.test(match.value), "format");
|
|
11
|
+
case "slack-token":
|
|
12
|
+
return formatResult(/^xox(?:b|p|a|r|s)-[0-9A-Za-z-]{10,}$/.test(match.value), "format");
|
|
13
|
+
case "github-token":
|
|
14
|
+
return formatResult(/^(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}$|^github_pat_[A-Za-z0-9_]{20,}$/.test(match.value), "format");
|
|
15
|
+
case "gitlab-token":
|
|
16
|
+
return formatResult(/^glpat-[A-Za-z0-9_-]{20,}$/.test(match.value), "format");
|
|
17
|
+
case "twilio-api-key":
|
|
18
|
+
return formatResult(/^SK[0-9a-fA-F]{32}$/.test(match.value), "format");
|
|
19
|
+
case "sendgrid-api-key":
|
|
20
|
+
return formatResult(/^SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$/.test(match.value), "format");
|
|
21
|
+
case "mailgun-api-key":
|
|
22
|
+
return formatResult(/^key-[A-Za-z0-9]{32}$/.test(match.value), "format");
|
|
23
|
+
case "anthropic-api-key":
|
|
24
|
+
return formatResult(/^sk-ant-[A-Za-z0-9_-]{20,}$/.test(match.value), "format");
|
|
25
|
+
case "cohere-api-key":
|
|
26
|
+
return formatResult(/^co_[A-Za-z0-9]{30,}$/.test(match.value), "format");
|
|
27
|
+
case "huggingface-token":
|
|
28
|
+
return formatResult(/^hf_[A-Za-z0-9]{30,}$/.test(match.value), "format");
|
|
29
|
+
case "telegram-bot-token":
|
|
30
|
+
return formatResult(/^\d{8,10}:[A-Za-z0-9_-]{35}$/.test(match.value), "format");
|
|
31
|
+
case "npm-token":
|
|
32
|
+
return formatResult(/^npm_[A-Za-z0-9]{36}$/.test(match.value), "format");
|
|
33
|
+
case "private-key-block": {
|
|
34
|
+
const hasEnd = /-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(fileContent.slice(match.index));
|
|
35
|
+
return formatResult(hasEnd, "pem-structure");
|
|
36
|
+
}
|
|
37
|
+
case "generic-high-entropy":
|
|
38
|
+
return formatResult(false, "unsupported");
|
|
39
|
+
default:
|
|
40
|
+
return formatResult(false, "unknown");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatResult(verified, method) {
|
|
45
|
+
return {
|
|
46
|
+
verified,
|
|
47
|
+
verificationMethod: method,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeVerifyMode(mode) {
|
|
52
|
+
const safe = String(mode || "none").toLowerCase();
|
|
53
|
+
return ["none", "safe"].includes(safe) ? safe : "none";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function shouldSkipAsNonSecret(match, snippet = "", filePath = "", hints = []) {
|
|
57
|
+
const lowerSnippet = snippet.toLowerCase();
|
|
58
|
+
const lowerPath = filePath.toLowerCase();
|
|
59
|
+
|
|
60
|
+
const builtinHints = ["example", "dummy", "fake", "sample", "not_secret", "replace_in_runtime_only", "docs_only"];
|
|
61
|
+
const allHints = [...builtinHints, ...hints.map((h) => String(h).toLowerCase())];
|
|
62
|
+
|
|
63
|
+
if (allHints.some((hint) => lowerSnippet.includes(hint))) return true;
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
match.rule === "generic-high-entropy" &&
|
|
67
|
+
["/test/", "/tests/", "/__tests__/", "/fixtures/", "/docs/"].some((segment) => lowerPath.includes(segment))
|
|
68
|
+
) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return false;
|
|
73
|
+
}
|