penthera 1.2.0 → 1.5.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 -0
- package/README.md +493 -45
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +1 -1
- 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 +12 -1
- package/docs/local-loop.md +276 -0
- package/docs/plan.md +100 -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/compliance/cra.js +525 -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 +440 -0
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +109 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +190 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/spdx.json +1 -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/hook/index.js +312 -0
- package/lib/hook/policy.js +62 -0
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +125 -0
- package/lib/jurisdictions/nl.json +9 -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/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +11 -1
- package/package.json +17 -4
- package/skills/penthera/SKILL.md +132 -22
- 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 +104 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/hook.js +131 -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 +212 -8
- 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/lib/fix/util.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for fix recipes: framework detection, file discovery, and the
|
|
3
|
+
* small, anchor-based text edits recipes are built from. No AST: every edit
|
|
4
|
+
* keys off a line the recipe can recognise exactly, and gives up otherwise.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { detectFramework as detectSourceFramework } from "../whitebox/frameworks.js";
|
|
9
|
+
import { createPatch } from "./diff.js";
|
|
10
|
+
|
|
11
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor", "reports", ".security-results"]);
|
|
12
|
+
const CODE_EXT = /\.(js|ts|mjs|cjs|jsx|tsx)$/;
|
|
13
|
+
const MAX_FILES = 2000;
|
|
14
|
+
|
|
15
|
+
export const ENTRY_CANDIDATES = [
|
|
16
|
+
"server.js", "server.ts", "server.mjs", "app.js", "app.ts", "app.mjs",
|
|
17
|
+
"index.js", "index.ts", "index.mjs", "main.js", "main.ts",
|
|
18
|
+
"src/server.js", "src/server.ts", "src/app.js", "src/app.ts",
|
|
19
|
+
"src/index.js", "src/index.ts", "src/main.js", "src/main.ts",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function readText(repoPath, rel) {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(join(repoPath, rel), "utf-8");
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readPackageJson(repoPath) {
|
|
31
|
+
const text = readText(repoPath, "package.json");
|
|
32
|
+
if (!text) return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasDep(repoPath, name) {
|
|
41
|
+
const pkg = readPackageJson(repoPath);
|
|
42
|
+
return Boolean(pkg && (pkg.dependencies?.[name] || pkg.devDependencies?.[name]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The web framework a repo uses: "next" | "hono" | "fastify" | "express" | null.
|
|
47
|
+
* package.json decides; source imports (via the white-box route scanner's
|
|
48
|
+
* detector) are the fallback for repos without one.
|
|
49
|
+
*/
|
|
50
|
+
export function detectFramework(repoPath) {
|
|
51
|
+
for (const fw of ["next", "hono", "fastify", "express"]) if (hasDep(repoPath, fw)) return fw;
|
|
52
|
+
if (["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"].some((f) => existsSync(join(repoPath, f)))) return "next";
|
|
53
|
+
for (const rel of ENTRY_CANDIDATES) {
|
|
54
|
+
const source = readText(repoPath, rel);
|
|
55
|
+
if (!source) continue;
|
|
56
|
+
const fw = detectSourceFramework(source);
|
|
57
|
+
if (fw !== "node") return fw;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Repo-relative paths of JS/TS source files, skipping dependencies and build output. */
|
|
63
|
+
export function walkCodeFiles(repoPath) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const walk = (dir) => {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dir);
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const entry of entries.sort()) {
|
|
73
|
+
if (out.length >= MAX_FILES) return;
|
|
74
|
+
if (SKIP.has(entry)) continue;
|
|
75
|
+
const full = join(dir, entry);
|
|
76
|
+
let st;
|
|
77
|
+
try {
|
|
78
|
+
st = statSync(full);
|
|
79
|
+
} catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (st.isDirectory()) walk(full);
|
|
83
|
+
else if (CODE_EXT.test(entry) && !/\.(test|spec)\.|\.d\.ts$|\.min\.js$/.test(entry)) out.push(relative(repoPath, full));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
walk(repoPath);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** First entry-candidate file whose source matches `anchor`. */
|
|
91
|
+
export function findEntryFile(repoPath, anchor) {
|
|
92
|
+
const candidates = [...ENTRY_CANDIDATES];
|
|
93
|
+
const main = readPackageJson(repoPath)?.main;
|
|
94
|
+
if (typeof main === "string") candidates.unshift(main.replace(/^\.\//, ""));
|
|
95
|
+
for (const rel of candidates) {
|
|
96
|
+
const source = readText(repoPath, rel);
|
|
97
|
+
if (source && anchor.test(source)) return { file: rel, source };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const isTypeScript = (file) => /\.tsx?$/.test(file);
|
|
103
|
+
|
|
104
|
+
export function moduleStyle(source) {
|
|
105
|
+
if (/^\s*import\s[^;]*?from\s+['"]|^\s*import\s+['"]|^\s*export\s/m.test(source)) return "esm";
|
|
106
|
+
if (/\brequire\s*\(/.test(source)) return "cjs";
|
|
107
|
+
return "esm";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Index of the line after the last top-level import/require statement. */
|
|
111
|
+
function importBlockEnd(lines) {
|
|
112
|
+
let end = 0;
|
|
113
|
+
let open = false;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const l = lines[i];
|
|
116
|
+
if (open) {
|
|
117
|
+
if (/\bfrom\s+['"][^'"]+['"];?\s*$/.test(l)) { open = false; end = i + 1; }
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (/^import\s.*\bfrom\s+['"][^'"]+['"];?\s*$/.test(l) || /^import\s+['"][^'"]+['"];?\s*$/.test(l)) end = i + 1;
|
|
121
|
+
else if (/^import\s+(?:type\s+)?\{[^}]*$/.test(l)) open = true;
|
|
122
|
+
else if (/^(?:const|let|var)\s+[\w{}\s,:]+=\s*require\(\s*['"][^'"]+['"]\s*\)[\w.]*;?\s*$/.test(l)) end = i + 1;
|
|
123
|
+
else if (i === 0 && /^#!/.test(l)) end = 1;
|
|
124
|
+
else if (/^['"]use (strict|client|server)['"];?\s*$/.test(l)) end = Math.max(end, i + 1);
|
|
125
|
+
}
|
|
126
|
+
return end;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Insert `block` (lines) after the import block, separated by a blank line. */
|
|
130
|
+
export function insertAfterImports(source, block) {
|
|
131
|
+
const lines = source.split("\n");
|
|
132
|
+
const at = importBlockEnd(lines);
|
|
133
|
+
const insert = [...block];
|
|
134
|
+
if (at > 0 && lines[at - 1].trim() !== "") insert.unshift("");
|
|
135
|
+
if (lines[at] !== undefined && lines[at].trim() !== "") insert.push("");
|
|
136
|
+
lines.splice(at, 0, ...insert);
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Add an import (ESM or CommonJS, matching the file) unless the module is already imported. */
|
|
141
|
+
export function addImport(source, { name, module, named = false }) {
|
|
142
|
+
const escaped = module.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
143
|
+
if (new RegExp(`from\\s+['"]${escaped}['"]|require\\(\\s*['"]${escaped}['"]\\s*\\)`).test(source)) return source;
|
|
144
|
+
const line = moduleStyle(source) === "cjs"
|
|
145
|
+
? `const ${named ? `{ ${name} }` : name} = require("${module}");`
|
|
146
|
+
: `import ${named ? `{ ${name} }` : name} from "${module}";`;
|
|
147
|
+
const lines = source.split("\n");
|
|
148
|
+
const at = importBlockEnd(lines);
|
|
149
|
+
lines.splice(at, 0, line);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Insert lines right after line `index`, matching that line's indentation. */
|
|
154
|
+
export function insertAfterLine(source, index, block) {
|
|
155
|
+
const lines = source.split("\n");
|
|
156
|
+
const indent = lines[index].match(/^\s*/)[0];
|
|
157
|
+
lines.splice(index + 1, 0, ...block.map((l) => (l ? indent + l : l)));
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Index of the first line matching re, or -1. */
|
|
162
|
+
export function findLine(source, re) {
|
|
163
|
+
return source.split("\n").findIndex((l) => re.test(l));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Split a call's arguments starting at the "(" at `open`, on one line only.
|
|
168
|
+
* Returns { args: [{ text, start, end }], close } with absolute offsets, or
|
|
169
|
+
* null if the call does not close before the end of the line.
|
|
170
|
+
*/
|
|
171
|
+
export function splitCallArgs(text, open) {
|
|
172
|
+
const args = [];
|
|
173
|
+
let depth = 0;
|
|
174
|
+
let quote = null;
|
|
175
|
+
let argStart = open + 1;
|
|
176
|
+
for (let i = open; i < text.length; i++) {
|
|
177
|
+
const ch = text[i];
|
|
178
|
+
if (ch === "\n" && !quote) return null;
|
|
179
|
+
if (quote) {
|
|
180
|
+
if (ch === "\\") i++;
|
|
181
|
+
else if (ch === quote) quote = null;
|
|
182
|
+
else if (ch === "\n") return null;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
186
|
+
else if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
187
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
188
|
+
depth--;
|
|
189
|
+
if (depth === 0) {
|
|
190
|
+
if (text.slice(argStart, i).trim()) args.push({ text: text.slice(argStart, i).trim(), start: argStart, end: i });
|
|
191
|
+
return { args, close: i };
|
|
192
|
+
}
|
|
193
|
+
} else if (ch === "," && depth === 1) {
|
|
194
|
+
args.push({ text: text.slice(argStart, i).trim(), start: argStart, end: i });
|
|
195
|
+
argStart = i + 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Add `NAME=value` to .env.example (creating it) unless NAME is already listed. */
|
|
202
|
+
export function envExampleChange(repoPath, name, value = "", comment = null) {
|
|
203
|
+
const before = readText(repoPath, ".env.example");
|
|
204
|
+
if (before != null && new RegExp(`^\\s*${name}\\s*=`, "m").test(before)) return null;
|
|
205
|
+
const add = [...(comment ? [`# ${comment}`] : []), `${name}=${value}`].join("\n") + "\n";
|
|
206
|
+
const after = before == null ? add : before + (before.endsWith("\n") || before === "" ? "" : "\n") + add;
|
|
207
|
+
return { file: ".env.example", before, after };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Turn [{ file, before, after }] into { diff, files }, dropping no-op changes. */
|
|
211
|
+
export function toDiff(changes) {
|
|
212
|
+
const parts = [];
|
|
213
|
+
const files = [];
|
|
214
|
+
for (const c of changes.filter(Boolean)) {
|
|
215
|
+
const patch = createPatch(c.file, c.before, c.after);
|
|
216
|
+
if (!patch) continue;
|
|
217
|
+
parts.push(patch);
|
|
218
|
+
files.push(c.file);
|
|
219
|
+
}
|
|
220
|
+
if (parts.length === 0) return null;
|
|
221
|
+
return { diff: parts.join(""), files };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** "sessionSecret" / "api-key" -> "SESSION_SECRET" / "API_KEY". */
|
|
225
|
+
export function toEnvName(key) {
|
|
226
|
+
return String(key)
|
|
227
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
228
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
229
|
+
.replace(/^_+|_+$/g, "")
|
|
230
|
+
.toUpperCase();
|
|
231
|
+
}
|
package/lib/fuzzer.js
CHANGED
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
* - Schemathesis, property-based testing from API schemas
|
|
9
9
|
* - RESTler, stateful fuzzing with dependency inference
|
|
10
10
|
* - fast-check, shrinkable random generators
|
|
11
|
+
*
|
|
12
|
+
* What this file is, for anyone reviewing the package:
|
|
13
|
+
* The values below are edge-case test inputs (type confusion, oversized
|
|
14
|
+
* strings, well-known injection probes). Penthera never executes them. They
|
|
15
|
+
* are sent as JSON request bodies to the user's own target, and only when
|
|
16
|
+
* the user opts in with --fuzz (or the deep profile), to see whether the API
|
|
17
|
+
* validates its input. Nothing runs on the machine running Penthera.
|
|
11
18
|
*/
|
|
12
19
|
|
|
20
|
+
import { request } from "../src/utils/http.js";
|
|
21
|
+
|
|
13
22
|
// ── Payload Generators ────────────────────────────────────────────────────
|
|
14
23
|
|
|
15
24
|
/** Generate fuzz values for a given field name (heuristic-based) */
|
|
@@ -176,15 +185,14 @@ export async function fuzzEndpoint(baseUrl, path, validBody, opts = {}) {
|
|
|
176
185
|
continue; // can't serialize (e.g., circular)
|
|
177
186
|
}
|
|
178
187
|
|
|
179
|
-
const controller = new AbortController();
|
|
180
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
181
|
-
|
|
182
188
|
try {
|
|
183
|
-
const res = await
|
|
189
|
+
const res = await request(`${baseUrl}${path}`, {
|
|
184
190
|
method,
|
|
185
191
|
headers,
|
|
186
192
|
body,
|
|
187
|
-
|
|
193
|
+
timeout,
|
|
194
|
+
probe: "api-fuzzing",
|
|
195
|
+
redirect: "follow",
|
|
188
196
|
});
|
|
189
197
|
|
|
190
198
|
const text = await res.text().catch(() => "");
|
|
@@ -223,9 +231,7 @@ export async function fuzzEndpoint(baseUrl, path, validBody, opts = {}) {
|
|
|
223
231
|
findings.push(result);
|
|
224
232
|
}
|
|
225
233
|
} catch {
|
|
226
|
-
// timeout or network error
|
|
227
|
-
} finally {
|
|
228
|
-
clearTimeout(timer);
|
|
234
|
+
// timeout or network error: recorded in result.errors by the HTTP client
|
|
229
235
|
}
|
|
230
236
|
}
|
|
231
237
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera hook`: a git pre-commit hook that runs the checks that matter most
|
|
3
|
+
* before code leaves the laptop, on staged content only.
|
|
4
|
+
*
|
|
5
|
+
* - secrets in staged files (lib/whitebox/secrets.js, AI provider keys included)
|
|
6
|
+
* - `penthera agents` rules on staged agent config (.mcp.json, CLAUDE.md, skills/, ...)
|
|
7
|
+
* - a staged package-lock.json: the capability diff against HEAD's lockfile,
|
|
8
|
+
* plus the license policy when penthera.policy.json has one
|
|
9
|
+
*
|
|
10
|
+
* Everything is read from the git index (what will be committed), never the
|
|
11
|
+
* working tree, so a half-staged file is judged by its staged half. Offline,
|
|
12
|
+
* no telemetry. Findings use the standard shape, so ids match a full scan and
|
|
13
|
+
* penthera.suppress.json works here too.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join, posix, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { findSecretsInText, isSecretScanTarget, maskMatch } from "../whitebox/secrets.js";
|
|
21
|
+
import { loadPolicy } from "../install/policy.js";
|
|
22
|
+
import { loadRepoSuppressions } from "../memory/repo-suppress.js";
|
|
23
|
+
import { findingId } from "../memory/views.js";
|
|
24
|
+
import { fingerprintFinding } from "../audit-loop.js";
|
|
25
|
+
import { globToRegExp, isAtLeast, SEVERITIES } from "./policy.js";
|
|
26
|
+
|
|
27
|
+
export const HOOK_MARKER = "# penthera-hook";
|
|
28
|
+
export const BACKUP_NAME = "pre-commit.pre-penthera";
|
|
29
|
+
const MAX_BLOB_BYTES = 2 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
// ── git plumbing ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function git(cwd, args, opts = {}) {
|
|
34
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, ...opts });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function repoRoot(cwd) {
|
|
38
|
+
try {
|
|
39
|
+
return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(`Not inside a git repository: ${cwd}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Paths added, copied, modified or renamed in the index (what this commit adds). */
|
|
46
|
+
export function stagedFiles(root) {
|
|
47
|
+
return git(root, ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR", "--no-renames"]).split("\0").filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read index blobs in one `git cat-file --batch` call. Returns Map(path → Buffer); large files are skipped. */
|
|
51
|
+
export function readStaged(root, paths) {
|
|
52
|
+
const out = new Map();
|
|
53
|
+
const wanted = paths.filter((p) => !p.includes("\n"));
|
|
54
|
+
if (!wanted.length) return out;
|
|
55
|
+
const r = spawnSync("git", ["cat-file", "--batch"], { cwd: root, input: wanted.map((p) => `:${p}\n`).join(""), maxBuffer: 1024 * 1024 * 1024 });
|
|
56
|
+
if (r.status !== 0) throw new Error(`git cat-file failed: ${String(r.stderr).trim()}`);
|
|
57
|
+
const buf = r.stdout;
|
|
58
|
+
let pos = 0;
|
|
59
|
+
for (const p of wanted) {
|
|
60
|
+
const nl = buf.indexOf(10, pos);
|
|
61
|
+
if (nl === -1) break;
|
|
62
|
+
const header = buf.subarray(pos, nl).toString();
|
|
63
|
+
pos = nl + 1;
|
|
64
|
+
const m = /^\S+ (\w+) (\d+)$/.exec(header);
|
|
65
|
+
if (!m) continue; // "<name> missing"
|
|
66
|
+
const size = Number(m[2]);
|
|
67
|
+
if (m[1] === "blob" && size <= MAX_BLOB_BYTES) out.set(p, buf.subarray(pos, pos + size));
|
|
68
|
+
pos += size + 1;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Checks ────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function lineOf(text, index) {
|
|
76
|
+
let n = 1;
|
|
77
|
+
for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++;
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkSecrets(files, blobs) {
|
|
82
|
+
const findings = [];
|
|
83
|
+
for (const path of files) {
|
|
84
|
+
if (!isSecretScanTarget(path)) continue;
|
|
85
|
+
const buf = blobs.get(path);
|
|
86
|
+
if (!buf || buf.subarray(0, 8192).includes(0)) continue;
|
|
87
|
+
const text = buf.toString("utf-8");
|
|
88
|
+
for (const hit of findSecretsInText(text)) {
|
|
89
|
+
// Same title/url/category/source as the repo scan, so finding ids match.
|
|
90
|
+
findings.push({
|
|
91
|
+
severity: hit.severity,
|
|
92
|
+
title: `${hit.name} in source`,
|
|
93
|
+
description: `Possible secret in \`${path}\`: \`${maskMatch(hit.match)}\``,
|
|
94
|
+
url: path,
|
|
95
|
+
line: lineOf(text, text.indexOf(hit.match)),
|
|
96
|
+
evidence: maskMatch(hit.match),
|
|
97
|
+
category: "secrets",
|
|
98
|
+
source: "secret-scan",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const AGENT_FILES = new Set(["AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", "GEMINI.md", ".cursorrules", ".windsurfrules", ".mcp.json", ".github/copilot-instructions.md", ".vscode/mcp.json"]);
|
|
106
|
+
const AGENT_DIRS = [".claude/", ".cursor/", ".agents/", ".codex/", ".clinerules/", ".claude-plugin/", ".github/instructions/", "skills/", "plugins/"];
|
|
107
|
+
|
|
108
|
+
/** Is this repo path agent configuration that `penthera agents` reads? */
|
|
109
|
+
export function isAgentConfig(path) {
|
|
110
|
+
return AGENT_FILES.has(path) || AGENT_DIRS.some((d) => path.startsWith(d));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Agent rules on the staged view of the agent config. The whole agent config
|
|
115
|
+
* in the index is checked out to a temp folder (a skill is judged as a folder,
|
|
116
|
+
* not file by file); only findings that touch a staged path are kept.
|
|
117
|
+
*/
|
|
118
|
+
async function checkAgents(root, staged) {
|
|
119
|
+
const touched = staged.filter(isAgentConfig);
|
|
120
|
+
if (!touched.length) return { findings: [], files: 0 };
|
|
121
|
+
const all = git(root, ["ls-files", "-z", "--cached"]).split("\0").filter((p) => p && isAgentConfig(p));
|
|
122
|
+
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "penthera-hook-")));
|
|
123
|
+
try {
|
|
124
|
+
const r = spawnSync("git", ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { cwd: root, input: all.join("\0") });
|
|
125
|
+
if (r.status !== 0) throw new Error(`git checkout-index failed: ${String(r.stderr).trim()}`);
|
|
126
|
+
const { scanAgents } = await import("../agents/index.js");
|
|
127
|
+
const result = await scanAgents(tmp, { lockPath: join(tmp, ".penthera-hook-no-lock") });
|
|
128
|
+
const relates = (url) => touched.some((s) => url === s || s.startsWith(`${url}/`) || url.startsWith(`${s}/`));
|
|
129
|
+
const findings = result.findings
|
|
130
|
+
.filter((f) => !String(f.templateId).startsWith("agents/lock-") && relates(f.url))
|
|
131
|
+
.map((f) => {
|
|
132
|
+
const m = /\(line (\d+)\)/.exec(f.description || "");
|
|
133
|
+
return m ? { ...f, line: Number(m[1]) } : f;
|
|
134
|
+
});
|
|
135
|
+
return { findings, files: touched.length };
|
|
136
|
+
} finally {
|
|
137
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const EMPTY_LOCK = { lockfileVersion: 3, packages: {} };
|
|
142
|
+
|
|
143
|
+
/** Capability diff (and license policy) for each staged lockfile, against HEAD. */
|
|
144
|
+
async function checkLockfiles(root, staged, blobs, { licensePolicy }) {
|
|
145
|
+
const locks = staged.filter((p) => ["package-lock.json", "npm-shrinkwrap.json"].includes(posix.basename(p)));
|
|
146
|
+
if (!locks.length) return { findings: [], files: 0 };
|
|
147
|
+
const { scanDeps } = await import("../deps/index.js");
|
|
148
|
+
const findings = [];
|
|
149
|
+
for (const path of locks) {
|
|
150
|
+
const buf = blobs.get(path);
|
|
151
|
+
if (!buf) continue;
|
|
152
|
+
let lock;
|
|
153
|
+
try { lock = JSON.parse(buf.toString("utf-8")); } catch (e) {
|
|
154
|
+
findings.push({ severity: "high", title: `${path} is not valid JSON`, description: e.message, url: path, category: "dependency-risk", source: "deps-scan" });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
let baseLock = EMPTY_LOCK;
|
|
158
|
+
try { baseLock = JSON.parse(git(root, ["show", `HEAD:${path}`])); } catch { /* new lockfile or first commit */ }
|
|
159
|
+
const dir = posix.dirname(path);
|
|
160
|
+
const result = await scanDeps(join(root, dir), { lock, baseLock, baseLabel: "HEAD", changedOnly: true, licensePolicy });
|
|
161
|
+
const prefix = dir === "." ? "" : `${dir}/`;
|
|
162
|
+
for (const f of result.findings) findings.push({ ...f, url: f.url ? `${prefix}${f.url}` : path });
|
|
163
|
+
}
|
|
164
|
+
return { findings, files: locks.length };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Run ───────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function suppressedBy(entries, f) {
|
|
170
|
+
if (!entries.length) return null;
|
|
171
|
+
const id = findingId(f);
|
|
172
|
+
const fp = fingerprintFinding(f);
|
|
173
|
+
return entries.find((e) => e.id === id || e.fingerprint === fp) || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const bySeverity = (a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || String(a.url).localeCompare(String(b.url)) || (a.line || 0) - (b.line || 0);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check what is staged. Never writes to the repo.
|
|
180
|
+
* @param {string} cwd anywhere inside the repo
|
|
181
|
+
* @param {{ policyPath?: string, now?: string }} opts
|
|
182
|
+
* @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
|
|
183
|
+
*/
|
|
184
|
+
export async function runHook(cwd, opts = {}) {
|
|
185
|
+
const started = Date.now();
|
|
186
|
+
const root = repoRoot(cwd);
|
|
187
|
+
const { policy, path: policyPath } = loadPolicy(root, opts.policyPath || null);
|
|
188
|
+
const { blockOn, checks, ignore } = policy.hook;
|
|
189
|
+
const ignored = ignore.map(globToRegExp);
|
|
190
|
+
const staged = stagedFiles(root).filter((p) => !ignored.some((re) => re.test(p)));
|
|
191
|
+
|
|
192
|
+
const wantBlobs = staged.filter((p) => (checks.secrets && isSecretScanTarget(p)) || /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(p));
|
|
193
|
+
const blobs = readStaged(root, wantBlobs);
|
|
194
|
+
|
|
195
|
+
const secrets = checks.secrets ? checkSecrets(staged, blobs) : [];
|
|
196
|
+
const agents = checks.agents ? await checkAgents(root, staged) : { findings: [], files: 0 };
|
|
197
|
+
const deps = checks.deps ? await checkLockfiles(root, staged, blobs, { licensePolicy: checks.licenses ? policy.licenses : null }) : { findings: [], files: 0 };
|
|
198
|
+
|
|
199
|
+
const sup = loadRepoSuppressions(root, { now: opts.now });
|
|
200
|
+
const all = [...secrets, ...agents.findings, ...deps.findings].map((f) => ({ ...f, id: findingId(f) }));
|
|
201
|
+
const suppressed = [];
|
|
202
|
+
const kept = [];
|
|
203
|
+
for (const f of all) {
|
|
204
|
+
const s = suppressedBy(sup.entries, f);
|
|
205
|
+
if (s) suppressed.push({ ...f, suppression: { reason: s.reason } });
|
|
206
|
+
else kept.push(f);
|
|
207
|
+
}
|
|
208
|
+
kept.sort(bySeverity);
|
|
209
|
+
return {
|
|
210
|
+
root,
|
|
211
|
+
staged: staged.length,
|
|
212
|
+
checked: { secrets: checks.secrets ? staged.filter(isSecretScanTarget).length : 0, agents: agents.files, lockfiles: deps.files },
|
|
213
|
+
blockOn,
|
|
214
|
+
policyPath,
|
|
215
|
+
blocking: kept.filter((f) => isAtLeast(f.severity, blockOn)),
|
|
216
|
+
below: kept.filter((f) => !isAtLeast(f.severity, blockOn)),
|
|
217
|
+
suppressed,
|
|
218
|
+
warnings: sup.warnings,
|
|
219
|
+
duration: Date.now() - started,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One short "how to fix" line per finding. */
|
|
224
|
+
export function fixHint(f) {
|
|
225
|
+
if (f.category === "secrets") return "Load it from an env var or secret store and unstage the file; rotate it if it was ever pushed or shared.";
|
|
226
|
+
if (f.category === "license") return `Replace ${f.package}, or add it to "allowPackages" in the licenses section of penthera.policy.json.`;
|
|
227
|
+
if (f.category === "dependency-risk") return `Review it before committing the lockfile: \`penthera deps --base HEAD -v\` shows the evidence.`;
|
|
228
|
+
const first = String(f.description || "").replace(/\s*\[[^\]]*\]$/, "").replace(/\s*\(line \d+\)/, "").split(/(?<=\.)\s/)[0];
|
|
229
|
+
return first || "See `penthera agents` for details.";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Install / uninstall ───────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
const BIN = fileURLToPath(new URL("../../bin/penthera.js", import.meta.url));
|
|
235
|
+
const sq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
236
|
+
|
|
237
|
+
export function hookScript({ node = process.execPath, bin = BIN } = {}) {
|
|
238
|
+
return `#!/bin/sh
|
|
239
|
+
${HOOK_MARKER}: pre-commit hook installed by \`penthera hook install\`.
|
|
240
|
+
# Remove it with \`penthera hook uninstall\`, which restores any hook it replaced.
|
|
241
|
+
# Skip it for one commit with \`git commit --no-verify\`.
|
|
242
|
+
hook_dir=$(dirname "$0")
|
|
243
|
+
if [ -x "$hook_dir/${BACKUP_NAME}" ]; then
|
|
244
|
+
"$hook_dir/${BACKUP_NAME}" "$@" || exit $?
|
|
245
|
+
fi
|
|
246
|
+
if [ -f ${sq(bin)} ] && [ -x ${sq(node)} ]; then
|
|
247
|
+
exec ${sq(node)} ${sq(bin)} hook run
|
|
248
|
+
elif command -v penthera >/dev/null 2>&1; then
|
|
249
|
+
exec penthera hook run
|
|
250
|
+
fi
|
|
251
|
+
echo "penthera: not found, pre-commit check skipped (npm install -g penthera, then penthera hook install)" >&2
|
|
252
|
+
exit 0
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Where git looks for hooks (honours core.hooksPath and worktrees). */
|
|
257
|
+
export function hooksDir(root) {
|
|
258
|
+
return resolve(root, git(root, ["rev-parse", "--git-path", "hooks"]).trim());
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function hooksPathSetting(root) {
|
|
262
|
+
try { return git(root, ["config", "--get", "core.hooksPath"]).trim() || null; } catch { return null; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const isOurs = (path) => existsSync(path) && readFileSync(path, "utf-8").includes(HOOK_MARKER);
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Write .git/hooks/pre-commit. An existing hook that is not ours is kept as
|
|
269
|
+
* pre-commit.pre-penthera and runs first.
|
|
270
|
+
* @returns {{ path, chained: string|null, updated: boolean }}
|
|
271
|
+
*/
|
|
272
|
+
export function installHook(cwd, { force = false, script = hookScript() } = {}) {
|
|
273
|
+
const root = repoRoot(cwd);
|
|
274
|
+
const custom = hooksPathSetting(root);
|
|
275
|
+
if (custom && !force) {
|
|
276
|
+
const err = new Error(`core.hooksPath is set to "${custom}" (husky, lefthook or similar manage hooks here). Add \`penthera hook run\` to that tool's pre-commit config instead, or rerun with --force to write ${join(custom, "pre-commit")}.`);
|
|
277
|
+
err.code = "HOOKS_PATH";
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
const dir = hooksDir(root);
|
|
281
|
+
const path = join(dir, "pre-commit");
|
|
282
|
+
const backup = join(dir, BACKUP_NAME);
|
|
283
|
+
let updated = false;
|
|
284
|
+
if (existsSync(path)) {
|
|
285
|
+
if (isOurs(path)) updated = true;
|
|
286
|
+
else if (existsSync(backup)) throw new Error(`Both ${path} and ${backup} exist. Merge them by hand, then run \`penthera hook install\` again.`);
|
|
287
|
+
else renameSync(path, backup);
|
|
288
|
+
}
|
|
289
|
+
mkdirSync(dir, { recursive: true });
|
|
290
|
+
writeFileSync(path, script);
|
|
291
|
+
chmodSync(path, 0o755);
|
|
292
|
+
return { path, chained: existsSync(backup) ? backup : null, updated };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Remove our hook and put back the one it replaced.
|
|
297
|
+
* @returns {{ path, removed: boolean, restored: string|null }}
|
|
298
|
+
*/
|
|
299
|
+
export function uninstallHook(cwd) {
|
|
300
|
+
const root = repoRoot(cwd);
|
|
301
|
+
const dir = hooksDir(root);
|
|
302
|
+
const path = join(dir, "pre-commit");
|
|
303
|
+
const backup = join(dir, BACKUP_NAME);
|
|
304
|
+
if (!existsSync(path)) return { path, removed: false, restored: null };
|
|
305
|
+
if (!isOurs(path)) throw new Error(`${path} was not installed by penthera; left untouched.`);
|
|
306
|
+
unlinkSync(path);
|
|
307
|
+
if (existsSync(backup)) {
|
|
308
|
+
renameSync(backup, path);
|
|
309
|
+
return { path, removed: true, restored: path };
|
|
310
|
+
}
|
|
311
|
+
return { path, removed: true, restored: null };
|
|
312
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `hook` section of penthera.policy.json: what `penthera hook run` checks
|
|
3
|
+
* and what blocks a commit.
|
|
4
|
+
*
|
|
5
|
+
* "hook": {
|
|
6
|
+
* "blockOn": "high", // lowest severity that blocks: critical | high | medium | low
|
|
7
|
+
* "checks": { "secrets": true, "agents": true, "deps": true, "licenses": true },
|
|
8
|
+
* "ignore": ["tests/fixtures/**"] // staged paths the hook skips (globs)
|
|
9
|
+
* }
|
|
10
|
+
*/
|
|
11
|
+
export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
12
|
+
const CHECKS = ["secrets", "agents", "deps", "licenses"];
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_HOOK_POLICY = Object.freeze({
|
|
15
|
+
blockOn: "high",
|
|
16
|
+
checks: Object.freeze(Object.fromEntries(CHECKS.map((c) => [c, true]))),
|
|
17
|
+
ignore: Object.freeze([]),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export function normalizeHookPolicy(raw, label = "hook") {
|
|
21
|
+
if (raw == null) return { ...DEFAULT_HOOK_POLICY, checks: { ...DEFAULT_HOOK_POLICY.checks }, ignore: [] };
|
|
22
|
+
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected an object`);
|
|
23
|
+
const extra = Object.keys(raw).filter((k) => !["blockOn", "checks", "ignore"].includes(k));
|
|
24
|
+
if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected blockOn, checks, ignore)`);
|
|
25
|
+
const blockOn = raw.blockOn ?? "high";
|
|
26
|
+
if (!SEVERITIES.slice(0, 4).includes(blockOn)) throw new Error(`${label}: "blockOn" must be one of critical, high, medium, low`);
|
|
27
|
+
const checks = { ...DEFAULT_HOOK_POLICY.checks };
|
|
28
|
+
if (raw.checks != null) {
|
|
29
|
+
if (typeof raw.checks !== "object" || Array.isArray(raw.checks)) throw new Error(`${label}: "checks" must be an object`);
|
|
30
|
+
for (const [k, v] of Object.entries(raw.checks)) {
|
|
31
|
+
if (!CHECKS.includes(k)) throw new Error(`${label}: unknown check "${k}" (known: ${CHECKS.join(", ")})`);
|
|
32
|
+
if (typeof v !== "boolean") throw new Error(`${label}: checks.${k} must be true or false`);
|
|
33
|
+
checks[k] = v;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const ignore = raw.ignore ?? [];
|
|
37
|
+
if (!Array.isArray(ignore) || ignore.some((x) => typeof x !== "string" || !x.trim())) throw new Error(`${label}: "ignore" must be an array of path globs`);
|
|
38
|
+
return { blockOn, checks, ignore: ignore.map((x) => x.trim()) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Git-style path glob: `**` crosses folders, `*` and `?` stay inside one; a bare name matches at any depth. */
|
|
42
|
+
export function globToRegExp(glob) {
|
|
43
|
+
let g = glob.replace(/^\.\//, "");
|
|
44
|
+
if (!g.includes("/")) g = `**/${g}`;
|
|
45
|
+
if (g.endsWith("/")) g += "**";
|
|
46
|
+
let re = "";
|
|
47
|
+
for (let i = 0; i < g.length; i++) {
|
|
48
|
+
const ch = g[i];
|
|
49
|
+
if (ch === "*" && g[i + 1] === "*") {
|
|
50
|
+
i++;
|
|
51
|
+
if (g[i + 1] === "/") { i++; re += "(?:.*/)?"; } else re += ".*";
|
|
52
|
+
} else if (ch === "*") re += "[^/]*";
|
|
53
|
+
else if (ch === "?") re += "[^/]";
|
|
54
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
}
|
|
56
|
+
return new RegExp(`^${re}$`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isAtLeast(severity, threshold) {
|
|
60
|
+
const s = SEVERITIES.indexOf(severity);
|
|
61
|
+
return s !== -1 && s <= SEVERITIES.indexOf(threshold);
|
|
62
|
+
}
|