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.
- package/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- 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"
|
package/skills/penthera/SKILL.md
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: penthera
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
the Penthera CLI
|
|
6
|
-
Scans the user's own live URL and repo
|
|
7
|
-
opt-in injection); checks npm
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
before I install it"); fixes
|
|
12
|
-
on a local branch and re-scans to verify ("scan and fix my app");
|
|
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
|
|
15
|
-
their own app, staging URL, localhost, repo, dependencies, or agent setup. Do
|
|
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.
|
|
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
|
|
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](
|
|
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 =
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
};
|