penthera 1.2.0 → 1.5.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 +269 -0
- package/README.md +493 -45
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +12 -1
- package/docs/local-loop.md +276 -0
- package/docs/plan.md +100 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +440 -0
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +109 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +190 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/hook/index.js +312 -0
- package/lib/hook/policy.js +62 -0
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +125 -0
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +11 -1
- package/package.json +17 -4
- package/skills/penthera/SKILL.md +132 -22
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +104 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +212 -8
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
package/src/cli/agent.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera agent --loop`: the local scan → fix → verify → learn loop.
|
|
3
|
+
*
|
|
4
|
+
* penthera agent --loop [--repo <path>] [--url <url>] [--max-iterations 5]
|
|
5
|
+
* [--auto low] [--run-tests] [--fix-model <name>] [--allow-dirty] [-o run.json]
|
|
6
|
+
*
|
|
7
|
+
* Every diff is shown and needs y (apply), n (not this run), s (skip for now)
|
|
8
|
+
* or q (quit). Fixes land as local commits on penthera/fix-<yyyymmdd-hhmm>.
|
|
9
|
+
* Nothing is pushed. See docs/local-loop.md.
|
|
10
|
+
*/
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { resolve, dirname } from "node:path";
|
|
13
|
+
import { existsSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
15
|
+
import { runLoop, LoopError } from "../agent/loop.js";
|
|
16
|
+
import { normalizeBaseUrl } from "../utils/url.js";
|
|
17
|
+
import { printPlan } from "./fix.js";
|
|
18
|
+
import { bold, dim, green, red, yellow } from "./ansi.js";
|
|
19
|
+
|
|
20
|
+
const HELP = `
|
|
21
|
+
${bold("penthera agent --loop")} — scan, fix, verify and learn, on this machine only
|
|
22
|
+
|
|
23
|
+
${dim("Usage")}
|
|
24
|
+
$ penthera agent --loop [--repo <path>] [--url <url>]
|
|
25
|
+
|
|
26
|
+
${dim("Options")}
|
|
27
|
+
-r, --repo <path> Repo to fix (default: .). Must be a clean git tree on a branch
|
|
28
|
+
-u, --url <url> Also scan this running app (egress limited to this host)
|
|
29
|
+
--max-iterations <n> Stop after n scan/fix rounds (default: 5)
|
|
30
|
+
--auto low Apply without asking only recipes that memory shows verified
|
|
31
|
+
3+ times on this framework with no failures
|
|
32
|
+
--run-tests Run the project's test script (node --run test) to verify
|
|
33
|
+
--fix-model <name> Ask a local Ollama model (127.0.0.1:11434 only) when no recipe
|
|
34
|
+
matches. Its diffs always need approval
|
|
35
|
+
--allow-dirty Start with uncommitted changes or a detached HEAD (files with
|
|
36
|
+
changes are never patched)
|
|
37
|
+
-o, --output <file> Write the loopRun JSON here
|
|
38
|
+
-h, --help Show this help
|
|
39
|
+
|
|
40
|
+
Each diff asks: y apply, n not this run, s skip for now, q quit.
|
|
41
|
+
Fixes are committed on a local branch penthera/fix-<yyyymmdd-hhmm>. Nothing is
|
|
42
|
+
pushed, fetched or opened as a pull request. Ctrl-C stops after the current step.
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
/** Line reader that works for a TTY and for piped answers. EOF answers "n". */
|
|
46
|
+
function createAsker(signal) {
|
|
47
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
48
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
49
|
+
let eof = false;
|
|
50
|
+
const aborted = new Promise((r) => signal.addEventListener("abort", () => r({ abort: true }), { once: true }));
|
|
51
|
+
|
|
52
|
+
async function readLine(question) {
|
|
53
|
+
process.stdout.write(question);
|
|
54
|
+
if (eof) return null;
|
|
55
|
+
const next = await Promise.race([lines.next(), aborted]);
|
|
56
|
+
if (next.abort) return "q";
|
|
57
|
+
if (next.done) { eof = true; return null; }
|
|
58
|
+
if (!process.stdin.isTTY) process.stdout.write(`${next.value}\n`);
|
|
59
|
+
return next.value.trim().toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
async ask({ plan, iteration, index, total, memory }) {
|
|
64
|
+
process.stdout.write(`\n ${bold(`[${iteration}.${index}/${total}]`)}`);
|
|
65
|
+
if (memory) process.stdout.write(dim(` memory: ${memory.verified} verified, ${memory.failed} failed`));
|
|
66
|
+
printPlan(plan);
|
|
67
|
+
for (let tries = 0; tries < 5; tries++) {
|
|
68
|
+
const a = await readLine(`\n Apply this fix? ${dim("[y]es / [n]o / [s]kip / [q]uit")} `);
|
|
69
|
+
if (a === null) {
|
|
70
|
+
process.stdout.write(`\n ${yellow("No more input")}: not applying (answer y on a terminal to apply).\n`);
|
|
71
|
+
return "n";
|
|
72
|
+
}
|
|
73
|
+
if (a === "y" || a === "yes") return "y";
|
|
74
|
+
if (a === "n" || a === "no") return "n";
|
|
75
|
+
if (a === "s" || a === "skip") return "skip";
|
|
76
|
+
if (a === "q" || a === "quit") return "quit";
|
|
77
|
+
}
|
|
78
|
+
return "skip";
|
|
79
|
+
},
|
|
80
|
+
close: () => rl.close(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runAgent(argv) {
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = parseArgs({
|
|
88
|
+
args: argv,
|
|
89
|
+
allowPositionals: true,
|
|
90
|
+
options: {
|
|
91
|
+
loop: { type: "boolean", default: false },
|
|
92
|
+
repo: { type: "string", short: "r" },
|
|
93
|
+
url: { type: "string", short: "u" },
|
|
94
|
+
"max-iterations": { type: "string" },
|
|
95
|
+
auto: { type: "string" },
|
|
96
|
+
"run-tests": { type: "boolean", default: false },
|
|
97
|
+
"fix-model": { type: "string" },
|
|
98
|
+
"allow-dirty": { type: "boolean", default: false },
|
|
99
|
+
output: { type: "string", short: "o" },
|
|
100
|
+
help: { type: "boolean", short: "h", default: false },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
} catch (e) {
|
|
104
|
+
process.stderr.write(` ${red("Error")} ${e.message}\n${HELP}`);
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
const opts = parsed.values;
|
|
108
|
+
if (opts.help || !opts.loop) {
|
|
109
|
+
console.log(HELP);
|
|
110
|
+
return opts.help ? 0 : 2;
|
|
111
|
+
}
|
|
112
|
+
if (opts.auto != null && opts.auto !== "low") {
|
|
113
|
+
process.stderr.write(` ${red("Error")} --auto only accepts "low"\n`);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
const maxIterations = opts["max-iterations"] == null ? 5 : Number(opts["max-iterations"]);
|
|
117
|
+
if (!Number.isInteger(maxIterations) || maxIterations < 1 || maxIterations > 50) {
|
|
118
|
+
process.stderr.write(` ${red("Error")} --max-iterations must be a whole number from 1 to 50\n`);
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
const repo = resolve(opts.repo || ".");
|
|
122
|
+
if (!existsSync(repo) || !statSync(repo).isDirectory()) {
|
|
123
|
+
process.stderr.write(` ${red("Error")} Repo not found: ${repo}\n`);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
const rawUrl = opts.url || parsed.positionals[0];
|
|
127
|
+
const url = rawUrl ? normalizeBaseUrl(rawUrl) : null;
|
|
128
|
+
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
const handle = {};
|
|
131
|
+
let interrupts = 0;
|
|
132
|
+
const onSigint = () => {
|
|
133
|
+
interrupts++;
|
|
134
|
+
if (interrupts === 1) {
|
|
135
|
+
process.stdout.write(`\n ${yellow("Stopping")} after the current step. Ctrl-C again to revert the fix in flight and exit now.\n`);
|
|
136
|
+
controller.abort();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try { handle.revertPending?.(); } catch { /* best effort */ }
|
|
140
|
+
process.stdout.write(`\n ${red("Stopped.")} The fix branch holds only verified commits.\n`);
|
|
141
|
+
process.exit(130);
|
|
142
|
+
};
|
|
143
|
+
process.on("SIGINT", onSigint);
|
|
144
|
+
|
|
145
|
+
const asker = createAsker(controller.signal);
|
|
146
|
+
process.stdout.write(`\n ${bold("penthera agent --loop")} ${dim(repo)}${url ? dim(` + ${url}`) : ""}\n`);
|
|
147
|
+
process.stdout.write(dim(` local only: egress limited to ${url ? new URL(url).host : "no hosts (repo scan only)"}${opts["fix-model"] ? " and Ollama on 127.0.0.1:11434" : ""}; nothing is pushed\n`));
|
|
148
|
+
|
|
149
|
+
let run;
|
|
150
|
+
try {
|
|
151
|
+
run = await runLoop({
|
|
152
|
+
repo, url, maxIterations, auto: opts.auto || "off", runTests: opts["run-tests"],
|
|
153
|
+
fixModel: opts["fix-model"] || null, allowDirty: opts["allow-dirty"],
|
|
154
|
+
ask: asker.ask, log: (l) => process.stdout.write(`${l}\n`), signal: controller.signal, handle,
|
|
155
|
+
});
|
|
156
|
+
} catch (e) {
|
|
157
|
+
asker.close();
|
|
158
|
+
process.off("SIGINT", onSigint);
|
|
159
|
+
process.stderr.write(` ${red("Not started")} ${e.message}\n`);
|
|
160
|
+
return e instanceof LoopError ? 2 : 1;
|
|
161
|
+
}
|
|
162
|
+
asker.close();
|
|
163
|
+
process.off("SIGINT", onSigint);
|
|
164
|
+
|
|
165
|
+
if (opts.output) {
|
|
166
|
+
const out = resolve(opts.output);
|
|
167
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
168
|
+
writeFileSync(out, JSON.stringify({ loopRun: run }, null, 2) + "\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const t = run.totals;
|
|
172
|
+
process.stdout.write(`\n ${bold("Done")}: ${run.stopReason} after ${t.iterations} iteration${t.iterations === 1 ? "" : "s"} (${(run.durationMs / 1000).toFixed(1)}s)\n`);
|
|
173
|
+
process.stdout.write(` ${green(`${t.verified} verified`)}, ${t.failed ? red(`${t.failed} failed and reverted`) : "0 failed"}, ${t.declined} declined, ${t.skipped} skipped\n`);
|
|
174
|
+
process.stdout.write(` findings: ${run.findings.initial ?? "?"} → ${run.findings.final ?? "?"} (non-info)\n`);
|
|
175
|
+
if (run.error) process.stdout.write(` ${red("Error")} ${run.error}\n`);
|
|
176
|
+
process.stdout.write(` branch ${bold(run.branch.fix)}: review with git log --stat ${run.branch.base || run.branch.baseSha.slice(0, 12)}..${run.branch.fix}\n`);
|
|
177
|
+
if (opts.output) process.stdout.write(` loopRun written to ${opts.output}\n`);
|
|
178
|
+
process.stdout.write(` Nothing was pushed. Merge or push the branch yourself when you are happy with it.\n\n`);
|
|
179
|
+
|
|
180
|
+
if (run.stopReason === "error") return 1;
|
|
181
|
+
if (run.stopReason === "interrupted") return 130;
|
|
182
|
+
return run.stopReason === "no-fixable-findings" ? 0 : 1;
|
|
183
|
+
}
|
package/src/cli/deps.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera deps [path]`: CLI runner for the dependency capability scan.
|
|
3
|
+
*/
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { mergeResults } from "./merge-results.js";
|
|
8
|
+
import { buildActionPlan } from "../../lib/remediation/index.js";
|
|
9
|
+
import { printReport, printFindings, writeJsonReport, writeSarifReport, createProgress, printError } from "../reporter.js";
|
|
10
|
+
import { writeMarkdownReport, markdownPathFromJson } from "../report/markdown.js";
|
|
11
|
+
|
|
12
|
+
export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod", "license-policy"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The license policy for this run: `--license-policy <preset|file>`, else the
|
|
16
|
+
* `licenses` section of penthera.policy.json, else none.
|
|
17
|
+
*/
|
|
18
|
+
async function resolveLicensePolicy(root, flag) {
|
|
19
|
+
const { normalizeLicensePolicy, LICENSE_PRESETS } = await import("../../lib/deps/license.js");
|
|
20
|
+
if (flag) {
|
|
21
|
+
if (LICENSE_PRESETS[flag]) return normalizeLicensePolicy({ preset: flag }, "--license-policy");
|
|
22
|
+
const path = resolve(flag);
|
|
23
|
+
if (!existsSync(path)) throw new Error(`--license-policy: "${flag}" is neither a preset (${Object.keys(LICENSE_PRESETS).join(", ")}) nor a file`);
|
|
24
|
+
let raw;
|
|
25
|
+
try { raw = JSON.parse(readFileSync(path, "utf-8")); } catch (e) { throw new Error(`${flag}: ${e.message}`); }
|
|
26
|
+
return normalizeLicensePolicy(raw && typeof raw === "object" && "licenses" in raw ? raw.licenses : raw, `${flag}: licenses`);
|
|
27
|
+
}
|
|
28
|
+
const { loadPolicy } = await import("../../lib/install/policy.js");
|
|
29
|
+
return loadPolicy(root).policy.licenses;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runDeps(args, opts, { version }) {
|
|
33
|
+
if (args.length > 1) {
|
|
34
|
+
printError(`deps takes one path, got: ${args.join(" ")}`);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
if (opts.diff && opts.base) {
|
|
38
|
+
printError("Use --diff <lockfile|dir> or --base <git-ref>, not both.");
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
const root = resolve(args[0] || ".");
|
|
42
|
+
if (!existsSync(root)) {
|
|
43
|
+
printError(`Path not found: ${root}`);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
const quiet = opts.quiet || opts.json;
|
|
47
|
+
if (opts.online && !quiet) {
|
|
48
|
+
process.stderr.write("\n \x1b[33mNOTE\x1b[0m --online fetches public package metadata from the npm registry.\n" +
|
|
49
|
+
" This is the only network use in `penthera deps`; without it, nothing leaves this machine.\n\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { scanDeps } = await import("../../lib/deps/index.js");
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
const licensePolicy = await resolveLicensePolicy(root, opts["license-policy"]);
|
|
56
|
+
result = await scanDeps(root, {
|
|
57
|
+
licensePolicy,
|
|
58
|
+
diff: opts.diff || null,
|
|
59
|
+
base: opts.base || null,
|
|
60
|
+
online: !!opts.online,
|
|
61
|
+
prod: !!opts.prod,
|
|
62
|
+
onPhase: createProgress(quiet),
|
|
63
|
+
});
|
|
64
|
+
} catch (e) {
|
|
65
|
+
printError(e.message);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const merged = mergeResults([result]);
|
|
70
|
+
merged.actionPlan = buildActionPlan(merged.findings);
|
|
71
|
+
|
|
72
|
+
if (opts.json) console.log(JSON.stringify(merged, null, 2));
|
|
73
|
+
else if (opts.quiet) printFindings(merged.findings);
|
|
74
|
+
else printReport(merged, { verbose: opts.verbose });
|
|
75
|
+
|
|
76
|
+
const mdOpts = { version };
|
|
77
|
+
const note = (s) => { if (!quiet) process.stderr.write(s); };
|
|
78
|
+
if (opts.output) {
|
|
79
|
+
writeJsonReport(merged, opts.output);
|
|
80
|
+
const md = markdownPathFromJson(opts.output);
|
|
81
|
+
writeMarkdownReport(merged, md, mdOpts);
|
|
82
|
+
note(` JSON report ${opts.output}\n Markdown report ${md}\n`);
|
|
83
|
+
}
|
|
84
|
+
if (opts.markdown) {
|
|
85
|
+
writeMarkdownReport(merged, opts.markdown, mdOpts);
|
|
86
|
+
note(` Markdown report ${opts.markdown}\n`);
|
|
87
|
+
}
|
|
88
|
+
if (opts.sarif) {
|
|
89
|
+
writeSarifReport(merged, opts.sarif);
|
|
90
|
+
note(` SARIF report ${opts.sarif}\n`);
|
|
91
|
+
}
|
|
92
|
+
if (opts["pr-comment"]) {
|
|
93
|
+
const { formatPrComment } = await import("../../lib/deps/report.js");
|
|
94
|
+
const path = resolve(opts["pr-comment"]);
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
writeFileSync(path, formatPrComment(merged, { version }));
|
|
97
|
+
note(` PR comment ${opts["pr-comment"]}\n`);
|
|
98
|
+
}
|
|
99
|
+
if (opts.output || opts.markdown || opts.sarif || opts["pr-comment"]) note("\n");
|
|
100
|
+
|
|
101
|
+
// exitCode, not exit(): large --json output to a pipe must drain first.
|
|
102
|
+
const hasSevere = merged.findings.some((f) => f.severity === "critical" || f.severity === "high");
|
|
103
|
+
process.exitCode = hasSevere ? 1 : 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera evidence verify <dir> [--allowed-signers <file>] [--json]`
|
|
3
|
+
*
|
|
4
|
+
* Recomputes every artifact hash in an evidence bundle and checks the SSH
|
|
5
|
+
* signature over manifest.json. Exit 0 when the bundle verifies, 1 when it
|
|
6
|
+
* does not, 2 on a usage error.
|
|
7
|
+
*/
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { verifyEvidence } from "../report/evidence.js";
|
|
10
|
+
|
|
11
|
+
const HELP = `
|
|
12
|
+
penthera evidence verify <dir> [--allowed-signers <file>] [--json]
|
|
13
|
+
|
|
14
|
+
Recomputes the SHA-256 of every file in an evidence bundle (written with
|
|
15
|
+
--evidence <dir>), fails on any changed, missing or extra file, and checks
|
|
16
|
+
manifest.json.sig with ssh-keygen against the allowed signers file.
|
|
17
|
+
|
|
18
|
+
Exit codes: 0 verified, 1 verification failed, 2 usage error.
|
|
19
|
+
See docs/evidence.md.
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export function runEvidence(argv, { out = process.stdout } = {}) {
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = parseArgs({
|
|
26
|
+
args: argv,
|
|
27
|
+
allowPositionals: true,
|
|
28
|
+
options: {
|
|
29
|
+
"allowed-signers": { type: "string" },
|
|
30
|
+
json: { type: "boolean", default: false },
|
|
31
|
+
help: { type: "boolean", short: "h", default: false },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
} catch (e) {
|
|
35
|
+
process.stderr.write(` ${e.message}\n${HELP}`);
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
const { values, positionals } = parsed;
|
|
39
|
+
if (values.help || positionals[0] !== "verify" || positionals.length !== 2) {
|
|
40
|
+
(values.help ? out : process.stderr).write(HELP);
|
|
41
|
+
return values.help ? 0 : 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const r = verifyEvidence(positionals[1], { allowedSigners: values["allowed-signers"] || null });
|
|
45
|
+
if (values.json) {
|
|
46
|
+
const { manifest, ...rest } = r;
|
|
47
|
+
out.write(JSON.stringify({ ...rest, target: manifest?.target || null, generated_at: manifest?.generated_at || null }, null, 2) + "\n");
|
|
48
|
+
return r.ok ? 0 : 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const m = r.manifest;
|
|
52
|
+
if (m) {
|
|
53
|
+
out.write(`\n Bundle ${positionals[1]}\n`);
|
|
54
|
+
out.write(` Target ${m.target}\n`);
|
|
55
|
+
out.write(` Generated ${m.generated_at} by penthera ${m.tool?.version}\n`);
|
|
56
|
+
if (m.git) out.write(` Git ${m.git.commit}${m.git.dirty ? " (uncommitted changes)" : ""}\n`);
|
|
57
|
+
out.write("\n");
|
|
58
|
+
}
|
|
59
|
+
for (const f of r.files) out.write(` ${f.ok ? "ok " : "FAIL"} ${f.path}${f.ok ? "" : ` (${f.reason})`}\n`);
|
|
60
|
+
for (const p of r.unlisted) out.write(` FAIL ${p} (not in manifest)\n`);
|
|
61
|
+
if (r.signature) {
|
|
62
|
+
const label = r.signature.ok ? (r.signature.status === "valid" ? "ok " : "warn") : "FAIL";
|
|
63
|
+
out.write(` ${label} signature: ${r.signature.detail}\n`);
|
|
64
|
+
}
|
|
65
|
+
out.write(r.ok ? "\n Verified: every artifact matches the manifest.\n\n" : `\n NOT VERIFIED:\n${r.problems.map((p) => ` - ${p}\n`).join("")}\n`);
|
|
66
|
+
return r.ok ? 0 : 1;
|
|
67
|
+
}
|
package/src/cli/fix.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera fix`: plan and apply deterministic fix recipes, locally.
|
|
3
|
+
*
|
|
4
|
+
* penthera fix --plan [--repo <path>] [-o <report.json>]
|
|
5
|
+
* penthera fix --apply <id> [--repo <path>] [-o <report.json>] [--yes] [--dry-run] [--allow-dirty]
|
|
6
|
+
*
|
|
7
|
+
* Planning never writes. Applying needs an explicit plan id plus approval (a
|
|
8
|
+
* y/N prompt, or --yes), never pushes, and refuses a dirty git tree. Nothing
|
|
9
|
+
* here touches the network, except the optional --fix-model fallback, which
|
|
10
|
+
* only talks to Ollama on 127.0.0.1.
|
|
11
|
+
*/
|
|
12
|
+
import { parseArgs } from "node:util";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
15
|
+
import { scanRepo } from "../scan-repo.js";
|
|
16
|
+
import { planFixes, probeRepo, redactPlan, applyDiff, findingKey, createModelFixer } from "../../lib/fix/index.js";
|
|
17
|
+
import { bold, dim, green, red, yellow, cyan } from "./ansi.js";
|
|
18
|
+
|
|
19
|
+
const HELP = `
|
|
20
|
+
${bold("penthera fix")} — plan and apply local fix recipes
|
|
21
|
+
|
|
22
|
+
${dim("Usage")}
|
|
23
|
+
$ penthera fix --plan [--repo <path>] Print a diff for each fixable finding
|
|
24
|
+
$ penthera fix --apply <id> [--repo <path>] Apply one planned diff (asks first)
|
|
25
|
+
|
|
26
|
+
${dim("Options")}
|
|
27
|
+
-r, --repo <path> Repo to fix (default: .)
|
|
28
|
+
-o, --input <file> Use findings from a JSON report (penthera ... -o report.json)
|
|
29
|
+
instead of a fresh repo scan
|
|
30
|
+
--fix-model <name> Ask a local Ollama model (127.0.0.1:11434 only) when no recipe
|
|
31
|
+
matches. Off by default; output needs human review
|
|
32
|
+
--json Print plans as JSON (secrets masked)
|
|
33
|
+
--yes Approve the diff without the y/N prompt (for --apply)
|
|
34
|
+
--dry-run Check that the diff applies, write nothing
|
|
35
|
+
--allow-dirty Apply even with uncommitted changes in the repo
|
|
36
|
+
-h, --help Show this help
|
|
37
|
+
|
|
38
|
+
Recipes: security headers, cookie flags, CORS allowlist, login rate limit,
|
|
39
|
+
hardcoded secret to env var. Plans change after each apply: run --plan again.
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
/** Findings for the repo: a report's findings, or a fresh scan plus recipe probes. */
|
|
43
|
+
async function gatherFindings(repo, input) {
|
|
44
|
+
if (input) {
|
|
45
|
+
const report = JSON.parse(readFileSync(resolve(input), "utf-8"));
|
|
46
|
+
return { findings: report.findings || [], fresh: false };
|
|
47
|
+
}
|
|
48
|
+
const result = await scanRepo(repo, {});
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
const findings = [...result.findings, ...probeRepo(repo)].filter((f) => {
|
|
51
|
+
if (f.severity === "info" && f.category === "config") return false;
|
|
52
|
+
const k = findingKey(f);
|
|
53
|
+
if (seen.has(k)) return false;
|
|
54
|
+
seen.add(k);
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
return { findings, fresh: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function colorDiff(diff) {
|
|
61
|
+
return diff.split("\n").map((l) => {
|
|
62
|
+
if (/^(---|\+\+\+) /.test(l)) return bold(l);
|
|
63
|
+
if (l.startsWith("@@")) return cyan(l);
|
|
64
|
+
if (l.startsWith("+")) return green(l);
|
|
65
|
+
if (l.startsWith("-")) return red(l);
|
|
66
|
+
return l;
|
|
67
|
+
}).join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function printPlan(plan) {
|
|
71
|
+
const conf = plan.confidence === "needs-human-review" ? yellow(plan.confidence) : plan.confidence;
|
|
72
|
+
process.stdout.write(`\n ${bold(plan.id)} ${plan.title}${plan.framework ? dim(` (${plan.framework})`) : ""} confidence: ${conf} source: ${plan.source}\n`);
|
|
73
|
+
for (const f of plan.findings) process.stdout.write(` ${dim("fixes")} [${f.severity || "?"}] ${f.title}${f.url ? dim(` ${f.url}`) : ""}\n`);
|
|
74
|
+
for (const a of plan.ownerActions) process.stdout.write(` ${yellow("owner action")} ${a}\n`);
|
|
75
|
+
process.stdout.write("\n" + colorDiff(plan.diff).replace(/^/gm, " ").trimEnd() + "\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function approve(plan, opts) {
|
|
79
|
+
if (opts.yes) return true;
|
|
80
|
+
if (!process.stdin.isTTY) {
|
|
81
|
+
process.stderr.write(` Not applying ${plan.id}: review the diff above, then re-run with --yes to approve it.\n`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const { initPrompt, confirm, closePrompt } = await import("./prompt.js");
|
|
85
|
+
await initPrompt();
|
|
86
|
+
const ok = await confirm(`\n Apply ${plan.id} to the working tree?`, false);
|
|
87
|
+
closePrompt();
|
|
88
|
+
return ok;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runFix(argv) {
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = parseArgs({
|
|
95
|
+
args: argv,
|
|
96
|
+
allowPositionals: false,
|
|
97
|
+
options: {
|
|
98
|
+
plan: { type: "boolean", default: false },
|
|
99
|
+
apply: { type: "string" },
|
|
100
|
+
repo: { type: "string", short: "r" },
|
|
101
|
+
input: { type: "string", short: "o" },
|
|
102
|
+
"fix-model": { type: "string" },
|
|
103
|
+
json: { type: "boolean", default: false },
|
|
104
|
+
yes: { type: "boolean", default: false },
|
|
105
|
+
"dry-run": { type: "boolean", default: false },
|
|
106
|
+
"allow-dirty": { type: "boolean", default: false },
|
|
107
|
+
help: { type: "boolean", short: "h", default: false },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
process.stderr.write(` ${red("Error")} ${e.message}\n${HELP}`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
const opts = parsed.values;
|
|
115
|
+
if (opts.help) {
|
|
116
|
+
console.log(HELP);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const repo = resolve(opts.repo || ".");
|
|
121
|
+
if (!existsSync(repo) || !statSync(repo).isDirectory()) {
|
|
122
|
+
process.stderr.write(` ${red("Error")} Repo not found: ${repo}\n`);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let model = null;
|
|
127
|
+
if (opts["fix-model"]) model = createModelFixer({ model: opts["fix-model"] });
|
|
128
|
+
|
|
129
|
+
let gathered;
|
|
130
|
+
try {
|
|
131
|
+
gathered = await gatherFindings(repo, opts.input);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
process.stderr.write(` ${red("Error")} Could not read findings: ${e.message}\n`);
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
const { plans, unmatched } = await planFixes(repo, gathered.findings, { model });
|
|
137
|
+
|
|
138
|
+
if (!opts.apply) {
|
|
139
|
+
if (opts.json) {
|
|
140
|
+
console.log(JSON.stringify({ repo, plans: plans.map(redactPlan), unmatched: unmatched.map((f) => ({ title: f.title, category: f.category, severity: f.severity, url: f.url || null })) }, null, 2));
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write(`\n ${bold("penthera fix")} ${dim(repo)}\n`);
|
|
144
|
+
process.stdout.write(` ${plans.length} fix${plans.length === 1 ? "" : "es"} planned for ${gathered.findings.length} finding${gathered.findings.length === 1 ? "" : "s"}${gathered.fresh ? " (fresh repo scan)" : ` (from ${opts.input})`}\n`);
|
|
145
|
+
for (const p of plans) printPlan(redactPlan(p));
|
|
146
|
+
const manual = unmatched.filter((f) => f.severity !== "info");
|
|
147
|
+
if (manual.length) {
|
|
148
|
+
process.stdout.write(`\n ${dim(`${manual.length} finding${manual.length === 1 ? "" : "s"} without a recipe; fix by hand (see the report's action plan)${model ? "" : " or try --fix-model <name>"}:`)}\n`);
|
|
149
|
+
for (const f of manual.slice(0, 20)) process.stdout.write(` [${f.severity}] ${f.title}\n`);
|
|
150
|
+
}
|
|
151
|
+
if (plans.length) process.stdout.write(`\n Apply one: penthera fix --apply <id>${opts.repo ? ` --repo ${opts.repo}` : ""}${opts.input ? ` -o ${opts.input}` : ""}\n\n`);
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const plan = plans.find((p) => p.id === opts.apply);
|
|
156
|
+
if (!plan) {
|
|
157
|
+
process.stderr.write(` ${red("Error")} No plan with id ${opts.apply}. Plans change after each apply; run penthera fix --plan again.\n`);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
printPlan(redactPlan(plan));
|
|
161
|
+
if (!opts["dry-run"] && !(await approve(plan, opts))) process.exit(1);
|
|
162
|
+
|
|
163
|
+
let result;
|
|
164
|
+
try {
|
|
165
|
+
result = applyDiff(repo, plan.diff, { dryRun: opts["dry-run"], allowDirty: opts["allow-dirty"] });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
process.stderr.write(` ${red("Not applied")} ${e.message}\n`);
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
const verb = result.applied ? "Applied" : "Dry run, would change";
|
|
171
|
+
process.stdout.write(`\n ${green(verb)} ${result.files.map((f) => f.path + (f.created ? " (new)" : "")).join(", ")}\n`);
|
|
172
|
+
if (!result.applied) process.exit(0);
|
|
173
|
+
|
|
174
|
+
// Verify by re-scanning. Report findings came from a scan we can't repeat here.
|
|
175
|
+
if (gathered.fresh) {
|
|
176
|
+
const after = await gatherFindings(repo, null);
|
|
177
|
+
const still = new Set(after.findings.map(findingKey));
|
|
178
|
+
for (const f of plan.findings) {
|
|
179
|
+
const gone = !still.has(findingKey(f));
|
|
180
|
+
process.stdout.write(` ${gone ? green("verified") : yellow("still reported")} ${f.title}\n`);
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
process.stdout.write(` Re-run the scan that produced ${opts.input} to verify.\n`);
|
|
184
|
+
}
|
|
185
|
+
if (plan.ownerActions.length) {
|
|
186
|
+
process.stdout.write(`\n ${yellow("Still on you:")}\n`);
|
|
187
|
+
for (const a of plan.ownerActions) process.stdout.write(` - ${a}\n`);
|
|
188
|
+
}
|
|
189
|
+
process.stdout.write(`\n Nothing was committed or pushed. Review with git diff, then commit it yourself.\n\n`);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
package/src/cli/hook.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera hook install|uninstall|run`: the pre-commit hook (lib/hook/).
|
|
3
|
+
* Exit codes for `run`: 0 commit may go ahead, 1 blocked, 2 error (also blocks).
|
|
4
|
+
*/
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { relative, resolve } from "node:path";
|
|
7
|
+
import { runHook, installHook, uninstallHook, fixHint } from "../../lib/hook/index.js";
|
|
8
|
+
import { printError } from "../reporter.js";
|
|
9
|
+
import { bold, dim, green, red, yellow, cyan } from "./ansi.js";
|
|
10
|
+
|
|
11
|
+
const HELP = `
|
|
12
|
+
${bold("penthera hook")} — block secrets, malicious agent config and risky lockfile changes before they are committed
|
|
13
|
+
|
|
14
|
+
${dim("Usage")}
|
|
15
|
+
$ penthera hook install Write .git/hooks/pre-commit (an existing hook is kept and runs first)
|
|
16
|
+
$ penthera hook uninstall Remove it and restore the hook it replaced
|
|
17
|
+
$ penthera hook run Check what is staged now (what the hook runs)
|
|
18
|
+
|
|
19
|
+
${dim("Options")}
|
|
20
|
+
--policy <file> Policy file (default: ./penthera.policy.json)
|
|
21
|
+
--force install: write into core.hooksPath even when another tool manages it
|
|
22
|
+
--json run: print the result as JSON
|
|
23
|
+
-v, --verbose run: also list findings below the blocking severity
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
|
|
26
|
+
${dim("What it checks (staged content only, offline)")}
|
|
27
|
+
Secrets and AI provider keys in staged files; \`penthera agents\` rules on staged
|
|
28
|
+
.mcp.json, CLAUDE.md, AGENTS.md, .claude/, .cursor/ and skills/; and, when
|
|
29
|
+
package-lock.json is staged, the capability diff against HEAD plus the license
|
|
30
|
+
policy. Blocks on high and critical by default.
|
|
31
|
+
|
|
32
|
+
${dim("Policy")} ${dim("(penthera.policy.json)")}
|
|
33
|
+
{ "hook": { "blockOn": "high", "ignore": ["tests/fixtures/**"],
|
|
34
|
+
"checks": { "secrets": true, "agents": true, "deps": true, "licenses": true } } }
|
|
35
|
+
|
|
36
|
+
${dim("Other hook managers")}
|
|
37
|
+
pre-commit: repo: https://github.com/danoszz/penthera, hooks: [{ id: penthera }]
|
|
38
|
+
husky: echo "npx --no-install penthera hook run" >> .husky/pre-commit
|
|
39
|
+
lefthook: pre-commit: { commands: { penthera: { run: npx --no-install penthera hook run } } }
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const SEV = { critical: red, high: red, medium: yellow, low: dim, info: dim };
|
|
43
|
+
const where = (f) => `${f.url}${f.line ? `:${f.line}` : ""}`;
|
|
44
|
+
|
|
45
|
+
function findingLine(f, mark) {
|
|
46
|
+
const sev = (SEV[f.severity] || dim)(f.severity.toUpperCase().padEnd(8));
|
|
47
|
+
const ev = f.evidence ? ` ${dim(f.evidence)}` : "";
|
|
48
|
+
return ` ${mark} ${sev} ${cyan(where(f))} ${f.title}${ev} ${dim(`id ${f.id}`)}\n ${dim("→")} ${fixHint(f)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printRun(r, { verbose }) {
|
|
52
|
+
const L = [];
|
|
53
|
+
const secs = (r.duration / 1000).toFixed(1);
|
|
54
|
+
const what = [`${r.staged} staged file${r.staged === 1 ? "" : "s"}`, r.checked.agents && `${r.checked.agents} agent config`, r.checked.lockfiles && `${r.checked.lockfiles} lockfile`].filter(Boolean).join(", ");
|
|
55
|
+
for (const w of r.warnings) L.push(` ${yellow("!")} ${w}`);
|
|
56
|
+
if (!r.blocking.length) {
|
|
57
|
+
const below = r.below.length ? dim(` · ${r.below.length} below ${r.blockOn}${verbose ? "" : " (-v to list)"}`) : "";
|
|
58
|
+
const sup = r.suppressed.length ? dim(` · ${r.suppressed.length} suppressed`) : "";
|
|
59
|
+
L.push(` ${green("✓")} ${bold("penthera")} ${what} checked in ${secs}s${below}${sup}`);
|
|
60
|
+
if (verbose) for (const f of r.below) L.push(findingLine(f, yellow("!")));
|
|
61
|
+
process.stderr.write(L.join("\n") + "\n");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
L.push("");
|
|
65
|
+
L.push(` ${red("✗")} ${bold("penthera")} blocked this commit: ${r.blocking.length} finding${r.blocking.length === 1 ? "" : "s"} at ${r.blockOn} or above ${dim(`(${what}, ${secs}s)`)}`);
|
|
66
|
+
L.push("");
|
|
67
|
+
for (const f of r.blocking) L.push(findingLine(f, red("✗")));
|
|
68
|
+
if (verbose) for (const f of r.below) L.push(findingLine(f, yellow("!")));
|
|
69
|
+
else if (r.below.length) L.push(dim(` + ${r.below.length} below ${r.blockOn}, not blocking (-v to list)`));
|
|
70
|
+
L.push("");
|
|
71
|
+
L.push(` Fix and \`git add\` again. A false positive? Add it to penthera.suppress.json:`);
|
|
72
|
+
L.push(dim(` { "version": 1, "suppress": [{ "id": "${r.blocking[0].id}", "reason": "why this is safe" }] }`));
|
|
73
|
+
L.push(` To skip the check for one commit: ${bold("git commit --no-verify")} ${dim("(the finding still ships)")}`);
|
|
74
|
+
L.push("");
|
|
75
|
+
process.stderr.write(L.join("\n") + "\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function runHookCommand(argv) {
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = parseArgs({
|
|
82
|
+
args: argv,
|
|
83
|
+
allowPositionals: true,
|
|
84
|
+
options: {
|
|
85
|
+
policy: { type: "string" },
|
|
86
|
+
force: { type: "boolean", default: false },
|
|
87
|
+
json: { type: "boolean", default: false },
|
|
88
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
89
|
+
help: { type: "boolean", short: "h", default: false },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
} catch (e) {
|
|
93
|
+
printError(e.message);
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
const { values: opts, positionals } = parsed;
|
|
97
|
+
const sub = positionals[0];
|
|
98
|
+
if (opts.help || !sub) {
|
|
99
|
+
console.log(HELP);
|
|
100
|
+
return sub || opts.help ? 0 : 2;
|
|
101
|
+
}
|
|
102
|
+
const cwd = process.cwd();
|
|
103
|
+
const rel = (p) => relative(cwd, p) || p;
|
|
104
|
+
try {
|
|
105
|
+
if (sub === "install") {
|
|
106
|
+
const r = installHook(cwd, { force: opts.force });
|
|
107
|
+
console.log(` ${green("✓")} ${r.updated ? "Updated" : "Installed"} ${rel(r.path)}`);
|
|
108
|
+
if (r.chained) console.log(` ${dim("▸")} Your existing hook moved to ${rel(r.chained)} and runs first; \`penthera hook uninstall\` puts it back.`);
|
|
109
|
+
console.log(` ${dim("▸")} Every commit now checks staged secrets, agent config and lockfile changes. Skip once with git commit --no-verify.`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
if (sub === "uninstall") {
|
|
113
|
+
const r = uninstallHook(cwd);
|
|
114
|
+
if (!r.removed) console.log(` ${dim("No penthera pre-commit hook installed.")}`);
|
|
115
|
+
else console.log(` ${green("✓")} Removed ${rel(r.path)}${r.restored ? `; your previous hook is back in place` : ""}`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (sub === "run") {
|
|
119
|
+
const r = await runHook(cwd, { policyPath: opts.policy ? resolve(opts.policy) : null });
|
|
120
|
+
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
121
|
+
else printRun(r, { verbose: opts.verbose });
|
|
122
|
+
return r.blocking.length ? 1 : 0;
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
printError(e.message);
|
|
126
|
+
if (sub === "run") process.stderr.write(` ${dim("The commit is blocked because the check could not run. Skip it once with git commit --no-verify.")}\n`);
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
printError(`Unknown hook command "${sub}". Use install, uninstall or run.`);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|