penthera 1.4.0 → 1.6.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +148 -575
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/compliance-cra.md +141 -0
  20. package/docs/compliance-nis2.md +3 -1
  21. package/docs/dashboard.md +244 -0
  22. package/docs/evidence.md +8 -4
  23. package/docs/examples/github-actions-deps.yml +4 -4
  24. package/docs/examples/github-actions-scan.yml +4 -4
  25. package/docs/index.md +11 -2
  26. package/docs/internal-audit.md +83 -0
  27. package/docs/marketing.md +151 -0
  28. package/docs/plan.md +101 -0
  29. package/docs/privacy.md +38 -0
  30. package/docs/references.bib +10 -0
  31. package/docs/references.md +1 -0
  32. package/docs/research/socket-positioning-notes.md +183 -0
  33. package/docs/use-cases/README.md +14 -0
  34. package/docs/use-cases/agencies.md +71 -0
  35. package/docs/use-cases/ai-coding-teams.md +81 -0
  36. package/docs/use-cases/eu-sme-compliance.md +68 -0
  37. package/docs/use-cases/open-source-maintainers.md +75 -0
  38. package/docs/use-cases/solo-founder.md +65 -0
  39. package/lib/blackbox/email-dns.js +5 -0
  40. package/lib/compliance/cra.js +525 -0
  41. package/lib/compliance/questionnaire.js +4 -2
  42. package/lib/deps/capabilities.js +49 -12
  43. package/lib/deps/index.js +314 -30
  44. package/lib/deps/license.js +329 -0
  45. package/lib/deps/lockfile.js +29 -3
  46. package/lib/deps/popular-pypi.json +1 -0
  47. package/lib/deps/python/capabilities.js +447 -0
  48. package/lib/deps/python/index.js +234 -0
  49. package/lib/deps/python/lockfiles.js +359 -0
  50. package/lib/deps/python/source.js +396 -0
  51. package/lib/deps/python/toml.js +219 -0
  52. package/lib/deps/report.js +54 -6
  53. package/lib/deps/source.js +149 -24
  54. package/lib/deps/spdx.json +1 -0
  55. package/lib/deps/typosquat.js +24 -13
  56. package/lib/fix/apply.js +83 -19
  57. package/lib/hook/index.js +345 -0
  58. package/lib/hook/policy.js +62 -0
  59. package/lib/install/index.js +59 -18
  60. package/lib/install/policy.js +27 -7
  61. package/lib/jurisdictions/nl.json +9 -0
  62. package/lib/memory/repo-suppress.js +11 -3
  63. package/lib/references.json +77 -0
  64. package/lib/remediation/index.js +9 -0
  65. package/lib/retirejs.js +69 -17
  66. package/lib/rules/regex-guard.js +368 -0
  67. package/lib/templates.js +13 -6
  68. package/lib/tls.js +7 -0
  69. package/lib/whitebox/aibom.js +384 -0
  70. package/lib/whitebox/frameworks.js +8 -5
  71. package/lib/whitebox/sbom.js +45 -10
  72. package/lib/whitebox/secrets.js +31 -8
  73. package/package.json +8 -3
  74. package/skills/penthera/SKILL.md +55 -8
  75. package/src/cli/deps.js +81 -6
  76. package/src/cli/evidence.js +14 -4
  77. package/src/cli/hook.js +131 -0
  78. package/src/cli/install.js +14 -5
  79. package/src/cli/report-outputs.js +25 -2
  80. package/src/cli/ui.js +89 -0
  81. package/src/cli.js +81 -11
  82. package/src/report/audience.js +10 -10
  83. package/src/report/evidence.js +33 -14
  84. package/src/report/footer.js +11 -0
  85. package/src/report/markdown.js +6 -3
  86. package/src/report/md-escape.js +16 -0
  87. package/src/reporter.js +1 -0
  88. package/src/scan-url.js +5 -1
  89. package/src/ui/api/data.js +309 -0
  90. package/src/ui/api/demo.js +464 -0
  91. package/src/ui/api/routes.js +134 -0
  92. package/src/ui/api/runs.js +243 -0
  93. package/src/ui/api/summarize.js +451 -0
  94. package/src/ui/security.js +153 -0
  95. package/src/ui/server.js +186 -0
  96. package/src/ui/static/app.css +723 -0
  97. package/src/ui/static/favicon.svg +1 -0
  98. package/src/ui/static/index.html +57 -0
  99. package/src/ui/static/js/api.js +29 -0
  100. package/src/ui/static/js/app.js +138 -0
  101. package/src/ui/static/js/charts.js +207 -0
  102. package/src/ui/static/js/copy.js +79 -0
  103. package/src/ui/static/js/dom.js +120 -0
  104. package/src/ui/static/js/format.js +42 -0
  105. package/src/ui/static/js/theme.js +10 -0
  106. package/src/ui/static/js/typer.js +99 -0
  107. package/src/ui/static/js/views/ai.js +68 -0
  108. package/src/ui/static/js/views/deps.js +96 -0
  109. package/src/ui/static/js/views/evidence.js +115 -0
  110. package/src/ui/static/js/views/findings.js +165 -0
  111. package/src/ui/static/js/views/history.js +92 -0
  112. package/src/ui/static/js/views/overview.js +184 -0
  113. package/src/ui/static/js/views/run.js +349 -0
  114. package/src/ui/static/js/views/settings.js +67 -0
@@ -0,0 +1,345 @@
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. That
12
+ * includes penthera.policy.json and penthera.suppress.json: an unstaged edit
13
+ * to either cannot loosen the check. Staged symlinks are never followed.
14
+ * Offline, no telemetry. Findings use the standard shape, so ids match a full
15
+ * scan and penthera.suppress.json works here too.
16
+ */
17
+ import { execFileSync, spawnSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join, posix, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { findSecretsInText, isSecretScanTarget, maskMatch } from "../whitebox/secrets.js";
23
+ import { loadPolicy, normalizePolicy, POLICY_FILE } from "../install/policy.js";
24
+ import { parseRepoSuppressions, REPO_SUPPRESS_FILE } from "../memory/repo-suppress.js";
25
+ import { findingId } from "../memory/views.js";
26
+ import { fingerprintFinding } from "../audit-loop.js";
27
+ import { globToRegExp, isAtLeast, SEVERITIES } from "./policy.js";
28
+
29
+ export const HOOK_MARKER = "# penthera-hook";
30
+ export const BACKUP_NAME = "pre-commit.pre-penthera";
31
+ const MAX_BLOB_BYTES = 2 * 1024 * 1024;
32
+
33
+ // ── git plumbing ──────────────────────────────────────────────────────────
34
+
35
+ function git(cwd, args, opts = {}) {
36
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, ...opts });
37
+ }
38
+
39
+ export function repoRoot(cwd) {
40
+ try {
41
+ return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
42
+ } catch {
43
+ throw new Error(`Not inside a git repository: ${cwd}`);
44
+ }
45
+ }
46
+
47
+ /** Paths added, copied, modified or renamed in the index (what this commit adds). */
48
+ export function stagedFiles(root) {
49
+ return git(root, ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR", "--no-renames"]).split("\0").filter(Boolean);
50
+ }
51
+
52
+ /** Read index blobs in one `git cat-file --batch` call. Returns Map(path → Buffer); large files are skipped. */
53
+ export function readStaged(root, paths) {
54
+ const out = new Map();
55
+ const wanted = paths.filter((p) => !p.includes("\n"));
56
+ if (!wanted.length) return out;
57
+ const r = spawnSync("git", ["cat-file", "--batch"], { cwd: root, input: wanted.map((p) => `:${p}\n`).join(""), maxBuffer: 1024 * 1024 * 1024 });
58
+ if (r.status !== 0) throw new Error(`git cat-file failed: ${String(r.stderr).trim()}`);
59
+ const buf = r.stdout;
60
+ let pos = 0;
61
+ for (const p of wanted) {
62
+ const nl = buf.indexOf(10, pos);
63
+ if (nl === -1) break;
64
+ const header = buf.subarray(pos, nl).toString();
65
+ pos = nl + 1;
66
+ const m = /^\S+ (\w+) (\d+)$/.exec(header);
67
+ if (!m) continue; // "<name> missing"
68
+ const size = Number(m[2]);
69
+ if (m[1] === "blob" && size <= MAX_BLOB_BYTES) out.set(p, buf.subarray(pos, pos + size));
70
+ pos += size + 1;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // ── Checks ────────────────────────────────────────────────────────────────
76
+
77
+ function lineOf(text, index) {
78
+ let n = 1;
79
+ for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++;
80
+ return n;
81
+ }
82
+
83
+ function checkSecrets(files, blobs) {
84
+ const findings = [];
85
+ for (const path of files) {
86
+ if (!isSecretScanTarget(path)) continue;
87
+ const buf = blobs.get(path);
88
+ if (!buf || buf.subarray(0, 8192).includes(0)) continue;
89
+ const text = buf.toString("utf-8");
90
+ for (const hit of findSecretsInText(text)) {
91
+ // Same title/url/category/source as the repo scan, so finding ids match.
92
+ findings.push({
93
+ severity: hit.severity,
94
+ title: `${hit.name} in source`,
95
+ description: `Possible secret in \`${path}\`: \`${maskMatch(hit.match)}\``,
96
+ url: path,
97
+ line: lineOf(text, text.indexOf(hit.match)),
98
+ evidence: maskMatch(hit.match),
99
+ category: "secrets",
100
+ source: "secret-scan",
101
+ });
102
+ }
103
+ }
104
+ return findings;
105
+ }
106
+
107
+ 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"]);
108
+ const AGENT_DIRS = [".claude/", ".cursor/", ".agents/", ".codex/", ".clinerules/", ".claude-plugin/", ".github/instructions/", "skills/", "plugins/"];
109
+
110
+ /** Is this repo path agent configuration that `penthera agents` reads? */
111
+ export function isAgentConfig(path) {
112
+ return AGENT_FILES.has(path) || AGENT_DIRS.some((d) => path.startsWith(d));
113
+ }
114
+
115
+ /**
116
+ * Agent rules on the staged view of the agent config. The whole agent config
117
+ * in the index is checked out to a temp folder (a skill is judged as a folder,
118
+ * not file by file); only findings that touch a staged path are kept.
119
+ */
120
+ async function checkAgents(root, staged) {
121
+ const touched = staged.filter(isAgentConfig);
122
+ if (!touched.length) return { findings: [], files: 0 };
123
+ // Regular files only (mode 100644/100755): a staged symlink would be checked
124
+ // out as a symlink and the scan would read whatever it points at.
125
+ const all = git(root, ["ls-files", "-z", "--cached", "--stage"]).split("\0")
126
+ .map((l) => /^(\d{6}) [0-9a-f]+ \d\t(.*)$/s.exec(l))
127
+ .filter((m) => m && (m[1] === "100644" || m[1] === "100755") && isAgentConfig(m[2]))
128
+ .map((m) => m[2]);
129
+ const tmp = realpathSync(mkdtempSync(join(tmpdir(), "penthera-hook-")));
130
+ try {
131
+ const r = spawnSync("git", ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { cwd: root, input: all.join("\0") });
132
+ if (r.status !== 0) throw new Error(`git checkout-index failed: ${String(r.stderr).trim()}`);
133
+ const { scanAgents } = await import("../agents/index.js");
134
+ const result = await scanAgents(tmp, { lockPath: join(tmp, ".penthera-hook-no-lock") });
135
+ const relates = (url) => touched.some((s) => url === s || s.startsWith(`${url}/`) || url.startsWith(`${s}/`));
136
+ const findings = result.findings
137
+ .filter((f) => !String(f.templateId).startsWith("agents/lock-") && relates(f.url))
138
+ .map((f) => {
139
+ const m = /\(line (\d+)\)/.exec(f.description || "");
140
+ return m ? { ...f, line: Number(m[1]) } : f;
141
+ });
142
+ return { findings, files: touched.length };
143
+ } finally {
144
+ rmSync(tmp, { recursive: true, force: true });
145
+ }
146
+ }
147
+
148
+ const EMPTY_LOCK = { lockfileVersion: 3, packages: {} };
149
+
150
+ /** Capability diff (and license policy) for each staged lockfile, against HEAD. */
151
+ async function checkLockfiles(root, staged, blobs, { licensePolicy }) {
152
+ const locks = staged.filter((p) => ["package-lock.json", "npm-shrinkwrap.json"].includes(posix.basename(p)));
153
+ if (!locks.length) return { findings: [], files: 0 };
154
+ const { scanDeps } = await import("../deps/index.js");
155
+ const findings = [];
156
+ for (const path of locks) {
157
+ const buf = blobs.get(path);
158
+ if (!buf) continue;
159
+ let lock;
160
+ try { lock = JSON.parse(buf.toString("utf-8")); } catch (e) {
161
+ findings.push({ severity: "high", title: `${path} is not valid JSON`, description: e.message, url: path, category: "dependency-risk", source: "deps-scan" });
162
+ continue;
163
+ }
164
+ let baseLock = EMPTY_LOCK;
165
+ try { baseLock = JSON.parse(git(root, ["show", `HEAD:${path}`])); } catch { /* new lockfile or first commit */ }
166
+ const dir = posix.dirname(path);
167
+ const result = await scanDeps(join(root, dir), { lock, baseLock, baseLabel: "HEAD", changedOnly: true, licensePolicy });
168
+ const prefix = dir === "." ? "" : `${dir}/`;
169
+ for (const f of result.findings) findings.push({ ...f, url: f.url ? `${prefix}${f.url}` : path });
170
+ }
171
+ return { findings, files: locks.length };
172
+ }
173
+
174
+ // ── Run ───────────────────────────────────────────────────────────────────
175
+
176
+ function suppressedBy(entries, f) {
177
+ if (!entries.length) return null;
178
+ const id = findingId(f);
179
+ const fp = fingerprintFinding(f);
180
+ return entries.find((e) => e.id === id || e.fingerprint === fp) || null;
181
+ }
182
+
183
+ 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);
184
+
185
+ /**
186
+ * Check what is staged. Never writes to the repo.
187
+ * @param {string} cwd anywhere inside the repo
188
+ * @param {{ policyPath?: string, now?: string }} opts
189
+ * @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
190
+ */
191
+ /** One file as staged, or null when it is not in the index. */
192
+ function stagedBlob(root, path) {
193
+ return readStaged(root, [path]).get(path) || null;
194
+ }
195
+
196
+ /** The policy: an explicit --policy file, else penthera.policy.json as staged, else defaults. */
197
+ function stagedPolicy(root, explicitPath) {
198
+ if (explicitPath) return loadPolicy(root, explicitPath);
199
+ const buf = stagedBlob(root, POLICY_FILE);
200
+ if (!buf) return { policy: normalizePolicy({}), path: null };
201
+ let raw;
202
+ try {
203
+ raw = JSON.parse(buf.toString("utf-8"));
204
+ } catch (e) {
205
+ throw new Error(`${POLICY_FILE} (staged): ${e.message}`);
206
+ }
207
+ return { policy: normalizePolicy(raw, `${POLICY_FILE} (staged)`), path: join(root, POLICY_FILE) };
208
+ }
209
+
210
+ export async function runHook(cwd, opts = {}) {
211
+ const started = Date.now();
212
+ const root = repoRoot(cwd);
213
+ const { policy, path: policyPath } = stagedPolicy(root, opts.policyPath || null);
214
+ const { blockOn, checks, ignore } = policy.hook;
215
+ const ignored = ignore.map(globToRegExp);
216
+ const staged = stagedFiles(root).filter((p) => !ignored.some((re) => re.test(p)));
217
+
218
+ const wantBlobs = staged.filter((p) => (checks.secrets && isSecretScanTarget(p)) || /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(p));
219
+ const blobs = readStaged(root, wantBlobs);
220
+
221
+ const secrets = checks.secrets ? checkSecrets(staged, blobs) : [];
222
+ const agents = checks.agents ? await checkAgents(root, staged) : { findings: [], files: 0 };
223
+ const deps = checks.deps ? await checkLockfiles(root, staged, blobs, { licensePolicy: checks.licenses ? policy.licenses : null }) : { findings: [], files: 0 };
224
+
225
+ const supBuf = stagedBlob(root, REPO_SUPPRESS_FILE);
226
+ const sup = supBuf ? parseRepoSuppressions(supBuf, { now: opts.now }) : { entries: [], warnings: [] };
227
+ for (const f of [POLICY_FILE, REPO_SUPPRESS_FILE]) {
228
+ if (f === POLICY_FILE && opts.policyPath) continue;
229
+ const disk = join(root, f);
230
+ const staged = f === POLICY_FILE ? policyPath : supBuf;
231
+ if (existsSync(disk) && !staged) sup.warnings.push(`${f} is not staged, so this check ignores it (git add it to apply it)`);
232
+ }
233
+ const all = [...secrets, ...agents.findings, ...deps.findings].map((f) => ({ ...f, id: findingId(f) }));
234
+ const suppressed = [];
235
+ const kept = [];
236
+ for (const f of all) {
237
+ const s = suppressedBy(sup.entries, f);
238
+ if (s) suppressed.push({ ...f, suppression: { reason: s.reason } });
239
+ else kept.push(f);
240
+ }
241
+ kept.sort(bySeverity);
242
+ return {
243
+ root,
244
+ staged: staged.length,
245
+ checked: { secrets: checks.secrets ? staged.filter(isSecretScanTarget).length : 0, agents: agents.files, lockfiles: deps.files },
246
+ blockOn,
247
+ policyPath,
248
+ blocking: kept.filter((f) => isAtLeast(f.severity, blockOn)),
249
+ below: kept.filter((f) => !isAtLeast(f.severity, blockOn)),
250
+ suppressed,
251
+ warnings: sup.warnings,
252
+ duration: Date.now() - started,
253
+ };
254
+ }
255
+
256
+ /** One short "how to fix" line per finding. */
257
+ export function fixHint(f) {
258
+ 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.";
259
+ if (f.category === "license") return `Replace ${f.package}, or add it to "allowPackages" in the licenses section of penthera.policy.json.`;
260
+ if (f.category === "dependency-risk") return `Review it before committing the lockfile: \`penthera deps --base HEAD -v\` shows the evidence.`;
261
+ const first = String(f.description || "").replace(/\s*\[[^\]]*\]$/, "").replace(/\s*\(line \d+\)/, "").split(/(?<=\.)\s/)[0];
262
+ return first || "See `penthera agents` for details.";
263
+ }
264
+
265
+ // ── Install / uninstall ───────────────────────────────────────────────────
266
+
267
+ const BIN = fileURLToPath(new URL("../../bin/penthera.js", import.meta.url));
268
+ const sq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
269
+
270
+ export function hookScript({ node = process.execPath, bin = BIN } = {}) {
271
+ return `#!/bin/sh
272
+ ${HOOK_MARKER}: pre-commit hook installed by \`penthera hook install\`.
273
+ # Remove it with \`penthera hook uninstall\`, which restores any hook it replaced.
274
+ # Skip it for one commit with \`git commit --no-verify\`.
275
+ hook_dir=$(dirname "$0")
276
+ if [ -x "$hook_dir/${BACKUP_NAME}" ]; then
277
+ "$hook_dir/${BACKUP_NAME}" "$@" || exit $?
278
+ fi
279
+ if [ -f ${sq(bin)} ] && [ -x ${sq(node)} ]; then
280
+ exec ${sq(node)} ${sq(bin)} hook run
281
+ elif command -v penthera >/dev/null 2>&1; then
282
+ exec penthera hook run
283
+ fi
284
+ echo "penthera: not found, pre-commit check skipped (npm install -g penthera, then penthera hook install)" >&2
285
+ exit 0
286
+ `;
287
+ }
288
+
289
+ /** Where git looks for hooks (honours core.hooksPath and worktrees). */
290
+ export function hooksDir(root) {
291
+ return resolve(root, git(root, ["rev-parse", "--git-path", "hooks"]).trim());
292
+ }
293
+
294
+ function hooksPathSetting(root) {
295
+ try { return git(root, ["config", "--get", "core.hooksPath"]).trim() || null; } catch { return null; }
296
+ }
297
+
298
+ const isOurs = (path) => existsSync(path) && readFileSync(path, "utf-8").includes(HOOK_MARKER);
299
+
300
+ /**
301
+ * Write .git/hooks/pre-commit. An existing hook that is not ours is kept as
302
+ * pre-commit.pre-penthera and runs first.
303
+ * @returns {{ path, chained: string|null, updated: boolean }}
304
+ */
305
+ export function installHook(cwd, { force = false, script = hookScript() } = {}) {
306
+ const root = repoRoot(cwd);
307
+ const custom = hooksPathSetting(root);
308
+ if (custom && !force) {
309
+ 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")}.`);
310
+ err.code = "HOOKS_PATH";
311
+ throw err;
312
+ }
313
+ const dir = hooksDir(root);
314
+ const path = join(dir, "pre-commit");
315
+ const backup = join(dir, BACKUP_NAME);
316
+ let updated = false;
317
+ if (existsSync(path)) {
318
+ if (isOurs(path)) updated = true;
319
+ else if (existsSync(backup)) throw new Error(`Both ${path} and ${backup} exist. Merge them by hand, then run \`penthera hook install\` again.`);
320
+ else renameSync(path, backup);
321
+ }
322
+ mkdirSync(dir, { recursive: true });
323
+ writeFileSync(path, script);
324
+ chmodSync(path, 0o755);
325
+ return { path, chained: existsSync(backup) ? backup : null, updated };
326
+ }
327
+
328
+ /**
329
+ * Remove our hook and put back the one it replaced.
330
+ * @returns {{ path, removed: boolean, restored: string|null }}
331
+ */
332
+ export function uninstallHook(cwd) {
333
+ const root = repoRoot(cwd);
334
+ const dir = hooksDir(root);
335
+ const path = join(dir, "pre-commit");
336
+ const backup = join(dir, BACKUP_NAME);
337
+ if (!existsSync(path)) return { path, removed: false, restored: null };
338
+ if (!isOurs(path)) throw new Error(`${path} was not installed by penthera; left untouched.`);
339
+ unlinkSync(path);
340
+ if (existsSync(backup)) {
341
+ renameSync(backup, path);
342
+ return { path, removed: true, restored: path };
343
+ }
344
+ return { path, removed: true, restored: null };
345
+ }
@@ -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
+ }
@@ -5,16 +5,19 @@
5
5
  * in a temporary copy of package.json / package-lock.json / .npmrc. Not in
6
6
  * place: npm rewrites node_modules/.package-lock.json even in lock-only
7
7
  * mode, and an abort must leave the project byte for byte as it was.
8
- * 2. Fetch. `npm cache add` puts each new tarball in npm's cache; nothing is
9
- * unpacked into node_modules and no package code runs. The deps engine
8
+ * 2. Fetch. `npm cache add --ignore-scripts` puts each new tarball in npm's
9
+ * cache; nothing is unpacked into node_modules and no package code runs
10
+ * (git dependencies would run `prepare` here without --ignore-scripts, so
11
+ * they are refused unless --allow-non-registry is passed). The deps engine
10
12
  * reads tarballs back from the cache only after verifying them against
11
13
  * the proposed lockfile's integrity (lib/deps/source.js).
12
14
  * 3. Diff. lib/deps scans the proposed tree against the current lockfile:
13
15
  * new packages, capabilities gained, install scripts, typosquats.
14
16
  * 4. Decide. lib/install/policy.js turns that into deny / needs-approval.
15
17
  * 5. Apply (only when allowed). The vetted package.json and lockfile are
16
- * written into the project and `npm install` installs exactly that tree,
17
- * with `--ignore-scripts` unless the new install scripts were approved.
18
+ * written into the project and `npm ci` installs exactly that tree (it
19
+ * fails rather than re-resolve), with `--ignore-scripts` unless the new
20
+ * install scripts were approved.
18
21
  *
19
22
  * Penthera makes no network calls here. npm talks to its configured registry
20
23
  * (resolve and fetch); that is the one expected egress.
@@ -35,6 +38,8 @@ const LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
35
38
  const COPY = ["package.json", ...LOCKFILES, ".npmrc"];
36
39
  const QUIET = ["--no-audit", "--no-fund", "--no-update-notifier"];
37
40
  const LOCAL_SPEC = /^(file:|link:|portal:|\.{1,2}\/|\/)/;
41
+ // Specs npm resolves outside the registry: git, tarball URLs, hosted-git shorthands.
42
+ const REMOTE_SPEC = /^(?:git\+|git:|github:|gitlab:|bitbucket:|gist:|https?:|ssh:)|^[^@][^/@:]*\/[^/]+$|^[^@]+@(?:git\+|git:|github:|gitlab:|bitbucket:|gist:|https?:|ssh:|file:)|\.(?:tgz|tar\.gz|tar)$/i;
38
43
 
39
44
  async function npm(args, opts) {
40
45
  try {
@@ -45,26 +50,50 @@ async function npm(args, opts) {
45
50
  }
46
51
  }
47
52
 
48
- /** npm's version and registry, as npm sees them from the project. */
53
+ /** npm's version and registries (default and @scope:registry), as npm sees them from the project. */
49
54
  export async function npmInfo(root, env = process.env) {
50
- const [version, registry] = await Promise.all([
55
+ const [version, config] = await Promise.all([
51
56
  npm(["--version"], { cwd: root, env }).then((r) => r.stdout.trim()),
52
- npm(["config", "get", "registry"], { cwd: root, env }).then((r) => r.stdout.trim(), () => "the configured npm registry"),
57
+ npm(["config", "list", "--json"], { cwd: root, env }).then((r) => JSON.parse(r.stdout), () => ({})),
53
58
  ]);
59
+ const registry = typeof config.registry === "string" ? config.registry : "the configured npm registry";
60
+ const registries = Object.entries(config)
61
+ .filter(([k, v]) => (k === "registry" || /^@[^:]+:registry$/.test(k)) && typeof v === "string")
62
+ .map(([, v]) => v);
54
63
  const major = parseInt(version, 10) || 0;
55
64
  // npm 12 blocks dependency install scripts unless package.json allowScripts lists them.
56
- return { version, major, registry, allowScriptsPolicy: major >= 12 };
65
+ return { version, major, registry, registries, allowScriptsPolicy: major >= 12 };
66
+ }
67
+
68
+ const origin = (u) => {
69
+ try {
70
+ const x = new URL(u);
71
+ return /^https?:$/.test(x.protocol) ? x.origin : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ /**
78
+ * Lockfile entries that would come from somewhere other than a configured
79
+ * registry: git repositories, tarball URLs on other hosts. Their contents are
80
+ * not what a registry name and version promise, and git ones run `prepare`.
81
+ */
82
+ export function nonRegistryPackages(pkgs, registries) {
83
+ const allowed = new Set((registries || []).map(origin).filter(Boolean));
84
+ return pkgs.filter((p) => p.resolved && !p.link && !allowed.has(origin(p.resolved)));
57
85
  }
58
86
 
59
87
  const key = (p) => `${p.path}@${p.version}#${p.integrity || ""}`;
60
88
  const scriptsOf = (s) => (s && Object.keys(s).length ? s : null);
61
89
 
62
90
  /** The rule ids a change raises (see lib/install/policy.js). */
63
- function flagsFor(caps, { newScripts, typosquat, unscanned }) {
91
+ function flagsFor(caps, { newScripts, typosquat, unscanned, scriptMismatch }) {
64
92
  const flags = [...(caps || [])].filter((id) => id !== "install-scripts");
65
93
  if (newScripts) flags.unshift("install-scripts");
66
94
  if (typosquat) flags.push("typosquat");
67
95
  if (unscanned) flags.push("unscanned");
96
+ if (scriptMismatch) flags.push("script-mismatch");
68
97
  return flags;
69
98
  }
70
99
 
@@ -89,7 +118,8 @@ export function changesFromDiff(diff, typosquats = [], packages = []) {
89
118
  capabilities: a.capabilities || [],
90
119
  installScripts: scripts,
91
120
  typosquatOf: typo.get(a.name)?.target || null,
92
- flags: flagsFor(a.capabilities, { newScripts: !!scripts, typosquat: typo.has(a.name), unscanned: a.capabilities === null }),
121
+ unscanned: a.unscanned || null,
122
+ flags: flagsFor(a.capabilities, { newScripts: !!scripts, typosquat: typo.has(a.name), unscanned: a.capabilities === null, scriptMismatch: !!a.scriptMismatch }),
93
123
  });
94
124
  }
95
125
  for (const u of diff.upgraded) {
@@ -109,7 +139,8 @@ export function changesFromDiff(diff, typosquats = [], packages = []) {
109
139
  previousInstallScripts: before,
110
140
  compared: u.unavailable == null,
111
141
  typosquatOf: null,
112
- flags: flagsFor(gained, { newScripts, typosquat: false, unscanned: u.unavailable === "new" }),
142
+ unscanned: u.unscanned || null,
143
+ flags: flagsFor(gained, { newScripts, typosquat: false, unscanned: u.unavailable === "new" || !!u.unscanned, scriptMismatch: !!u.scriptMismatch }),
113
144
  });
114
145
  }
115
146
  return out;
@@ -143,6 +174,10 @@ export async function planInstall(root, specs, opts = {}) {
143
174
  checkProject(root, JSON.parse(readFileSync(pkgPath, "utf-8")));
144
175
  const bad = specs.filter((s) => s.startsWith("-") || LOCAL_SPEC.test(s));
145
176
  if (bad.length) throw new Error(`Unsupported package spec(s): ${bad.join(", ")}. Pass registry names (name, name@version, name@range).`);
177
+ const remote = specs.filter((s) => REMOTE_SPEC.test(s));
178
+ if (remote.length && !opts.allowNonRegistry) {
179
+ throw new Error(`Non-registry package spec(s): ${remote.join(", ")}. Git and tarball-URL installs bypass the registry's name and version, and git ones run \`prepare\` while being fetched. Pass --allow-non-registry if you mean it.`);
180
+ }
146
181
 
147
182
  const info = opts.info || await npmInfo(root, env);
148
183
  const original = Object.fromEntries(COPY.map((f) => [f, readIfExists(join(root, f))]));
@@ -171,6 +206,10 @@ export async function planInstall(root, specs, opts = {}) {
171
206
  }
172
207
  const oldKeys = new Set(parseLockfile(JSON.parse(readFileSync(baseLockPath, "utf-8"))).map(key));
173
208
  const fresh = parseLockfile(proposedLock).filter((p) => !p.link && p.version && !oldKeys.has(key(p)));
209
+ const offRegistry = nonRegistryPackages(fresh, info.registries || [info.registry]);
210
+ if (offRegistry.length && !opts.allowNonRegistry) {
211
+ throw new Error(`The resolved tree pulls ${offRegistry.length} package(s) from outside the configured registry: ${offRegistry.slice(0, 5).map((p) => `${p.name} (${p.resolved})`).join(", ")}. Nothing was fetched or installed. Pass --allow-non-registry to review them anyway.`);
212
+ }
174
213
 
175
214
  // New tarballs go into a private npm cache (npm cache add): nothing is
176
215
  // unpacked or run. Private because npm will not reliably say where its own
@@ -182,11 +221,11 @@ export async function planInstall(root, specs, opts = {}) {
182
221
  if (toFetch.length) {
183
222
  onPhase(`Fetching ${toFetch.length} tarball(s) with npm cache add (nothing is unpacked or run)`);
184
223
  const fetchSpecs = [...new Set(toFetch.map((p) => (p.resolved && !/^https?:/.test(p.resolved) ? p.resolved : `${p.name}@${p.version}`)))];
185
- await npm(["cache", "add", "--cache", fetchCache, ...(opts.offline ? ["--offline"] : []), ...fetchSpecs], { cwd: tmp, env });
224
+ await npm(["cache", "add", "--ignore-scripts", ...QUIET, "--cache", fetchCache, ...(opts.offline ? ["--offline"] : []), ...fetchSpecs], { cwd: tmp, env });
186
225
  }
187
226
 
188
227
  onPhase("Comparing capabilities with the current tree (offline, integrity-verified)");
189
- const result = await scanDeps(work, { diff: baseLockPath, treeRoot: root, cacheDir: [fetchCache, userCache] });
228
+ const result = await scanDeps(work, { diff: baseLockPath, treeRoot: root, cacheDir: [fetchCache, userCache], ecosystem: "npm" });
190
229
  const changes = changesFromDiff(result.deps.diff, result.deps.typosquats, result.deps.packages);
191
230
  const verdict = evaluatePolicy(changes, opts.policy || normalizePolicy({}));
192
231
 
@@ -242,8 +281,10 @@ function restore(root, original) {
242
281
 
243
282
  /**
244
283
  * Install the vetted tree. Writes the proposed package.json and lockfile,
245
- * then runs `npm install` with no package arguments, so npm installs exactly
246
- * the lockfile that was reviewed. Restores both files if npm fails.
284
+ * then runs `npm ci`, which installs exactly that lockfile and fails instead
285
+ * of re-resolving, so what runs is what was reviewed (`npm install` could
286
+ * resolve something new and only be caught afterwards, once its scripts ran).
287
+ * Restores both files if npm fails.
247
288
  * @param {{ runScripts: boolean, env?: object, stdio?: string, offline?: boolean }} opts
248
289
  */
249
290
  export async function applyInstall(plan, opts) {
@@ -255,15 +296,15 @@ export async function applyInstall(plan, opts) {
255
296
 
256
297
  writeFileSync(join(root, "package.json"), pkgJson);
257
298
  writeFileSync(join(root, plan.lockName), plan.proposed[plan.lockName]);
258
- const args = ["install", ...QUIET, ...(opts.runScripts ? [] : ["--ignore-scripts"]), ...(opts.offline ? ["--offline"] : [])];
299
+ const args = ["ci", ...QUIET, ...(opts.runScripts ? [] : ["--ignore-scripts"]), ...(opts.offline ? ["--offline"] : [])];
259
300
  try {
260
301
  await npm(args, { cwd: root, env });
261
302
  } catch (e) {
262
303
  restore(root, original);
263
- throw new Error(`${e.message}\npackage.json and ${plan.lockName} were restored; node_modules may be partly updated (run \`npm ci\` to reset it).`);
304
+ throw new Error(`${e.message}\npackage.json and ${plan.lockName} were restored; node_modules may be partly updated (run \`npm ci --ignore-scripts\` to reset it).`);
264
305
  }
265
306
 
266
- // npm should have installed the reviewed lockfile as is.
307
+ // npm ci never rewrites the lockfile; check anyway.
267
308
  const want = new Set(parseLockfile(JSON.parse(plan.proposed[plan.lockName])).map(key));
268
309
  const got = parseLockfile(JSON.parse(readFileSync(join(root, plan.lockName), "utf-8"))).map(key);
269
310
  const drift = got.filter((k) => !want.has(k));
@@ -8,25 +8,42 @@
8
8
  * }
9
9
  *
10
10
  * `deny` and `requireApproval` list capability ids from lib/deps/capabilities.js,
11
- * plus two of the firewall's own: `typosquat` (a new name close to a popular
12
- * package) and `unscanned` (a new package whose files could not be read).
11
+ * plus three of the firewall's own: `typosquat` (a new name close to a popular
12
+ * package), `unscanned` (a new package whose files could not be read safely)
13
+ * and `script-mismatch` (the lockfile says the package has an install script
14
+ * but the scan found none: the scanner and npm disagree about its files).
15
+ *
16
+ * Two floors no policy lowers, not even an allow entry: `script-mismatch` is
17
+ * always denied, and `unscanned` always needs at least an approval. Both mean
18
+ * Penthera could not see what would run, so "clean" would be a guess.
13
19
  * `allow` lists package names or globs (`*` matches any run of characters),
14
20
  * optionally pinned to one version with `@x.y.z`; an allowed package passes
15
21
  * both lists. A field left out keeps its default; a field given replaces it.
16
22
  * Everything here is pure: no files are read except the policy itself.
23
+ *
24
+ * The same file has two optional sections for other commands: `licenses`
25
+ * (penthera deps, lib/deps/license.js) and `hook` (penthera hook run,
26
+ * lib/hook/policy.js). Both come back as null / defaults when absent.
17
27
  */
18
28
  import { existsSync, readFileSync } from "node:fs";
19
29
  import { join } from "node:path";
20
30
  import { CAPABILITY_ORDER, CAPABILITIES } from "../deps/capabilities.js";
31
+ import { normalizeLicensePolicy } from "../deps/license.js";
32
+ import { normalizeHookPolicy } from "../hook/policy.js";
21
33
 
22
34
  export const POLICY_FILE = "penthera.policy.json";
23
35
 
24
- export const RULE_IDS = [...CAPABILITY_ORDER, "typosquat", "unscanned"];
36
+ export const RULE_IDS = [...CAPABILITY_ORDER, "typosquat", "unscanned", "script-mismatch"];
37
+
38
+ /** Floors the policy cannot lower (see the header). */
39
+ export const ALWAYS_DENY = Object.freeze(["script-mismatch"]);
40
+ export const ALWAYS_APPROVE = Object.freeze(["unscanned"]);
25
41
 
26
42
  export const RULE_LABELS = {
27
43
  ...Object.fromEntries(CAPABILITY_ORDER.map((id) => [id, CAPABILITIES[id].label])),
28
44
  typosquat: "looks like a popular package",
29
45
  unscanned: "could not be scanned",
46
+ "script-mismatch": "lockfile lists an install script the scan did not find",
30
47
  };
31
48
 
32
49
  export const DEFAULT_POLICY = Object.freeze({
@@ -41,9 +58,9 @@ export const DEFAULT_POLICY = Object.freeze({
41
58
  */
42
59
  export function normalizePolicy(raw, label = POLICY_FILE) {
43
60
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected a JSON object`);
44
- const known = ["allow", "deny", "requireApproval", "$schema", "description"];
61
+ const known = ["allow", "deny", "requireApproval", "licenses", "hook", "$schema", "description"];
45
62
  const extra = Object.keys(raw).filter((k) => !known.includes(k));
46
- if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected allow, deny, requireApproval)`);
63
+ if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected allow, deny, requireApproval, licenses, hook)`);
47
64
  const out = {};
48
65
  for (const field of ["allow", "deny", "requireApproval"]) {
49
66
  const v = raw[field] ?? DEFAULT_POLICY[field];
@@ -56,6 +73,8 @@ export function normalizePolicy(raw, label = POLICY_FILE) {
56
73
  const bad = out[field].filter((id) => !RULE_IDS.includes(id));
57
74
  if (bad.length) throw new Error(`${label}: unknown capability in "${field}": ${bad.join(", ")}. Known: ${RULE_IDS.join(", ")}`);
58
75
  }
76
+ out.licenses = raw.licenses == null ? null : normalizeLicensePolicy(raw.licenses, `${label}: licenses`);
77
+ out.hook = normalizeHookPolicy(raw.hook, `${label}: hook`);
59
78
  return out;
60
79
  }
61
80
 
@@ -107,8 +126,9 @@ export function matchAllow(allow, name, version) {
107
126
  export function evaluatePolicy(changes, policy) {
108
127
  const items = changes.map((c) => {
109
128
  const allowedBy = matchAllow(policy.allow, c.name, c.version);
110
- const deny = allowedBy ? [] : c.flags.filter((id) => policy.deny.includes(id));
111
- const approval = allowedBy ? [] : c.flags.filter((id) => policy.requireApproval.includes(id) && !deny.includes(id));
129
+ const deny = c.flags.filter((id) => ALWAYS_DENY.includes(id) || (!allowedBy && policy.deny.includes(id)));
130
+ const approval = c.flags.filter((id) => !deny.includes(id)
131
+ && (ALWAYS_APPROVE.includes(id) || (!allowedBy && policy.requireApproval.includes(id))));
112
132
  return { ...c, allowedBy, deny, approval };
113
133
  });
114
134
  const denied = items.filter((i) => i.deny.length);