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.
Files changed (104) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +128 -663
  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/dashboard.md +244 -0
  20. package/docs/evidence.md +8 -4
  21. package/docs/examples/github-actions-deps.yml +4 -4
  22. package/docs/examples/github-actions-scan.yml +4 -4
  23. package/docs/index.md +9 -0
  24. package/docs/internal-audit.md +83 -0
  25. package/docs/marketing.md +151 -0
  26. package/docs/plan.md +4 -3
  27. package/docs/privacy.md +38 -0
  28. package/docs/references.bib +10 -0
  29. package/docs/references.md +1 -0
  30. package/docs/research/socket-positioning-notes.md +183 -0
  31. package/docs/use-cases/README.md +14 -0
  32. package/docs/use-cases/agencies.md +71 -0
  33. package/docs/use-cases/ai-coding-teams.md +81 -0
  34. package/docs/use-cases/eu-sme-compliance.md +68 -0
  35. package/docs/use-cases/open-source-maintainers.md +75 -0
  36. package/docs/use-cases/solo-founder.md +65 -0
  37. package/lib/blackbox/email-dns.js +5 -0
  38. package/lib/compliance/questionnaire.js +4 -2
  39. package/lib/deps/capabilities.js +49 -12
  40. package/lib/deps/index.js +290 -30
  41. package/lib/deps/lockfile.js +25 -2
  42. package/lib/deps/popular-pypi.json +1 -0
  43. package/lib/deps/python/capabilities.js +447 -0
  44. package/lib/deps/python/index.js +234 -0
  45. package/lib/deps/python/lockfiles.js +359 -0
  46. package/lib/deps/python/source.js +396 -0
  47. package/lib/deps/python/toml.js +219 -0
  48. package/lib/deps/report.js +37 -6
  49. package/lib/deps/source.js +149 -24
  50. package/lib/deps/typosquat.js +24 -13
  51. package/lib/fix/apply.js +83 -19
  52. package/lib/hook/index.js +41 -8
  53. package/lib/install/index.js +59 -18
  54. package/lib/install/policy.js +17 -5
  55. package/lib/memory/repo-suppress.js +11 -3
  56. package/lib/references.json +77 -0
  57. package/lib/remediation/index.js +9 -0
  58. package/lib/retirejs.js +69 -17
  59. package/lib/rules/regex-guard.js +368 -0
  60. package/lib/templates.js +13 -6
  61. package/lib/tls.js +7 -0
  62. package/lib/whitebox/frameworks.js +8 -5
  63. package/lib/whitebox/secrets.js +20 -7
  64. package/package.json +7 -3
  65. package/skills/penthera/SKILL.md +23 -5
  66. package/src/cli/deps.js +60 -5
  67. package/src/cli/evidence.js +14 -4
  68. package/src/cli/install.js +14 -5
  69. package/src/cli/report-outputs.js +25 -2
  70. package/src/cli/ui.js +89 -0
  71. package/src/cli.js +19 -7
  72. package/src/report/audience.js +10 -10
  73. package/src/report/evidence.js +33 -14
  74. package/src/report/footer.js +11 -0
  75. package/src/report/markdown.js +6 -3
  76. package/src/report/md-escape.js +16 -0
  77. package/src/reporter.js +1 -0
  78. package/src/scan-url.js +5 -1
  79. package/src/ui/api/data.js +309 -0
  80. package/src/ui/api/demo.js +464 -0
  81. package/src/ui/api/routes.js +134 -0
  82. package/src/ui/api/runs.js +243 -0
  83. package/src/ui/api/summarize.js +451 -0
  84. package/src/ui/security.js +153 -0
  85. package/src/ui/server.js +186 -0
  86. package/src/ui/static/app.css +723 -0
  87. package/src/ui/static/favicon.svg +1 -0
  88. package/src/ui/static/index.html +57 -0
  89. package/src/ui/static/js/api.js +29 -0
  90. package/src/ui/static/js/app.js +138 -0
  91. package/src/ui/static/js/charts.js +207 -0
  92. package/src/ui/static/js/copy.js +79 -0
  93. package/src/ui/static/js/dom.js +120 -0
  94. package/src/ui/static/js/format.js +42 -0
  95. package/src/ui/static/js/theme.js +10 -0
  96. package/src/ui/static/js/typer.js +99 -0
  97. package/src/ui/static/js/views/ai.js +68 -0
  98. package/src/ui/static/js/views/deps.js +96 -0
  99. package/src/ui/static/js/views/evidence.js +115 -0
  100. package/src/ui/static/js/views/findings.js +165 -0
  101. package/src/ui/static/js/views/history.js +92 -0
  102. package/src/ui/static/js/views/overview.js +184 -0
  103. package/src/ui/static/js/views/run.js +349 -0
  104. 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. Offline,
12
- * no telemetry. Findings use the standard shape, so ids match a full scan and
13
- * penthera.suppress.json works here too.
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 { loadRepoSuppressions } from "../memory/repo-suppress.js";
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
- const all = git(root, ["ls-files", "-z", "--cached"]).split("\0").filter((p) => p && isAgentConfig(p));
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 } = loadPolicy(root, opts.policyPath || null);
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 sup = loadRepoSuppressions(root, { now: opts.now });
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 = [];
@@ -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,8 +8,14 @@
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.
@@ -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 = allowedBy ? [] : c.flags.filter((id) => policy.deny.includes(id));
119
- 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))));
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(readFileSync(path, "utf-8")); } catch (e) {
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)) {
@@ -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
  }
@@ -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
- // External scripts
147
- const srcRe = /<script[^>]+src=["']([^"']+)["']/gi;
148
- let match;
149
- while ((match = srcRe.exec(html))) {
150
- let src = match[1];
151
- // Resolve relative URLs
152
- if (src.startsWith("//")) src = "https:" + src;
153
- else if (src.startsWith("/")) src = baseUrl + src;
154
- else if (!src.startsWith("http")) src = baseUrl + "/" + src;
155
- scripts.external.push(src);
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
- // Inline scripts (for version detection)
159
- const inlineRe = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
160
- while ((match = inlineRe.exec(html))) {
161
- if (match[1].trim().length > 10) {
162
- scripts.inline.push(match[1]);
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
- return scripts;
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 ─────────────────────────────────────────────────────────