penthera 1.1.1 → 1.4.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 +269 -3
- package/README.md +489 -286
- package/SECURITY.md +28 -6
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-nis2.md +166 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +15 -3
- package/docs/local-loop.md +276 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +19 -9
- package/skills/penthera/SKILL.md +104 -23
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +152 -6
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
- package/scripts/package-skill.sh +0 -16
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe: security response headers (CSP starter, HSTS, X-Frame-Options and
|
|
3
|
+
* frame-ancestors, Referrer-Policy, X-Content-Type-Options) for Next.js,
|
|
4
|
+
* Express, Fastify and Hono. Guidance: remediation rules "header-csp" and
|
|
5
|
+
* "header-generic", and references/remediation.md#security-headers.
|
|
6
|
+
*/
|
|
7
|
+
import { auditSecurityHeaders } from "../../blackbox/headers.js";
|
|
8
|
+
import {
|
|
9
|
+
detectFramework, hasDep, readText, walkCodeFiles, findEntryFile, findLine,
|
|
10
|
+
addImport, insertAfterLine, toDiff,
|
|
11
|
+
} from "../util.js";
|
|
12
|
+
|
|
13
|
+
// A starter CSP: it allows inline scripts and styles because most framework
|
|
14
|
+
// apps (Next.js hydration in particular) break without them. The owner action
|
|
15
|
+
// says to tighten it.
|
|
16
|
+
const CSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'";
|
|
17
|
+
|
|
18
|
+
export const SECURITY_HEADERS = [
|
|
19
|
+
["Content-Security-Policy", CSP],
|
|
20
|
+
["Strict-Transport-Security", "max-age=63072000; includeSubDomains"],
|
|
21
|
+
["X-Frame-Options", "DENY"],
|
|
22
|
+
["Referrer-Policy", "strict-origin-when-cross-origin"],
|
|
23
|
+
["X-Content-Type-Options", "nosniff"],
|
|
24
|
+
["Permissions-Policy", "camera=(), microphone=(), geolocation=()"],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// The headers this recipe is responsible for (Permissions-Policy is a bonus in
|
|
28
|
+
// the literal variants, but helmet and secureHeaders don't set it by default).
|
|
29
|
+
const COVERED = ["content-security-policy", "strict-transport-security", "x-frame-options", "referrer-policy", "x-content-type-options"];
|
|
30
|
+
const HELMET_DEFAULTS = COVERED;
|
|
31
|
+
const HONO_DEFAULTS = ["strict-transport-security", "x-frame-options", "referrer-policy", "x-content-type-options"];
|
|
32
|
+
|
|
33
|
+
const NEXT_CONFIGS = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
|
|
34
|
+
|
|
35
|
+
const OWNER_ACTIONS = [
|
|
36
|
+
"Tighten the starter Content-Security-Policy for your app: it allows inline scripts and styles. Test in staging (or ship it as Content-Security-Policy-Report-Only first) and move to nonces or hashes.",
|
|
37
|
+
"Only keep Strict-Transport-Security if the site is served over HTTPS everywhere; add `preload` once you are sure.",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ANCHORS = {
|
|
41
|
+
express: /^(\s*)(?:const|let|var)\s+(\w+)\s*=\s*express\(\s*\)\s*;?\s*$/,
|
|
42
|
+
hono: /^(\s*)(?:const|let|var)\s+(\w+)\s*=\s*new\s+Hono(?:<[^>]*>)?\(\s*\)\s*;?\s*$/,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function fastifyAnchor(source) {
|
|
46
|
+
const factory = source.match(/import\s+(\w+)\s+from\s+['"]fastify['"]/)?.[1]
|
|
47
|
+
|| source.match(/(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*['"]fastify['"]\s*\)\s*;?/)?.[1];
|
|
48
|
+
if (factory) return new RegExp(`^(\\s*)(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:await\\s+)?${factory}\\([^()]*\\)\\s*;?\\s*$`);
|
|
49
|
+
return /^(\s*)(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*['"]fastify['"]\s*\)\([^()]*\)\s*;?\s*$/;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function anchorFor(framework, source) {
|
|
53
|
+
return framework === "fastify" ? fastifyAnchor(source) : ANCHORS[framework];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nextConfig(repoPath) {
|
|
57
|
+
for (const file of NEXT_CONFIGS) {
|
|
58
|
+
const source = readText(repoPath, file);
|
|
59
|
+
if (source != null) return { file, source };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lower-case names of the security headers the repo's code visibly sets. */
|
|
65
|
+
export function staticHeaders(repoPath, framework) {
|
|
66
|
+
const found = new Set();
|
|
67
|
+
const literal = /(?:key\s*:\s*|\.(?:setHeader|header|set|append)\(\s*)['"]([A-Za-z-]+)['"]/g;
|
|
68
|
+
const sources = [];
|
|
69
|
+
if (framework === "next") {
|
|
70
|
+
const cfg = nextConfig(repoPath);
|
|
71
|
+
if (cfg) sources.push(cfg.source);
|
|
72
|
+
for (const f of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
|
|
73
|
+
const s = readText(repoPath, f);
|
|
74
|
+
if (s) sources.push(s);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
for (const f of walkCodeFiles(repoPath)) {
|
|
78
|
+
const s = readText(repoPath, f);
|
|
79
|
+
if (s) sources.push(s);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const s of sources) {
|
|
83
|
+
for (const m of s.matchAll(literal)) found.add(m[1].toLowerCase());
|
|
84
|
+
const helmet = /['"]helmet['"]/.test(s) && /\bhelmet\s*\(/.test(s);
|
|
85
|
+
const fastifyHelmet = /['"]@fastify\/helmet['"]/.test(s) && /\.register\(/.test(s);
|
|
86
|
+
if (helmet || fastifyHelmet) HELMET_DEFAULTS.forEach((h) => found.add(h));
|
|
87
|
+
if (/\bsecureHeaders\s*\(/.test(s)) {
|
|
88
|
+
HONO_DEFAULTS.forEach((h) => found.add(h));
|
|
89
|
+
if (/contentSecurityPolicy\s*:/.test(s)) found.add("content-security-policy");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function missingHeaders(repoPath, framework) {
|
|
96
|
+
const present = staticHeaders(repoPath, framework);
|
|
97
|
+
return COVERED.filter((h) => !present.has(h));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function planNext(repoPath) {
|
|
101
|
+
const cfg = nextConfig(repoPath);
|
|
102
|
+
const headerLines = [
|
|
103
|
+
"const securityHeaders = [",
|
|
104
|
+
...SECURITY_HEADERS.map(([k, v]) => ` { key: "${k}", value: ${JSON.stringify(v)} },`),
|
|
105
|
+
"];",
|
|
106
|
+
"",
|
|
107
|
+
];
|
|
108
|
+
const headersFn = [
|
|
109
|
+
" async headers() {",
|
|
110
|
+
' return [{ source: "/:path*", headers: securityHeaders }];',
|
|
111
|
+
" },",
|
|
112
|
+
];
|
|
113
|
+
if (!cfg) {
|
|
114
|
+
const after = [
|
|
115
|
+
"// Security headers (added by penthera fix).",
|
|
116
|
+
...headerLines,
|
|
117
|
+
"/** @type {import('next').NextConfig} */",
|
|
118
|
+
"const nextConfig = {",
|
|
119
|
+
...headersFn,
|
|
120
|
+
"};",
|
|
121
|
+
"",
|
|
122
|
+
"export default nextConfig;",
|
|
123
|
+
"",
|
|
124
|
+
].join("\n");
|
|
125
|
+
return toDiff([{ file: "next.config.mjs", before: null, after }]);
|
|
126
|
+
}
|
|
127
|
+
const { file, source } = cfg;
|
|
128
|
+
// An existing headers() would need merging; leave that to a human.
|
|
129
|
+
if (/\bheaders\s*\(/.test(source) || /\bsecurityHeaders\b/.test(source)) return null;
|
|
130
|
+
|
|
131
|
+
const lines = source.split("\n");
|
|
132
|
+
let idx = -1;
|
|
133
|
+
let empty = false;
|
|
134
|
+
for (let i = 0; i < lines.length; i++) {
|
|
135
|
+
const m = lines[i].match(/^(?:(?:const|let|var)\s+(\w+)(?:\s*:\s*[\w.]+)?\s*=|module\.exports\s*=|export\s+default)\s*\{\s*(\}\s*;?)?\s*$/);
|
|
136
|
+
if (!m) continue;
|
|
137
|
+
// A named config object must be the one that gets exported.
|
|
138
|
+
if (m[1] && !new RegExp(`(?:export\\s+default|module\\.exports\\s*=)\\s*${m[1]}\\s*;?\\s*$`, "m").test(source)) continue;
|
|
139
|
+
idx = i;
|
|
140
|
+
empty = Boolean(m[2]);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
if (idx < 0) return null;
|
|
144
|
+
|
|
145
|
+
const opening = lines[idx].replace(/\}\s*;?\s*$/, "").trimEnd();
|
|
146
|
+
const semicolon = /;\s*$/.test(lines[idx]);
|
|
147
|
+
const replaced = empty ? [opening, ...headersFn, `}${semicolon ? ";" : ""}`] : [lines[idx], ...headersFn];
|
|
148
|
+
lines.splice(idx, 1, ...replaced);
|
|
149
|
+
// Keep a JSDoc type comment (/** @type {import('next').NextConfig} */) attached to its object.
|
|
150
|
+
const at = idx > 0 && /^\s*\/\*\*.*\*\/\s*$/.test(lines[idx - 1]) ? idx - 1 : idx;
|
|
151
|
+
lines.splice(at, 0, "// Security headers (added by penthera fix).", ...headerLines);
|
|
152
|
+
return toDiff([{ file, before: source, after: lines.join("\n") }]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function planServer(repoPath, framework) {
|
|
156
|
+
const probeAnchor = framework === "fastify" ? /['"]fastify['"]/ : ANCHORS[framework];
|
|
157
|
+
const entry = findEntryFile(repoPath, new RegExp(probeAnchor.source, "m"));
|
|
158
|
+
if (!entry) return null;
|
|
159
|
+
const { file, source } = entry;
|
|
160
|
+
const anchor = anchorFor(framework, source);
|
|
161
|
+
const idx = findLine(source, anchor);
|
|
162
|
+
if (idx < 0) return null;
|
|
163
|
+
const app = source.split("\n")[idx].match(anchor)[2];
|
|
164
|
+
|
|
165
|
+
let after;
|
|
166
|
+
if (framework === "express" && hasDep(repoPath, "helmet")) {
|
|
167
|
+
after = insertAfterLine(source, idx, [`${app}.use(helmet());`]);
|
|
168
|
+
after = addImport(after, { name: "helmet", module: "helmet" });
|
|
169
|
+
} else if (framework === "fastify" && hasDep(repoPath, "@fastify/helmet")) {
|
|
170
|
+
after = insertAfterLine(source, idx, [`${app}.register(fastifyHelmet);`]);
|
|
171
|
+
after = addImport(after, { name: "fastifyHelmet", module: "@fastify/helmet" });
|
|
172
|
+
} else if (framework === "hono") {
|
|
173
|
+
after = insertAfterLine(source, idx, [
|
|
174
|
+
`${app}.use(`,
|
|
175
|
+
' "*",',
|
|
176
|
+
" secureHeaders({",
|
|
177
|
+
" contentSecurityPolicy: {",
|
|
178
|
+
` defaultSrc: ["'self'"],`,
|
|
179
|
+
` scriptSrc: ["'self'", "'unsafe-inline'"],`,
|
|
180
|
+
` styleSrc: ["'self'", "'unsafe-inline'"],`,
|
|
181
|
+
` imgSrc: ["'self'", "data:", "blob:"],`,
|
|
182
|
+
` frameAncestors: ["'none'"],`,
|
|
183
|
+
` objectSrc: ["'none'"],`,
|
|
184
|
+
` baseUri: ["'self'"],`,
|
|
185
|
+
" },",
|
|
186
|
+
' xFrameOptions: "DENY",',
|
|
187
|
+
' referrerPolicy: "strict-origin-when-cross-origin",',
|
|
188
|
+
" }),",
|
|
189
|
+
");",
|
|
190
|
+
]);
|
|
191
|
+
after = addImport(after, { name: "secureHeaders", module: "hono/secure-headers", named: true });
|
|
192
|
+
} else if (framework === "express") {
|
|
193
|
+
after = insertAfterLine(source, idx, [
|
|
194
|
+
"",
|
|
195
|
+
"// Security headers (added by penthera fix). Tighten the starter CSP for your app.",
|
|
196
|
+
`${app}.use((req, res, next) => {`,
|
|
197
|
+
...SECURITY_HEADERS.map(([k, v]) => ` res.setHeader("${k}", ${JSON.stringify(v)});`),
|
|
198
|
+
" next();",
|
|
199
|
+
"});",
|
|
200
|
+
]);
|
|
201
|
+
} else if (framework === "fastify") {
|
|
202
|
+
after = insertAfterLine(source, idx, [
|
|
203
|
+
"",
|
|
204
|
+
"// Security headers (added by penthera fix). Tighten the starter CSP for your app.",
|
|
205
|
+
`${app}.addHook("onSend", async (request, reply, payload) => {`,
|
|
206
|
+
...SECURITY_HEADERS.map(([k, v]) => ` reply.header("${k}", ${JSON.stringify(v)});`),
|
|
207
|
+
" return payload;",
|
|
208
|
+
"});",
|
|
209
|
+
]);
|
|
210
|
+
} else {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return toDiff([{ file, before: source, after }]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default {
|
|
217
|
+
id: "security-headers",
|
|
218
|
+
title: "Add security response headers",
|
|
219
|
+
matches(finding) {
|
|
220
|
+
const t = finding.title || "";
|
|
221
|
+
if (/permissions-policy/i.test(t)) return false;
|
|
222
|
+
return /content-security-policy|x-frame-options|frame-ancestors|x-content-type-options|referrer-policy|strict-transport-security|hsts/i.test(t);
|
|
223
|
+
},
|
|
224
|
+
detect(repoPath) {
|
|
225
|
+
return detectFramework(repoPath);
|
|
226
|
+
},
|
|
227
|
+
plan(repoPath, _finding, framework) {
|
|
228
|
+
if (!["next", "express", "fastify", "hono"].includes(framework)) return null;
|
|
229
|
+
if (missingHeaders(repoPath, framework).length === 0) return null; // already fixed
|
|
230
|
+
const change = framework === "next" ? planNext(repoPath) : planServer(repoPath, framework);
|
|
231
|
+
if (!change) return null;
|
|
232
|
+
return { ...change, ownerActions: OWNER_ACTIONS, confidence: "medium" };
|
|
233
|
+
},
|
|
234
|
+
/** Static stand-in for the header audit: which covered headers the code never sets. */
|
|
235
|
+
probe(repoPath) {
|
|
236
|
+
const framework = detectFramework(repoPath);
|
|
237
|
+
if (!["next", "express", "fastify", "hono"].includes(framework)) return [];
|
|
238
|
+
const present = staticHeaders(repoPath, framework);
|
|
239
|
+
const headers = Object.fromEntries([...present].map((h) => [h, "set"]));
|
|
240
|
+
const where = framework === "next"
|
|
241
|
+
? nextConfig(repoPath)?.file || "next.config.mjs"
|
|
242
|
+
: findEntryFile(repoPath, framework === "fastify" ? /['"]fastify['"]/ : new RegExp(ANCHORS[framework].source, "m"))?.file || null;
|
|
243
|
+
const findings = auditSecurityHeaders(headers, "https://localhost", { local: true })
|
|
244
|
+
.filter((f) => !/permissions-policy/i.test(f.title));
|
|
245
|
+
if (!present.has("strict-transport-security")) {
|
|
246
|
+
findings.push({ severity: "low", title: "Missing Strict-Transport-Security", description: "No HSTS header is set in code.", category: "headers" });
|
|
247
|
+
}
|
|
248
|
+
return findings.map((f) => ({ ...f, url: where, source: "fix-probe:headers" }));
|
|
249
|
+
},
|
|
250
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe: a rate-limit stub on login/auth routes. Express (express-rate-limit
|
|
3
|
+
* when installed, otherwise an in-memory limiter), Fastify (preHandler), Hono
|
|
4
|
+
* (middleware) and Next.js App Router route handlers. Guidance: remediation
|
|
5
|
+
* rules "rate-limiting" and "auth", references/remediation.md#rate-limiting.
|
|
6
|
+
*
|
|
7
|
+
* The stub is per-process and in memory: it stops naive brute force against a
|
|
8
|
+
* single instance, and the owner action says to move to a shared store.
|
|
9
|
+
*/
|
|
10
|
+
import { detectFramework as detectSourceFramework } from "../../whitebox/frameworks.js";
|
|
11
|
+
import { detectFramework, hasDep, readText, walkCodeFiles, insertAfterImports, addImport, isTypeScript, toDiff } from "../util.js";
|
|
12
|
+
|
|
13
|
+
const AUTH_SEGMENT = /^(?:login|log-in|logon|signin|sign-in|signup|sign-up|register|auth|session|sessions|forgot-password|reset-password|password-reset|otp|mfa|2fa|verify-otp|magic-link)$/i;
|
|
14
|
+
const ROUTE_LINE = /^(\s*)(\w+)\.(post|put|all)\(\s*(['"`])([^'"`]+)\4\s*,\s*(.*)$/;
|
|
15
|
+
const LIMITED = /rateLimit|limiter|throttle/i;
|
|
16
|
+
|
|
17
|
+
export function isAuthPath(path) {
|
|
18
|
+
if (!path || /\[\.\.\./.test(path)) return false; // catch-alls such as NextAuth's [...nextauth]
|
|
19
|
+
return path.split("/").filter(Boolean).some((seg) => AUTH_SEGMENT.test(seg));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nextRoutePath(file) {
|
|
23
|
+
const m = file.match(/(?:^|\/)app\/(.*\/)?route\.(?:js|ts|mjs)$/);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const dirs = (m[1] || "").split("/").filter((d) => d && !/^\(.*\)$/.test(d));
|
|
26
|
+
return "/" + dirs.join("/");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Unprotected auth routes in one file: [{ method, path, line, framework }]. */
|
|
30
|
+
function authRoutes(file, source, repoFramework) {
|
|
31
|
+
const routes = [];
|
|
32
|
+
const nextPath = nextRoutePath(file);
|
|
33
|
+
if (nextPath) {
|
|
34
|
+
if (!isAuthPath(nextPath) || LIMITED.test(source)) return routes;
|
|
35
|
+
const lines = source.split("\n");
|
|
36
|
+
const line = lines.findIndex((l) => /^export\s+(?:async\s+)?function\s+POST\s*\(/.test(l));
|
|
37
|
+
if (line >= 0) routes.push({ method: "POST", path: nextPath, line, framework: "next" });
|
|
38
|
+
return routes;
|
|
39
|
+
}
|
|
40
|
+
const fileFw = detectSourceFramework(source);
|
|
41
|
+
const framework = fileFw !== "node" ? fileFw : repoFramework;
|
|
42
|
+
if (!["express", "fastify", "hono"].includes(framework)) return routes;
|
|
43
|
+
if (/\.(?:use|register)\([^)\n]*(?:rateLimit|limiter|throttle|rate-limit)/i.test(source)) return routes; // a global limiter
|
|
44
|
+
source.split("\n").forEach((l, line) => {
|
|
45
|
+
const m = l.match(ROUTE_LINE);
|
|
46
|
+
if (!m || !isAuthPath(m[5]) || LIMITED.test(m[6])) return;
|
|
47
|
+
routes.push({ method: m[3].toUpperCase(), path: m[5], line, framework });
|
|
48
|
+
});
|
|
49
|
+
return routes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function coreLines(ts) {
|
|
53
|
+
return [
|
|
54
|
+
"// Login rate limit (added by penthera fix): at most LOGIN_MAX_ATTEMPTS per client",
|
|
55
|
+
"// per window, kept in memory. Use a shared store (Redis, your edge or WAF) when",
|
|
56
|
+
"// you run more than one instance.",
|
|
57
|
+
"const LOGIN_WINDOW_MS = 15 * 60 * 1000;",
|
|
58
|
+
"const LOGIN_MAX_ATTEMPTS = 10;",
|
|
59
|
+
`const loginAttempts = new Map${ts ? "<string, { count: number; reset: number }>" : ""}();`,
|
|
60
|
+
`function loginLimitExceeded(key${ts ? ": string" : ""})${ts ? ": boolean" : ""} {`,
|
|
61
|
+
" const now = Date.now();",
|
|
62
|
+
" if (loginAttempts.size > 10000) {",
|
|
63
|
+
" for (const [k, v] of loginAttempts) if (now > v.reset) loginAttempts.delete(k);",
|
|
64
|
+
" }",
|
|
65
|
+
" const entry = loginAttempts.get(key);",
|
|
66
|
+
" if (!entry || now > entry.reset) {",
|
|
67
|
+
" loginAttempts.set(key, { count: 1, reset: now + LOGIN_WINDOW_MS });",
|
|
68
|
+
" return false;",
|
|
69
|
+
" }",
|
|
70
|
+
" entry.count += 1;",
|
|
71
|
+
" return entry.count > LOGIN_MAX_ATTEMPTS;",
|
|
72
|
+
"}",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const TOO_MANY = '{ error: "Too many attempts, try again later" }';
|
|
77
|
+
|
|
78
|
+
function wrapperLines(framework, ts) {
|
|
79
|
+
const any = ts ? ": any" : "";
|
|
80
|
+
if (framework === "express") {
|
|
81
|
+
return [
|
|
82
|
+
`function rateLimitLogin(req${any}, res${any}, next${any}) {`,
|
|
83
|
+
` if (loginLimitExceeded(req.ip || "unknown")) return res.status(429).json(${TOO_MANY});`,
|
|
84
|
+
" next();",
|
|
85
|
+
"}",
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
if (framework === "fastify") {
|
|
89
|
+
return [
|
|
90
|
+
`async function rateLimitLogin(request${any}, reply${any}) {`,
|
|
91
|
+
` if (loginLimitExceeded(request.ip)) return reply.code(429).send(${TOO_MANY});`,
|
|
92
|
+
"}",
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
if (framework === "hono") {
|
|
96
|
+
return [
|
|
97
|
+
`async function rateLimitLogin(c${any}, next${any}) {`,
|
|
98
|
+
' const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown";',
|
|
99
|
+
` if (loginLimitExceeded(ip)) return c.json(${TOO_MANY}, 429);`,
|
|
100
|
+
" await next();",
|
|
101
|
+
"}",
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
return [
|
|
105
|
+
`function rateLimitLogin(request${ts ? ": Request" : ""})${ts ? ": Response | null" : ""} {`,
|
|
106
|
+
' const ip = request.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";',
|
|
107
|
+
" if (!loginLimitExceeded(ip)) return null;",
|
|
108
|
+
` return Response.json(${TOO_MANY}, { status: 429 });`,
|
|
109
|
+
"}",
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fixFile(repoPath, file, source, routes) {
|
|
114
|
+
const lines = source.split("\n");
|
|
115
|
+
const framework = routes[0].framework;
|
|
116
|
+
const useExpressRateLimit = framework === "express" && hasDep(repoPath, "express-rate-limit");
|
|
117
|
+
for (const r of [...routes].reverse()) {
|
|
118
|
+
const l = lines[r.line];
|
|
119
|
+
if (framework === "next") {
|
|
120
|
+
const m = l.match(/^export\s+(?:async\s+)?function\s+POST\s*\(\s*(\w+)/);
|
|
121
|
+
if (!m || !/\{\s*$/.test(l)) return null;
|
|
122
|
+
lines.splice(r.line + 1, 0, ` const limited = rateLimitLogin(${m[1]});`, " if (limited) return limited;");
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const m = l.match(ROUTE_LINE);
|
|
126
|
+
if (framework === "fastify") {
|
|
127
|
+
if (!m[6] || /^\{/.test(m[6])) return null; // options object (or unseen next line): merge by hand
|
|
128
|
+
lines[r.line] = l.slice(0, l.length - m[6].length) + `{ preHandler: rateLimitLogin }, ${m[6]}`;
|
|
129
|
+
} else {
|
|
130
|
+
lines[r.line] = l.slice(0, l.length - m[6].length) + `rateLimitLogin, ${m[6]}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let after = lines.join("\n");
|
|
134
|
+
if (!/\brateLimitLogin\s*=|function\s+rateLimitLogin\b/.test(source)) {
|
|
135
|
+
const ts = isTypeScript(file);
|
|
136
|
+
if (useExpressRateLimit) {
|
|
137
|
+
after = insertAfterImports(after, [
|
|
138
|
+
"// Login rate limit (added by penthera fix): 10 attempts per client per 15 minutes.",
|
|
139
|
+
'const rateLimitLogin = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: "draft-7", legacyHeaders: false });',
|
|
140
|
+
]);
|
|
141
|
+
after = addImport(after, { name: "rateLimit", module: "express-rate-limit", named: true });
|
|
142
|
+
} else {
|
|
143
|
+
after = insertAfterImports(after, [...coreLines(ts), "", ...wrapperLines(framework, ts)]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { file, before: source, after };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function routeFiles(repoPath) {
|
|
150
|
+
const framework = detectFramework(repoPath);
|
|
151
|
+
return walkCodeFiles(repoPath)
|
|
152
|
+
.map((file) => {
|
|
153
|
+
const source = readText(repoPath, file);
|
|
154
|
+
return { file, source, routes: source ? authRoutes(file, source, framework) : [] };
|
|
155
|
+
})
|
|
156
|
+
.filter((f) => f.routes.length > 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function routeOf(finding) {
|
|
160
|
+
return finding.route || (finding.title || "").match(/(?:rate limiting|rate limit)[^/]*?(?:(?:POST|PUT|ALL)\s+)?(\/\S*)/i)?.[1] || finding.url || "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default {
|
|
164
|
+
id: "auth-rate-limit",
|
|
165
|
+
title: "Rate-limit login and auth routes",
|
|
166
|
+
matches(finding) {
|
|
167
|
+
const isRate = finding.category === "rate-limiting" || /rate limit/i.test(finding.title || "");
|
|
168
|
+
if (!isRate) return false;
|
|
169
|
+
const route = routeOf(finding);
|
|
170
|
+
try {
|
|
171
|
+
return isAuthPath(route.startsWith("http") ? new URL(route).pathname : route);
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
detect(repoPath) {
|
|
177
|
+
const fw = detectFramework(repoPath);
|
|
178
|
+
return ["next", "express", "fastify", "hono"].includes(fw) ? fw : null;
|
|
179
|
+
},
|
|
180
|
+
plan(repoPath, finding) {
|
|
181
|
+
let route = routeOf(finding);
|
|
182
|
+
try {
|
|
183
|
+
if (route.startsWith("http")) route = new URL(route).pathname;
|
|
184
|
+
} catch { /* keep as-is */ }
|
|
185
|
+
const target = routeFiles(repoPath).find((f) => f.routes.some((r) => r.path === route));
|
|
186
|
+
if (!target) return null;
|
|
187
|
+
const change = toDiff([fixFile(repoPath, target.file, target.source, target.routes)]);
|
|
188
|
+
if (!change) return null;
|
|
189
|
+
return {
|
|
190
|
+
...change,
|
|
191
|
+
ownerActions: [
|
|
192
|
+
"The limiter is in memory and per process. Move it to a shared store (Redis, your platform's limiter, or a WAF rule) before you run more than one instance.",
|
|
193
|
+
"Make sure the client IP is the real one: set Express `trust proxy` behind a proxy, and only trust X-Forwarded-For from your own edge. Consider also limiting per account.",
|
|
194
|
+
],
|
|
195
|
+
confidence: "medium",
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
probe(repoPath) {
|
|
199
|
+
return routeFiles(repoPath).flatMap(({ file, routes }) =>
|
|
200
|
+
routes.map((r) => ({
|
|
201
|
+
severity: "medium",
|
|
202
|
+
title: `Login route without rate limiting: ${r.method} ${r.path}`,
|
|
203
|
+
description: `Defined in ${file}:${r.line + 1}`,
|
|
204
|
+
url: file,
|
|
205
|
+
route: r.path,
|
|
206
|
+
category: "rate-limiting",
|
|
207
|
+
source: "fix-probe:rate-limit",
|
|
208
|
+
})),
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe: move a hardcoded secret out of JS/TS source into an environment
|
|
3
|
+
* variable and list that variable in .env.example. Guidance: remediation rule
|
|
4
|
+
* "secrets", references/remediation.md#hardcoded-secrets.
|
|
5
|
+
*
|
|
6
|
+
* The secret is already burned: a diff can take it out of the code, but only
|
|
7
|
+
* the owner can rotate it, so "rotate this secret" is always an owner action.
|
|
8
|
+
* The plan's diff contains the old value on its "-" line; plans carry it in
|
|
9
|
+
* `redactions` so anything that prints or stores a plan can mask it.
|
|
10
|
+
*/
|
|
11
|
+
import { findSecretsInText } from "../../whitebox/secrets.js";
|
|
12
|
+
import { readText, walkCodeFiles, envExampleChange, toDiff, toEnvName } from "../util.js";
|
|
13
|
+
|
|
14
|
+
const JS_FILE = /\.(js|ts|mjs|cjs|jsx|tsx)$/;
|
|
15
|
+
|
|
16
|
+
// Env var names for provider keys that are not inside a named assignment.
|
|
17
|
+
const PROVIDER_ENV = [
|
|
18
|
+
[/Anthropic/i, "ANTHROPIC_API_KEY"],
|
|
19
|
+
[/OpenRouter/i, "OPENROUTER_API_KEY"],
|
|
20
|
+
[/OpenAI/i, "OPENAI_API_KEY"],
|
|
21
|
+
[/Google/i, "GOOGLE_API_KEY"],
|
|
22
|
+
[/Groq/i, "GROQ_API_KEY"],
|
|
23
|
+
[/Hugging Face/i, "HF_TOKEN"],
|
|
24
|
+
[/Replicate/i, "REPLICATE_API_TOKEN"],
|
|
25
|
+
[/xAI/i, "XAI_API_KEY"],
|
|
26
|
+
[/Perplexity/i, "PERPLEXITY_API_KEY"],
|
|
27
|
+
[/LangSmith/i, "LANGSMITH_API_KEY"],
|
|
28
|
+
[/Pinecone/i, "PINECONE_API_KEY"],
|
|
29
|
+
[/Stripe/i, "STRIPE_SECRET_KEY"],
|
|
30
|
+
[/AWS/i, "AWS_ACCESS_KEY_ID"],
|
|
31
|
+
[/GitHub/i, "GITHUB_TOKEN"],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Locate the string literal holding a secret on one line.
|
|
36
|
+
* Returns { start, end, value, envName } (offsets within the line) or null.
|
|
37
|
+
*/
|
|
38
|
+
function locate(line, hit) {
|
|
39
|
+
const at = line.indexOf(hit.match);
|
|
40
|
+
if (at < 0) return null;
|
|
41
|
+
// Assignment rules match `key = "value"`; provider rules match the raw key.
|
|
42
|
+
const assigned = hit.match.match(/^([\w$-]+)\s*[:=]\s*(['"])([^'"]*)\2$/);
|
|
43
|
+
const value = assigned ? assigned[3] : hit.match;
|
|
44
|
+
const valueAt = assigned ? at + hit.match.lastIndexOf(value) : at;
|
|
45
|
+
const quote = line[valueAt - 1];
|
|
46
|
+
if (!["'", '"', "`"].includes(quote) || line[valueAt + value.length] !== quote) return null; // not a whole literal
|
|
47
|
+
if (quote === "`" && value.includes("${")) return null;
|
|
48
|
+
// Name: the key the literal is assigned to, else the provider's usual name.
|
|
49
|
+
const before = line.slice(0, valueAt - 1);
|
|
50
|
+
const key = before.match(/([A-Za-z_$][\w$]*)['"]?\s*[:=]\s*$/)?.[1];
|
|
51
|
+
const envName = (key && !/^(?:key|value|token|secret)$/i.test(key) && toEnvName(key))
|
|
52
|
+
|| PROVIDER_ENV.find(([re]) => re.test(hit.name))?.[1]
|
|
53
|
+
|| (key && toEnvName(key))
|
|
54
|
+
|| "SECRET_VALUE";
|
|
55
|
+
return { start: valueAt - 1, end: valueAt + value.length + 1, value, envName };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findSecret(source, title) {
|
|
59
|
+
const lines = source.split("\n");
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
for (const hit of findSecretsInText(lines[i])) {
|
|
62
|
+
if (title && `${hit.name} in source` !== title) continue;
|
|
63
|
+
const loc = locate(lines[i], hit);
|
|
64
|
+
if (loc) return { line: i, ...loc, name: hit.name };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default {
|
|
71
|
+
id: "secret-to-env",
|
|
72
|
+
title: "Move a hardcoded secret to an environment variable",
|
|
73
|
+
matches(finding) {
|
|
74
|
+
return finding.category === "secrets";
|
|
75
|
+
},
|
|
76
|
+
detect() {
|
|
77
|
+
return "node";
|
|
78
|
+
},
|
|
79
|
+
plan(repoPath, finding) {
|
|
80
|
+
const file = finding.url;
|
|
81
|
+
if (!file || !JS_FILE.test(file) || !walkCodeFiles(repoPath).includes(file)) return null;
|
|
82
|
+
const source = readText(repoPath, file);
|
|
83
|
+
if (!source) return null;
|
|
84
|
+
const hit = findSecret(source, finding.title);
|
|
85
|
+
if (!hit) return null;
|
|
86
|
+
|
|
87
|
+
const lines = source.split("\n");
|
|
88
|
+
const l = lines[hit.line];
|
|
89
|
+
// TypeScript types process.env values as string | undefined; the owner action says to set it.
|
|
90
|
+
const ref = `process.env.${hit.envName}${/\.tsx?$/.test(file) ? " as string" : ""}`;
|
|
91
|
+
lines[hit.line] = l.slice(0, hit.start) + ref + l.slice(hit.end);
|
|
92
|
+
const change = toDiff([
|
|
93
|
+
{ file, before: source, after: lines.join("\n") },
|
|
94
|
+
envExampleChange(repoPath, hit.envName, "", `${hit.name}: set the real value in .env or your secret manager, never in code`),
|
|
95
|
+
]);
|
|
96
|
+
if (!change) return null;
|
|
97
|
+
|
|
98
|
+
const ownerActions = [
|
|
99
|
+
`Rotate this secret at the provider now and treat the old value as compromised. A recipe can never rotate it for you.`,
|
|
100
|
+
`Set ${hit.envName} in your local .env and in every deployment environment before deploying this change.`,
|
|
101
|
+
"If the old value was ever committed or pushed, purge it from git history (git filter-repo or BFG) after rotating.",
|
|
102
|
+
];
|
|
103
|
+
if (/^(?:NEXT_PUBLIC_|VITE_|PUBLIC_|REACT_APP_)/.test(hit.envName) || /^\s*['"]use client['"]/.test(source)) {
|
|
104
|
+
ownerActions.push("This code runs in the browser, so an env var is still shipped to every visitor. Move the call that needs the secret to a server route.");
|
|
105
|
+
}
|
|
106
|
+
const ignore = readText(repoPath, ".gitignore");
|
|
107
|
+
if (!ignore || !/^\.env\b/m.test(ignore)) ownerActions.push("Add .env to .gitignore so the real value is never committed.");
|
|
108
|
+
return { ...change, ownerActions, confidence: "high", redactions: [hit.value] };
|
|
109
|
+
},
|
|
110
|
+
};
|