penthera 1.5.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 +170 -0
- package/README.md +128 -663
- 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/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 +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- 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/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- 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 +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- 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/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- 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 +19 -7
- 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
package/lib/hook/index.js
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* plus the license policy when penthera.policy.json has one
|
|
9
9
|
*
|
|
10
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.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
14
16
|
*/
|
|
15
17
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
16
18
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
@@ -18,8 +20,8 @@ import { tmpdir } from "node:os";
|
|
|
18
20
|
import { join, posix, resolve } from "node:path";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
20
22
|
import { findSecretsInText, isSecretScanTarget, maskMatch } from "../whitebox/secrets.js";
|
|
21
|
-
import { loadPolicy } from "../install/policy.js";
|
|
22
|
-
import {
|
|
23
|
+
import { loadPolicy, normalizePolicy, POLICY_FILE } from "../install/policy.js";
|
|
24
|
+
import { parseRepoSuppressions, REPO_SUPPRESS_FILE } from "../memory/repo-suppress.js";
|
|
23
25
|
import { findingId } from "../memory/views.js";
|
|
24
26
|
import { fingerprintFinding } from "../audit-loop.js";
|
|
25
27
|
import { globToRegExp, isAtLeast, SEVERITIES } from "./policy.js";
|
|
@@ -118,7 +120,12 @@ export function isAgentConfig(path) {
|
|
|
118
120
|
async function checkAgents(root, staged) {
|
|
119
121
|
const touched = staged.filter(isAgentConfig);
|
|
120
122
|
if (!touched.length) return { findings: [], files: 0 };
|
|
121
|
-
|
|
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]);
|
|
122
129
|
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "penthera-hook-")));
|
|
123
130
|
try {
|
|
124
131
|
const r = spawnSync("git", ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { cwd: root, input: all.join("\0") });
|
|
@@ -181,10 +188,29 @@ const bySeverity = (a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf
|
|
|
181
188
|
* @param {{ policyPath?: string, now?: string }} opts
|
|
182
189
|
* @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
|
|
183
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
|
+
|
|
184
210
|
export async function runHook(cwd, opts = {}) {
|
|
185
211
|
const started = Date.now();
|
|
186
212
|
const root = repoRoot(cwd);
|
|
187
|
-
const { policy, path: policyPath } =
|
|
213
|
+
const { policy, path: policyPath } = stagedPolicy(root, opts.policyPath || null);
|
|
188
214
|
const { blockOn, checks, ignore } = policy.hook;
|
|
189
215
|
const ignored = ignore.map(globToRegExp);
|
|
190
216
|
const staged = stagedFiles(root).filter((p) => !ignored.some((re) => re.test(p)));
|
|
@@ -196,7 +222,14 @@ export async function runHook(cwd, opts = {}) {
|
|
|
196
222
|
const agents = checks.agents ? await checkAgents(root, staged) : { findings: [], files: 0 };
|
|
197
223
|
const deps = checks.deps ? await checkLockfiles(root, staged, blobs, { licensePolicy: checks.licenses ? policy.licenses : null }) : { findings: [], files: 0 };
|
|
198
224
|
|
|
199
|
-
const
|
|
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
|
+
}
|
|
200
233
|
const all = [...secrets, ...agents.findings, ...deps.findings].map((f) => ({ ...f, id: findingId(f) }));
|
|
201
234
|
const suppressed = [];
|
|
202
235
|
const kept = [];
|
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,8 +8,14 @@
|
|
|
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.
|
|
@@ -27,12 +33,17 @@ import { normalizeHookPolicy } from "../hook/policy.js";
|
|
|
27
33
|
|
|
28
34
|
export const POLICY_FILE = "penthera.policy.json";
|
|
29
35
|
|
|
30
|
-
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"]);
|
|
31
41
|
|
|
32
42
|
export const RULE_LABELS = {
|
|
33
43
|
...Object.fromEntries(CAPABILITY_ORDER.map((id) => [id, CAPABILITIES[id].label])),
|
|
34
44
|
typosquat: "looks like a popular package",
|
|
35
45
|
unscanned: "could not be scanned",
|
|
46
|
+
"script-mismatch": "lockfile lists an install script the scan did not find",
|
|
36
47
|
};
|
|
37
48
|
|
|
38
49
|
export const DEFAULT_POLICY = Object.freeze({
|
|
@@ -115,8 +126,9 @@ export function matchAllow(allow, name, version) {
|
|
|
115
126
|
export function evaluatePolicy(changes, policy) {
|
|
116
127
|
const items = changes.map((c) => {
|
|
117
128
|
const allowedBy = matchAllow(policy.allow, c.name, c.version);
|
|
118
|
-
const deny =
|
|
119
|
-
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))));
|
|
120
132
|
return { ...c, allowedBy, deny, approval };
|
|
121
133
|
});
|
|
122
134
|
const denied = items.filter((i) => i.deny.length);
|
|
@@ -25,15 +25,23 @@ const MAX_ENTRIES = 500;
|
|
|
25
25
|
* entries are dropped with a warning, never guessed at.
|
|
26
26
|
*/
|
|
27
27
|
export function loadRepoSuppressions(repoPath, { now = new Date().toISOString() } = {}) {
|
|
28
|
-
const warnings = [];
|
|
29
28
|
const path = join(repoPath, REPO_SUPPRESS_FILE);
|
|
30
|
-
if (!existsSync(path)) return { entries: [], warnings, path: null };
|
|
29
|
+
if (!existsSync(path)) return { entries: [], warnings: [], path: null };
|
|
31
30
|
const st = statSync(path);
|
|
32
31
|
if (!st.isFile()) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is not a regular file, ignored`], path };
|
|
33
32
|
if (st.size > MAX_BYTES) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is larger than 64 KB, ignored`], path };
|
|
33
|
+
return parseRepoSuppressions(readFileSync(path), { now, path });
|
|
34
|
+
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Validate the file's bytes (from disk, or from the git index for the
|
|
38
|
+
* pre-commit hook). Same rules and result shape as loadRepoSuppressions().
|
|
39
|
+
*/
|
|
40
|
+
export function parseRepoSuppressions(buf, { now = new Date().toISOString(), path = REPO_SUPPRESS_FILE } = {}) {
|
|
41
|
+
const warnings = [];
|
|
42
|
+
if (buf.length > MAX_BYTES) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is larger than 64 KB, ignored`], path };
|
|
35
43
|
let doc;
|
|
36
|
-
try { doc = JSON.parse(
|
|
44
|
+
try { doc = JSON.parse(buf.toString("utf-8")); } catch (e) {
|
|
37
45
|
return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is not valid JSON (${e.message}), ignored`], path };
|
|
38
46
|
}
|
|
39
47
|
if (!doc || typeof doc !== "object" || doc.version !== 1 || !Array.isArray(doc.suppress)) {
|
package/lib/references.json
CHANGED
|
@@ -127,6 +127,78 @@
|
|
|
127
127
|
],
|
|
128
128
|
"attack-chain": [
|
|
129
129
|
"shostack2014threat"
|
|
130
|
+
],
|
|
131
|
+
"dependency-risk": [
|
|
132
|
+
"ladisa2023sok",
|
|
133
|
+
"ohm2020backstabber",
|
|
134
|
+
"zimmermann2019small"
|
|
135
|
+
],
|
|
136
|
+
"license": [
|
|
137
|
+
"spdxlicenselist"
|
|
138
|
+
],
|
|
139
|
+
"agent-config": [
|
|
140
|
+
"owasp2025agentic",
|
|
141
|
+
"owasp2025mcptop10",
|
|
142
|
+
"greshake2023signed"
|
|
143
|
+
],
|
|
144
|
+
"machine": [
|
|
145
|
+
"scarfone2008sp800115",
|
|
146
|
+
"saltzer1975protection"
|
|
147
|
+
],
|
|
148
|
+
"machine-integrity": [
|
|
149
|
+
"scarfone2008sp800115",
|
|
150
|
+
"thompson1984reflections"
|
|
151
|
+
],
|
|
152
|
+
"machine-config": [
|
|
153
|
+
"scarfone2008sp800115",
|
|
154
|
+
"saltzer1975protection"
|
|
155
|
+
],
|
|
156
|
+
"suspicious-process": [
|
|
157
|
+
"scarfone2008sp800115"
|
|
158
|
+
],
|
|
159
|
+
"ssh-config": [
|
|
160
|
+
"scarfone2008sp800115",
|
|
161
|
+
"saltzer1975protection"
|
|
162
|
+
],
|
|
163
|
+
"rootkit": [
|
|
164
|
+
"scarfone2008sp800115",
|
|
165
|
+
"thompson1984reflections"
|
|
166
|
+
],
|
|
167
|
+
"persistence": [
|
|
168
|
+
"scarfone2008sp800115"
|
|
169
|
+
],
|
|
170
|
+
"network": [
|
|
171
|
+
"scarfone2008sp800115"
|
|
172
|
+
],
|
|
173
|
+
"system-commands": [
|
|
174
|
+
"scarfone2008sp800115"
|
|
175
|
+
],
|
|
176
|
+
"suspicious-files": [
|
|
177
|
+
"scarfone2008sp800115"
|
|
178
|
+
],
|
|
179
|
+
"rkhunter": [
|
|
180
|
+
"scarfone2008sp800115"
|
|
181
|
+
],
|
|
182
|
+
"permissions": [
|
|
183
|
+
"scarfone2008sp800115",
|
|
184
|
+
"saltzer1975protection"
|
|
185
|
+
],
|
|
186
|
+
"malware": [
|
|
187
|
+
"scarfone2008sp800115"
|
|
188
|
+
],
|
|
189
|
+
"kernel": [
|
|
190
|
+
"scarfone2008sp800115",
|
|
191
|
+
"saltzer1975protection"
|
|
192
|
+
],
|
|
193
|
+
"hidden-files": [
|
|
194
|
+
"scarfone2008sp800115"
|
|
195
|
+
],
|
|
196
|
+
"code-signing": [
|
|
197
|
+
"scarfone2008sp800115",
|
|
198
|
+
"thompson1984reflections"
|
|
199
|
+
],
|
|
200
|
+
"browser-extensions": [
|
|
201
|
+
"scarfone2008sp800115"
|
|
130
202
|
]
|
|
131
203
|
},
|
|
132
204
|
"sources": {
|
|
@@ -369,6 +441,11 @@
|
|
|
369
441
|
"cite": "MITRE CWE",
|
|
370
442
|
"title": "CWE: Common Weakness Enumeration",
|
|
371
443
|
"url": "https://cwe.mitre.org/"
|
|
444
|
+
},
|
|
445
|
+
"spdxlicenselist": {
|
|
446
|
+
"cite": "SPDX License List",
|
|
447
|
+
"title": "SPDX License List",
|
|
448
|
+
"url": "https://spdx.org/licenses/"
|
|
372
449
|
}
|
|
373
450
|
}
|
|
374
451
|
}
|
package/lib/remediation/index.js
CHANGED
|
@@ -111,6 +111,15 @@ const RULES = [
|
|
|
111
111
|
effort: "quick",
|
|
112
112
|
ownerAction: false,
|
|
113
113
|
},
|
|
114
|
+
{
|
|
115
|
+
id: "license",
|
|
116
|
+
match: (f) => f.category === "license",
|
|
117
|
+
action: "Replace or approve the dependency whose license breaks your policy",
|
|
118
|
+
why: "A dependency under a license your policy does not allow (strong copyleft such as AGPL in a closed product, or no license at all, which means no rights to use it) is a legal exposure that ships with every release.",
|
|
119
|
+
fix: "Swap the package for one under an allowed license, or, if legal has reviewed it, add it to `allowPackages` in the `licenses` section of penthera.policy.json with a reason. An UNLICENSED or missing license needs the author to publish one; until then treat it as not usable.",
|
|
120
|
+
effort: "moderate",
|
|
121
|
+
ownerAction: true, // a legal call, not a code change
|
|
122
|
+
},
|
|
114
123
|
{
|
|
115
124
|
id: "dependency-risk",
|
|
116
125
|
match: (f) => f.category === "dependency-risk",
|
package/lib/retirejs.js
CHANGED
|
@@ -139,31 +139,83 @@ function extractVersion(text, patterns) {
|
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* Extract <script src="..."> URLs and inline script blocks from HTML.
|
|
142
|
+
*
|
|
143
|
+
* A small tokenizer rather than regexes: the HTML comes from the scanned
|
|
144
|
+
* target, so tag names match case-insensitively, attributes may use either
|
|
145
|
+
* quote or none, and a closing tag may carry whitespace or junk before its `>`
|
|
146
|
+
* (`</script >`, `</SCRIPT foo>`), as browsers accept. This only reads; it
|
|
147
|
+
* never rewrites or "sanitises" HTML.
|
|
142
148
|
*/
|
|
143
149
|
export function extractScripts(html, baseUrl) {
|
|
144
150
|
const scripts = { external: [], inline: [] };
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
const lower = String(html ?? "").toLowerCase();
|
|
152
|
+
const src0 = String(html ?? "");
|
|
153
|
+
let pos = 0;
|
|
154
|
+
while ((pos = lower.indexOf("<script", pos)) !== -1) {
|
|
155
|
+
const after = lower[pos + 7];
|
|
156
|
+
// `<scripts>` or `<script-x>` is a different tag.
|
|
157
|
+
if (after !== undefined && after !== ">" && after !== "/" && !/\s/.test(after)) { pos += 7; continue; }
|
|
158
|
+
const { attrs, end } = readTag(src0, pos + 7);
|
|
159
|
+
if (end === -1) break;
|
|
160
|
+
const src = attrs.src;
|
|
161
|
+
if (src) {
|
|
162
|
+
let url = src;
|
|
163
|
+
if (url.startsWith("//")) url = "https:" + url;
|
|
164
|
+
else if (url.startsWith("/")) url = baseUrl + url;
|
|
165
|
+
else if (!url.startsWith("http")) url = baseUrl + "/" + url;
|
|
166
|
+
scripts.external.push(url);
|
|
167
|
+
}
|
|
168
|
+
const close = findScriptClose(lower, end);
|
|
169
|
+
const body = src0.slice(end, close.start);
|
|
170
|
+
if (body.trim().length > 10) scripts.inline.push(body);
|
|
171
|
+
pos = close.end;
|
|
156
172
|
}
|
|
173
|
+
return scripts;
|
|
174
|
+
}
|
|
157
175
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
176
|
+
/** Read attributes from just after `<script` to the tag's `>`. */
|
|
177
|
+
function readTag(s, i) {
|
|
178
|
+
const attrs = Object.create(null);
|
|
179
|
+
const n = s.length;
|
|
180
|
+
while (i < n) {
|
|
181
|
+
while (i < n && (/\s/.test(s[i]) || s[i] === "/")) i++;
|
|
182
|
+
if (i >= n) return { attrs, end: -1 };
|
|
183
|
+
if (s[i] === ">") return { attrs, end: i + 1 };
|
|
184
|
+
let name = "";
|
|
185
|
+
while (i < n && !/[\s=>/]/.test(s[i])) name += s[i++];
|
|
186
|
+
while (i < n && /\s/.test(s[i])) i++;
|
|
187
|
+
let value = "";
|
|
188
|
+
if (s[i] === "=") {
|
|
189
|
+
i++;
|
|
190
|
+
while (i < n && /\s/.test(s[i])) i++;
|
|
191
|
+
const q = s[i];
|
|
192
|
+
if (q === '"' || q === "'") {
|
|
193
|
+
const close = s.indexOf(q, i + 1);
|
|
194
|
+
if (close === -1) return { attrs, end: -1 };
|
|
195
|
+
value = s.slice(i + 1, close);
|
|
196
|
+
i = close + 1;
|
|
197
|
+
} else {
|
|
198
|
+
while (i < n && !/[\s>]/.test(s[i])) value += s[i++];
|
|
199
|
+
}
|
|
163
200
|
}
|
|
201
|
+
name = name.toLowerCase();
|
|
202
|
+
if (name && !(name in attrs)) attrs[name] = value;
|
|
164
203
|
}
|
|
204
|
+
return { attrs, end: -1 };
|
|
205
|
+
}
|
|
165
206
|
|
|
166
|
-
|
|
207
|
+
/** Find `</script ...>` (any case, anything up to `>`); unclosed runs to EOF. */
|
|
208
|
+
function findScriptClose(lower, from) {
|
|
209
|
+
let i = from;
|
|
210
|
+
while ((i = lower.indexOf("</script", i)) !== -1) {
|
|
211
|
+
const c = lower[i + 8];
|
|
212
|
+
if (c === undefined || c === ">" || c === "/" || /\s/.test(c)) {
|
|
213
|
+
const gt = lower.indexOf(">", i + 8);
|
|
214
|
+
return { start: i, end: gt === -1 ? lower.length : gt + 1 };
|
|
215
|
+
}
|
|
216
|
+
i += 8;
|
|
217
|
+
}
|
|
218
|
+
return { start: lower.length, end: lower.length };
|
|
167
219
|
}
|
|
168
220
|
|
|
169
221
|
// ── Main scanner ─────────────────────────────────────────────────────────
|