penthera 1.7.0 → 1.8.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 (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build a rule pack's pack.json: hash every rule file in the directory and
4
+ * write the index. Run it after editing any rule file, then sign the result
5
+ * with scripts/rules/sign-pack.sh (maintainer only).
6
+ *
7
+ * node scripts/rules/build-pack.mjs <dir> [--name n] [--version x.y.z]
8
+ * [--min-penthera x.y.z] [--description "..."] [--created-at ISO]
9
+ *
10
+ * Existing pack.json values are kept unless overridden. created_at only moves
11
+ * when a rule file changed, so rebuilding an unchanged pack is a no-op. A
12
+ * pack.json.sig left over from before a change is deleted: it no longer
13
+ * matches, and a stale signature is worse than none.
14
+ */
15
+ import { readFileSync, writeFileSync, readdirSync, existsSync, unlinkSync } from "node:fs";
16
+ import { join, resolve, basename, dirname } from "node:path";
17
+ import { createHash } from "node:crypto";
18
+ import { parseArgs } from "node:util";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const { values: opts, positionals } = parseArgs({
22
+ allowPositionals: true,
23
+ options: {
24
+ name: { type: "string" },
25
+ version: { type: "string" },
26
+ "min-penthera": { type: "string" },
27
+ description: { type: "string" },
28
+ "created-at": { type: "string" },
29
+ },
30
+ });
31
+ if (positionals.length !== 1) {
32
+ console.error("usage: node scripts/rules/build-pack.mjs <pack-dir> [--name n] [--version x.y.z] [--min-penthera x.y.z] [--description text] [--created-at ISO]");
33
+ process.exit(2);
34
+ }
35
+
36
+ const dir = resolve(positionals[0]);
37
+ const manifestPath = join(dir, "pack.json");
38
+ const old = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf-8")) : {};
39
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf-8"));
40
+
41
+ const rules = readdirSync(dir)
42
+ .filter((f) => f.endsWith(".json") && f !== "pack.json")
43
+ .sort()
44
+ .map((file) => {
45
+ const buf = readFileSync(join(dir, file));
46
+ let kind;
47
+ try { kind = JSON.parse(buf.toString("utf-8")).kind; } catch (e) { console.error(`${file}: invalid JSON (${e.message})`); process.exit(1); }
48
+ return { file, kind, sha256: createHash("sha256").update(buf).digest("hex") };
49
+ });
50
+ if (rules.length === 0) { console.error(`No rule files (*.json) in ${dir}`); process.exit(1); }
51
+
52
+ const changed = JSON.stringify(old.rules || []) !== JSON.stringify(rules);
53
+ const manifest = {
54
+ schema: 1,
55
+ name: opts.name || old.name || basename(dir),
56
+ version: opts.version || old.version || "1.0.0",
57
+ created_at: opts["created-at"] || (!changed && old.created_at) || new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
58
+ min_penthera: opts["min-penthera"] || old.min_penthera || pkg.version,
59
+ ...(opts.description || old.description ? { description: opts.description || old.description } : {}),
60
+ rules,
61
+ };
62
+ const out = JSON.stringify(manifest, null, 2) + "\n";
63
+ const before = existsSync(manifestPath) ? readFileSync(manifestPath, "utf-8") : null;
64
+ writeFileSync(manifestPath, out);
65
+
66
+ // Validate the result with the same code that loads packs.
67
+ const { verifyPack } = await import("../../lib/rules/pack.js");
68
+ const sigPath = join(dir, "pack.json.sig");
69
+ if (before !== out && existsSync(sigPath)) {
70
+ unlinkSync(sigPath);
71
+ console.log("Removed pack.json.sig: pack.json changed, so the old signature no longer applies. Re-sign it.");
72
+ }
73
+ const v = verifyPack(dir);
74
+ if (!v.ok) {
75
+ console.error(`pack.json written, but the pack does not verify:\n ${v.errors.join("\n ")}`);
76
+ process.exit(1);
77
+ }
78
+ for (const w of v.warnings) console.log(`warning: ${w}`);
79
+ console.log(`${before === out ? "Unchanged" : "Wrote"} ${manifestPath}: ${manifest.name}@${manifest.version}, ${v.count} rule(s) in ${rules.length} file(s). Signature: ${v.signature}.`);
80
+ if (v.signature !== "valid") console.log("Next: sign it with scripts/rules/sign-pack.sh before release.");
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release gate: every rule pack bundled in lib/rules/ must verify and carry a
4
+ * valid signature from a key in lib/rules/allowed_signers. Runs in the publish
5
+ * workflow before `npm stage publish`, so a rule change cannot ship without
6
+ * passing through the maintainer's signing key.
7
+ *
8
+ * node scripts/rules/check-bundled.mjs (npm run rules:check)
9
+ */
10
+ import { basename } from "node:path";
11
+ import { verifyPack, bundledPackDirs } from "../../lib/rules/index.js";
12
+
13
+ let bad = 0;
14
+ for (const dir of bundledPackDirs()) {
15
+ const v = verifyPack(dir);
16
+ const name = `${basename(dir)}${v.version ? `@${v.version}` : ""}`;
17
+ if (!v.ok) {
18
+ console.error(`FAIL ${name}: ${v.errors.join("; ")}`);
19
+ bad++;
20
+ } else if (v.signature !== "valid") {
21
+ console.error(`FAIL ${name}: ${v.signature === "missing" ? "not signed" : `signature invalid (${v.signatureError})`}. Sign it: scripts/rules/sign-pack.sh lib/rules/${basename(dir)}`);
22
+ bad++;
23
+ } else {
24
+ console.log(`ok ${name}: ${v.count} rules, signed by ${v.signer}`);
25
+ }
26
+ }
27
+ if (bad) {
28
+ console.error(`\n${bad} bundled rule pack(s) not ready to release. See docs/rule-packs.md ("Signing a release").`);
29
+ process.exit(1);
30
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # Sign a rule pack's pack.json. Maintainer only: it needs the private half of
3
+ # a key listed in lib/rules/allowed_signers. Never run this in CI; the point
4
+ # of the signature is that the key stays on the maintainer's machine (or a
5
+ # hardware key), so a compromised workflow cannot produce a trusted pack.
6
+ #
7
+ # scripts/rules/sign-pack.sh <pack-dir> [private-key]
8
+ #
9
+ # private-key defaults to ~/.ssh/id_ed25519. Steps:
10
+ # 1. node scripts/rules/build-pack.mjs <pack-dir> (hashes are current)
11
+ # 2. ssh-keygen -Y sign -n penthera-rules (writes pack.json.sig)
12
+ # 3. penthera rules verify <pack-dir> (checks it against allowed_signers)
13
+ # Review `git diff <pack-dir>` before signing: the signature vouches for
14
+ # every rule in the pack.
15
+ set -euo pipefail
16
+
17
+ if [ $# -lt 1 ] || [ $# -gt 2 ]; then
18
+ echo "usage: $0 <pack-dir> [private-key]" >&2
19
+ exit 2
20
+ fi
21
+ DIR="$1"
22
+ KEY="${2:-$HOME/.ssh/id_ed25519}"
23
+ ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
24
+
25
+ [ -f "$DIR/pack.json" ] || { echo "No pack.json in $DIR (run build-pack.mjs first)" >&2; exit 1; }
26
+ [ -f "$KEY" ] || { echo "Private key not found: $KEY" >&2; exit 1; }
27
+ command -v ssh-keygen >/dev/null || { echo "ssh-keygen not found (OpenSSH 8.1+ needed)" >&2; exit 1; }
28
+
29
+ node "$ROOT/scripts/rules/build-pack.mjs" "$DIR"
30
+ rm -f "$DIR/pack.json.sig"
31
+ ssh-keygen -Y sign -f "$KEY" -n penthera-rules "$DIR/pack.json"
32
+ node "$ROOT/bin/penthera.js" rules verify "$DIR"
@@ -1,25 +1,25 @@
1
1
  ---
2
2
  name: penthera
3
3
  description: >
4
- Open-source security for the apps and AI systems the user ships, run locally with
5
- the Penthera CLI. Sets it up in one command ("set up penthera in this repo").
6
- Scans the user's own live URL and repo (TLS, headers, auth, OpenAPI, secrets incl. AI keys,
7
- opt-in injection); checks npm and Python dependencies offline ("is this dependency safe?",
8
- "what changed in my deps?", "install X safely", "any AGPL in our deps?"); blocks
9
- secrets and risky agent config before they are committed ("add a pre-commit hook");
10
- audits AI tooling ("audit my MCP servers / skills / hooks", "check this skill
11
- before I install it"); fixes findings
12
- on a local branch and re-scans to verify ("scan and fix my app"); and writes
4
+ Offline cyber security agent for the user's own apps and AI coding tools, run
5
+ locally with the Penthera CLI; open R&D, measured in public. Sets it up in one
6
+ command ("set up penthera in this repo"). Scans the user's own live URL and repo
7
+ (TLS, headers, auth, OpenAPI, secrets incl. AI keys, opt-in injection); checks npm
8
+ and Python dependencies offline ("is this dependency safe?", "what changed in my
9
+ deps?", "install X safely", "any AGPL in our deps?"); blocks secrets and risky
10
+ agent config before commit ("add a pre-commit hook"); audits AI tooling ("audit
11
+ my MCP servers / skills / hooks", "check this skill before I install it"); fixes
12
+ findings on a local branch and re-scans to verify ("scan and fix my app"); writes
13
13
  reports and evidence ("report for my auditor / manager", NIS2, SARIF, baseline
14
- diff). Use when the user asks to scan, audit, harden, fix, or review the security of
15
- their own app, staging URL, localhost, repo, dependencies, or agent setup. Do NOT
16
- use for targets without explicit authorization.
14
+ diff). Use when the user asks to scan, audit, harden, fix, or review the security
15
+ of their own app, staging URL, localhost, repo, dependencies, or agent setup. Do
16
+ NOT use for targets without explicit authorization.
17
17
  license: MIT
18
18
  compatibility: Node.js 22+, network access to target, Penthera CLI (npm link or node bin/penthera.js from repo). macOS only for --machine.
19
19
  allowed-tools: "Bash(node:*) Bash(npm:*) Bash(penthera:*) Read Write Edit"
20
20
  metadata:
21
21
  author: danoszz
22
- version: 1.7.0
22
+ version: 1.8.0
23
23
  category: security
24
24
  ---
25
25
 
@@ -70,6 +70,7 @@ All examples below use `penthera`; substitute `node bin/penthera.js` when needed
70
70
  | Scan a live URL | `penthera <url> --profile standard -o reports/scan.json` |
71
71
  | Scan repo only (secrets, routes) | `penthera --repo . -o reports/repo-scan.json` |
72
72
  | Is this dependency safe? / what changed in my deps? | `penthera deps` or `penthera deps --base origin/main` (see **Dependency risk scan**) |
73
+ | Am I affected by a PHP / nginx / Apache / Node.js / Python / OpenSSL advisory? | `penthera <url>` and `penthera --repo .`; for an advisory newer than the release, add `--rules <signed-pack>` (see docs/runtime-advisories.md). Report "check on server" findings as unknown, never as safe |
73
74
  | Install X safely / is it safe to add this package? | `penthera install <pkg> --dry-run`, then `penthera install <pkg>` (see **Install firewall**) |
74
75
  | Are our dependency licenses OK? / any AGPL? | `penthera deps --license-policy permissive` (see **License policy**) |
75
76
  | Add a pre-commit hook / stop me committing secrets | `penthera hook install` (see **Pre-commit hook**) |
@@ -253,7 +254,7 @@ penthera <url> --repo . --framework nis2 --audience auditor --markdown reports/a
253
254
  penthera <url> --repo . --framework nis2 --evidence reports/evidence-$(date +%F) --sign-key ~/.ssh/id_ed25519
254
255
  penthera evidence verify reports/evidence-$(date +%F)
255
256
  ```
256
- Tell the user to send the public key to the auditor separately (an `allowed_signers` line) and point them to `docs/evidence.md` for the one-command check.
257
+ Tell the user to send the public key to the auditor separately (an `allowed_signers` line) and point them to [docs/evidence.md](https://github.com/danoszz/penthera/blob/main/docs/evidence.md) for the one-command check.
257
258
  4. A house format: write a template with `{{summary.critical}}`, `{{#each findings}}…{{/each}}` and `{{#if x}}…{{/if}}` (no other syntax), then `--report-template reports/template.md --markdown reports/custom.md`.
258
259
  5. Summarize what the report says in two or three sentences. Never describe a bundle as an audit or a certification: it is technical evidence.
259
260
 
@@ -355,7 +356,7 @@ penthera ui --demo --open # fake data, to look around
355
356
  2. It reads only the `--project` folders, the reports folder and `~/.penthera`. If the screens are empty, scan with `-o reports/<name>.json` first (and `penthera deps -o reports/deps.json`, `penthera agents . -o reports/agents.json`).
356
357
  3. The bar at the bottom of the view starts scans, dependency checks, AI tooling audits and the fix loop for the selected project. The fix loop is `penthera agent --loop` with the same rules: the user approves every diff in the page, nothing is pushed. A URL that is not on this machine needs its hostname typed to confirm; the authorization gate above still applies.
357
358
 
358
- See [docs/dashboard.md](../../docs/dashboard.md).
359
+ See [docs/dashboard.md](https://github.com/danoszz/penthera/blob/main/docs/dashboard.md).
359
360
 
360
361
  ## Examples
361
362
 
package/src/agent/git.js CHANGED
@@ -9,15 +9,40 @@
9
9
  * - GIT_NO_LAZY_FETCH=1, so a partial clone cannot fetch missing objects;
10
10
  * - core.hooksPath=/dev/null, core.fsmonitor=false and commit --no-verify, so
11
11
  * the scanned repo's hooks never run (a repo can ship hooks that do anything);
12
+ * - signing off (commit/tag.gpgsign=false, every gpg.*.program and
13
+ * gpg.ssh.defaultKeyCommand overridden), so a repo cannot name a signer;
14
+ * - no attributes from the user's attributes file (core.attributesFile=/dev/null)
15
+ * or, on git >= 2.40, from the work tree (--attr-source=<empty tree>), and
16
+ * every filter driver the config defines emptied, so no clean/smudge/process
17
+ * filter runs whatever selects it ($GIT_DIR/info/attributes is always read);
18
+ * - GIT_LITERAL_PATHSPECS=1, so a file named `[id].js` or `:(glob)x` is only
19
+ * ever that file;
12
20
  * - GIT_TERMINAL_PROMPT=0 and an empty credential helper, so nothing asks for
13
21
  * or hands out credentials.
22
+ * Subcommands that write (checkout, add, commit, reset) also need git >= 2.31
23
+ * (for GIT_CONFIG_KEY_n, which empties the filters) and are refused while the
24
+ * repo's own config sets any filter.*, gpg.*, include.* or includeIf.* key.
14
25
  */
15
26
  import { execFileSync } from "node:child_process";
27
+ import { safeEnv, SAFE_CONFIG } from "../../lib/git.js";
16
28
 
17
29
  export const ALLOWED_SUBCOMMANDS = Object.freeze([
18
30
  "rev-parse", "symbolic-ref", "status", "diff", "log", "show-ref", "checkout", "add", "commit", "config",
31
+ "check-ignore", "reset",
19
32
  ]);
20
33
 
34
+ /** Subcommands that change the work tree, the index or refs. */
35
+ export const WRITE_SUBCOMMANDS = Object.freeze(["checkout", "add", "commit", "reset"]);
36
+
37
+ /** GIT_CONFIG_COUNT/KEY_n/VALUE_n arrived in git 2.31: below it, filters cannot be emptied. */
38
+ export const MIN_WRITE_VERSION = Object.freeze([2, 31]);
39
+
40
+ /** --attr-source arrived in git 2.40; older git still reads the work tree's .gitattributes. */
41
+ export const ATTR_SOURCE_VERSION = Object.freeze([2, 40]);
42
+
43
+ /** The empty tree: as an attribute source it means "no .gitattributes anywhere". */
44
+ export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
45
+
21
46
  /** Arguments that would make an allowed subcommand reach outside the repo. */
22
47
  const FORBIDDEN_ARGS = [
23
48
  /^-c$/, /^--config/, /^--git-dir/, /^--work-tree/, /^--exec-path/, /^--upload-pack/, /^--receive-pack/,
@@ -25,10 +50,10 @@ const FORBIDDEN_ARGS = [
25
50
  ];
26
51
 
27
52
  export class GitPolicyError extends Error {
28
- constructor(message) {
53
+ constructor(message, code = "GIT_FORBIDDEN") {
29
54
  super(message);
30
55
  this.name = "GitPolicyError";
31
- this.code = "GIT_FORBIDDEN";
56
+ this.code = code;
32
57
  }
33
58
  }
34
59
 
@@ -49,15 +74,45 @@ export function assertAllowed(args) {
49
74
  if (sub === "config" && !(rest.length === 2 && rest[0] === "--get" && /^[\w.-]+$/.test(rest[1]))) {
50
75
  throw new GitPolicyError("git config: only `config --get <key>` is allowed");
51
76
  }
77
+ // `reset` may only unstage paths: no --hard, no commit-ish.
78
+ if (sub === "reset" && !(rest.length >= 3 && rest[0] === "-q" && rest[1] === "--")) {
79
+ throw new GitPolicyError("git reset: only `reset -q -- <paths>` is allowed");
80
+ }
52
81
  return true;
53
82
  }
54
83
 
55
- const SAFE_CONFIG = [
56
- "-c", "protocol.allow=never",
57
- "-c", "core.hooksPath=/dev/null",
58
- "-c", "core.fsmonitor=false",
59
- "-c", "credential.helper=",
60
- ];
84
+
85
+ /** "git version 2.43.0.windows.1" -> [2, 43], or null. */
86
+ export function parseGitVersion(text) {
87
+ const m = /git version (\d+)\.(\d+)/.exec(String(text || ""));
88
+ return m ? [Number(m[1]), Number(m[2])] : null;
89
+ }
90
+
91
+ const atLeast = (v, min) => Boolean(v) && (v[0] > min[0] || (v[0] === min[0] && v[1] >= min[1]));
92
+
93
+ const versionCache = new WeakMap(); // exec -> [major, minor] | null
94
+
95
+ /** How to get past a GIT_UNSAFE_CONFIG refusal. */
96
+ const WAY_FORWARD = "Move the setting to your global config (`git config --global <key> ...`) and remove it here (`git config --unset <key>`), or apply fixes by hand from the report (`penthera fix --plan` prints each diff). Scans and reports still work.";
97
+
98
+ /** Keys in the repo's own config that make a write unsafe. */
99
+ const UNSAFE_REPO_KEY = /^(filter|gpg)\.|^include(if)?\./i;
100
+ const FILTER_KEY = /^filter\.(.+)\.(clean|smudge|process|required)$/i;
101
+
102
+ /**
103
+ * Parse `git config --show-scope -z --get-regexp` output into [{ scope, key }].
104
+ * Each record is "scope\0key\nvalue\0" (or "scope\0key\0" for a bare boolean).
105
+ */
106
+ export function parseScopedConfig(out) {
107
+ const parts = String(out || "").split("\0");
108
+ const entries = [];
109
+ for (let i = 0; i + 1 < parts.length; i += 2) {
110
+ const scope = parts[i];
111
+ const nl = parts[i + 1].indexOf("\n");
112
+ entries.push({ scope, key: nl < 0 ? parts[i + 1] : parts[i + 1].slice(0, nl) });
113
+ }
114
+ return entries;
115
+ }
61
116
 
62
117
  /**
63
118
  * Bind a git runner to a work tree.
@@ -67,19 +122,97 @@ const SAFE_CONFIG = [
67
122
  * @returns {{ run(...args: string[]): string, tryRun(...args: string[]): string|null }}
68
123
  */
69
124
  export function createGit(cwd, { exec = execFileSync, env = process.env } = {}) {
70
- const childEnv = { ...env, GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" };
125
+ const baseEnv = { ...safeEnv(env), GIT_OPTIONAL_LOCKS: "0", GIT_LITERAL_PATHSPECS: "1" };
126
+ const opts = (extra = {}) => ({
127
+ cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 32 * 1024 * 1024, ...extra,
128
+ });
129
+
130
+ const version = () => {
131
+ if (!versionCache.has(exec)) {
132
+ let v = null;
133
+ try { v = parseGitVersion(exec("git", ["--version"], opts({ env: baseEnv, timeout: 10_000 }))); } catch { v = null; }
134
+ versionCache.set(exec, v);
135
+ }
136
+ return versionCache.get(exec);
137
+ };
138
+
139
+ // Read the config git will see (includes followed: reading config runs nothing).
140
+ // Exit 1 means no key matched; anything else fails closed.
141
+ const probe = () => {
142
+ let out;
143
+ try {
144
+ out = exec("git", ["config", "--show-scope", "-z", "--get-regexp", "^(filter|gpg|include|includeif)\\."],
145
+ opts({ env: baseEnv, timeout: 10_000 }));
146
+ } catch (e) {
147
+ if (e?.status === 1) out = "";
148
+ else throw new GitPolicyError(`git: could not read this repo's git config safely (${String(e?.stderr || e?.message || e).trim().split("\n")[0]})`, "GIT_UNSAFE_CONFIG");
149
+ }
150
+ const entries = parseScopedConfig(out);
151
+ const filters = new Set();
152
+ for (const { key } of entries) {
153
+ const m = FILTER_KEY.exec(key);
154
+ if (m) filters.add(m[1]);
155
+ }
156
+ const repoKeys = entries.filter((e) => (e.scope === "local" || e.scope === "worktree") && UNSAFE_REPO_KEY.test(e.key)).map((e) => e.key);
157
+ return { filters: [...filters], repoKeys: [...new Set(repoKeys)] };
158
+ };
159
+ let probed = null;
160
+
161
+ // Every filter driver any config defines is emptied for this call, through
162
+ // GIT_CONFIG_KEY_n (a driver name may contain "=", which `-c` cannot carry).
163
+ const envFor = (filters) => {
164
+ if (!filters.length) return baseEnv;
165
+ const childEnv = { ...baseEnv };
166
+ let n = /^\d+$/.test(String(env.GIT_CONFIG_COUNT || "")) ? Number(env.GIT_CONFIG_COUNT) : 0;
167
+ for (const name of filters) {
168
+ for (const [k, v] of [["clean", ""], ["smudge", ""], ["process", ""], ["required", "false"]]) {
169
+ childEnv[`GIT_CONFIG_KEY_${n}`] = `filter.${name}.${k}`;
170
+ childEnv[`GIT_CONFIG_VALUE_${n}`] = v;
171
+ n++;
172
+ }
173
+ }
174
+ childEnv.GIT_CONFIG_COUNT = String(n);
175
+ return childEnv;
176
+ };
177
+
71
178
  const run = (...args) => {
72
179
  assertAllowed(args);
73
- const full = args[0] === "commit" ? ["commit", "--no-verify", ...args.slice(1)] : args;
74
- return exec("git", [...SAFE_CONFIG, ...full], {
75
- cwd, env: childEnv, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 32 * 1024 * 1024,
76
- });
180
+ const sub = args[0];
181
+ const v = version();
182
+ if (WRITE_SUBCOMMANDS.includes(sub)) {
183
+ probed = probe(); // fresh before every write
184
+ if (probed.repoKeys.length) {
185
+ const keys = probed.repoKeys.slice(0, 5).join(", ") + (probed.repoKeys.length > 5 ? ", ..." : "");
186
+ throw new GitPolicyError(
187
+ `penthera won't write in this repo: its own git config sets ${keys}, and repo-level git config can run programs during checkout and commit. ${WAY_FORWARD.replaceAll("<key>", probed.repoKeys[0])}`,
188
+ "GIT_UNSAFE_CONFIG",
189
+ );
190
+ }
191
+ if (!atLeast(v, MIN_WRITE_VERSION)) {
192
+ throw new GitPolicyError(
193
+ `penthera won't write in this repo: git ${v ? v.join(".") : "(unknown version)"} is older than ${MIN_WRITE_VERSION.join(".")}, the minimum it needs to switch off filters the repo configures. Upgrade git, or apply fixes by hand from the report (\`penthera fix --plan\` prints each diff). Scans and reports still work.`,
194
+ "GIT_TOO_OLD",
195
+ );
196
+ }
197
+ } else if (!probed) {
198
+ probed = probe();
199
+ }
200
+ if (probed.filters.length && !atLeast(v, MIN_WRITE_VERSION)) {
201
+ throw new GitPolicyError(`git: this repo's config defines filter drivers, and git older than ${MIN_WRITE_VERSION.join(".")} cannot switch them off. Upgrade git.`, "GIT_TOO_OLD");
202
+ }
203
+ const pre = atLeast(v, ATTR_SOURCE_VERSION) ? [...SAFE_CONFIG, `--attr-source=${EMPTY_TREE}`] : SAFE_CONFIG;
204
+ const full = sub === "commit" ? ["commit", "--no-verify", ...args.slice(1)] : args;
205
+ let childEnv = envFor(probed.filters);
206
+ // check-ignore takes paths, not pathspecs, and rejects the literal flag; callers pass "./<path>".
207
+ if (sub === "check-ignore") childEnv = { ...childEnv, GIT_LITERAL_PATHSPECS: "0" };
208
+ return exec("git", [...pre, ...full], opts({ env: childEnv }));
77
209
  };
78
210
  const tryRun = (...args) => {
79
211
  try {
80
212
  return run(...args);
81
213
  } catch (e) {
82
- if (e instanceof GitPolicyError) throw e;
214
+ // A forbidden call is a bug in the caller; an unreadable config or an old git is just "no answer".
215
+ if (e instanceof GitPolicyError && e.code === "GIT_FORBIDDEN") throw e;
83
216
  return null;
84
217
  }
85
218
  };