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.
- package/CHANGELOG.md +227 -0
- package/README.md +148 -575
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +11 -2
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +101 -0
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +314 -30
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +29 -3
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +54 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +345 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +27 -7
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +31 -8
- package/package.json +8 -3
- package/skills/penthera/SKILL.md +55 -8
- package/src/cli/deps.js +81 -6
- package/src/cli/evidence.js +14 -4
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +81 -11
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- 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
|
+
}
|
package/lib/install/index.js
CHANGED
|
@@ -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
|
|
9
|
-
* unpacked into node_modules and no package code runs
|
|
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
|
|
17
|
-
* with `--ignore-scripts` unless the new
|
|
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,
|
|
55
|
+
const [version, config] = await Promise.all([
|
|
51
56
|
npm(["--version"], { cwd: root, env }).then((r) => r.stdout.trim()),
|
|
52
|
-
npm(["config", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
*
|
|
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 = ["
|
|
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
|
|
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));
|
package/lib/install/policy.js
CHANGED
|
@@ -8,25 +8,42 @@
|
|
|
8
8
|
* }
|
|
9
9
|
*
|
|
10
10
|
* `deny` and `requireApproval` list capability ids from lib/deps/capabilities.js,
|
|
11
|
-
* plus
|
|
12
|
-
* package)
|
|
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 =
|
|
111
|
-
const approval =
|
|
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);
|