penthera 1.1.1 → 1.4.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 -3
- package/README.md +489 -286
- package/SECURITY.md +28 -6
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-nis2.md +166 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- 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 +15 -3
- package/docs/local-loop.md +276 -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/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 +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -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/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -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/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +19 -9
- package/skills/penthera/SKILL.md +104 -23
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- 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 +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -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 +152 -6
- 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/scripts/package-skill.sh +0 -16
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera install [pkg...]`: the install firewall CLI (lib/install/).
|
|
3
|
+
*
|
|
4
|
+
* Shows what an install would change (new packages, capabilities gained, new
|
|
5
|
+
* install scripts and their commands), applies penthera.policy.json, and only
|
|
6
|
+
* then runs the real `npm install`. Exit codes: 0 installed (or a clean
|
|
7
|
+
* --dry-run), 1 blocked or aborted (project untouched), 2 error.
|
|
8
|
+
*/
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { resolve, relative } from "node:path";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { planInstall, applyInstall, scriptChanges, npmInfo } from "../../lib/install/index.js";
|
|
13
|
+
import { loadPolicy, RULE_LABELS, POLICY_FILE } from "../../lib/install/policy.js";
|
|
14
|
+
import { createProgress, printError } from "../reporter.js";
|
|
15
|
+
import { initPrompt, ask, closePrompt } from "./prompt.js";
|
|
16
|
+
import { bold, dim, green, red, yellow, cyan } from "./ansi.js";
|
|
17
|
+
|
|
18
|
+
const HELP = `
|
|
19
|
+
${bold("penthera install")} — install npm packages through a capability firewall
|
|
20
|
+
|
|
21
|
+
${dim("Usage")}
|
|
22
|
+
$ penthera install <pkg> [<pkg>...] Check, then install (asks when the policy says so)
|
|
23
|
+
$ penthera install Same, for what package.json already lists
|
|
24
|
+
|
|
25
|
+
${dim("Options")}
|
|
26
|
+
-y, --yes Never prompt: install only if nothing breaches the policy (CI)
|
|
27
|
+
--dry-run Report only; never install
|
|
28
|
+
--policy <file> Policy file (default: ./${POLICY_FILE}, else built-in defaults)
|
|
29
|
+
--dir <path> Project folder (default: .)
|
|
30
|
+
-D, --save-dev Save as devDependencies
|
|
31
|
+
-E, --save-exact Save exact versions
|
|
32
|
+
--offline npm resolves from its cache only (no registry traffic at all)
|
|
33
|
+
--json Print the plan as JSON
|
|
34
|
+
-v, --verbose Show more evidence per capability
|
|
35
|
+
-h, --help Show this help
|
|
36
|
+
|
|
37
|
+
${dim("How it works")}
|
|
38
|
+
npm resolves the new tree in a temporary copy with scripts disabled, new
|
|
39
|
+
tarballs go into npm's cache (integrity-verified, never unpacked or run),
|
|
40
|
+
and the \`penthera deps\` engine diffs capabilities against your lockfile.
|
|
41
|
+
Nothing in your project changes until you approve. Approved install scripts
|
|
42
|
+
run; otherwise the install uses --ignore-scripts.
|
|
43
|
+
|
|
44
|
+
${dim("Policy")} ${dim(`(${POLICY_FILE})`)}
|
|
45
|
+
{ "allow": ["@acme/*", "esbuild"], "deny": ["token-harvest"],
|
|
46
|
+
"requireApproval": ["install-scripts", "ai-config-write", "ai-cli-invoke",
|
|
47
|
+
"token-harvest", "obfuscated", "typosquat", "unscanned"] }
|
|
48
|
+
The requireApproval list above is the default.
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const label = (id) => RULE_LABELS[id] || id;
|
|
52
|
+
const at = (i) => `${i.name}@${i.version}`;
|
|
53
|
+
|
|
54
|
+
function printPlan(plan, { policyPath, verbose }) {
|
|
55
|
+
const L = [];
|
|
56
|
+
const { items, denied, needsApproval } = plan.verdict;
|
|
57
|
+
const added = items.filter((i) => i.change === "added");
|
|
58
|
+
const upgraded = items.filter((i) => i.change === "upgraded");
|
|
59
|
+
L.push("");
|
|
60
|
+
L.push(` ${bold("Changes")} ${added.length} added · ${upgraded.length} upgraded · ${plan.removed.length} removed`);
|
|
61
|
+
L.push("");
|
|
62
|
+
if (items.length === 0 && plan.removed.length === 0) L.push(` ${green("✓")} Nothing changes: the lockfile already has this.`);
|
|
63
|
+
for (const i of [...upgraded, ...added]) {
|
|
64
|
+
const head = i.change === "upgraded"
|
|
65
|
+
? `${yellow("▲")} ${bold(i.name)} ${i.from} → ${i.version}`
|
|
66
|
+
: `${cyan("+")} ${bold(i.name)} ${i.version} ${dim("(new)")}`;
|
|
67
|
+
const shown = i.flags.length ? i.flags : i.capabilities;
|
|
68
|
+
const note = shown.length
|
|
69
|
+
? (i.change === "upgraded" ? red(`+ ${shown.map(label).join(", ")}`) : shown.map(label).join(", "))
|
|
70
|
+
: dim(i.change === "upgraded" ? (i.compared ? "no new capabilities" : "old version not readable") : "no notable capabilities");
|
|
71
|
+
L.push(` ${head} ${note}`);
|
|
72
|
+
if (i.typosquatOf) L.push(` ${yellow(`looks like ${i.typosquatOf}`)}: check this is the package you meant`);
|
|
73
|
+
if (i.flags.includes("install-scripts")) {
|
|
74
|
+
for (const [hook, cmd] of Object.entries(i.installScripts || {})) {
|
|
75
|
+
const was = i.previousInstallScripts?.[hook];
|
|
76
|
+
L.push(` ${red(hook)}: ${cmd}${was && was !== cmd ? dim(` (was: ${was})`) : ""}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const ev = plan.evidence?.[at(i)] || {};
|
|
80
|
+
for (const id of i.flags.filter((f) => f !== "install-scripts")) {
|
|
81
|
+
for (const e of (ev[id] || []).slice(0, verbose ? 3 : 1)) L.push(` ${dim(`${e.file}:${e.line}`)} ${e.snippet}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const r of plan.removed) L.push(` ${dim("−")} ${r.name} ${r.version} ${dim("(removed)")}`);
|
|
85
|
+
|
|
86
|
+
L.push("");
|
|
87
|
+
L.push(` ${bold("Policy")} ${policyPath ? relative(process.cwd(), policyPath) || policyPath : dim(`defaults (no ${POLICY_FILE})`)}`);
|
|
88
|
+
for (const i of denied) L.push(` ${red("✗")} ${at(i)} ${red("denied")}: ${i.deny.map(label).join(", ")}`);
|
|
89
|
+
for (const i of needsApproval) L.push(` ${yellow("!")} ${at(i)} needs approval: ${i.approval.map(label).join(", ")}`);
|
|
90
|
+
for (const i of items.filter((x) => x.allowedBy && x.flags.length)) L.push(` ${green("✓")} ${at(i)} allowed by "${i.allowedBy}"`);
|
|
91
|
+
if (plan.verdict.clean) L.push(` ${green("✓")} Nothing breaches the policy.`);
|
|
92
|
+
L.push("");
|
|
93
|
+
process.stdout.write(L.join("\n") + "\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function planJson(plan, extra) {
|
|
97
|
+
return {
|
|
98
|
+
root: plan.root,
|
|
99
|
+
specs: plan.specs,
|
|
100
|
+
npm: { version: plan.npm.version, registry: plan.npm.registry },
|
|
101
|
+
clean: plan.verdict.clean,
|
|
102
|
+
changes: plan.verdict.items,
|
|
103
|
+
removed: plan.removed,
|
|
104
|
+
typosquats: plan.typosquats,
|
|
105
|
+
...extra,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function runInstall(argv) {
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = parseArgs({
|
|
113
|
+
args: argv,
|
|
114
|
+
allowPositionals: true,
|
|
115
|
+
options: {
|
|
116
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
117
|
+
"dry-run": { type: "boolean", default: false },
|
|
118
|
+
policy: { type: "string" },
|
|
119
|
+
dir: { type: "string" },
|
|
120
|
+
"save-dev": { type: "boolean", short: "D", default: false },
|
|
121
|
+
"save-exact": { type: "boolean", short: "E", default: false },
|
|
122
|
+
offline: { type: "boolean", default: false },
|
|
123
|
+
json: { type: "boolean", default: false },
|
|
124
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
125
|
+
help: { type: "boolean", short: "h", default: false },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
} catch (e) {
|
|
129
|
+
printError(e.message);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
const { values: opts, positionals: specs } = parsed;
|
|
133
|
+
if (opts.help) {
|
|
134
|
+
console.log(HELP);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
const root = resolve(opts.dir || ".");
|
|
138
|
+
if (!existsSync(root)) {
|
|
139
|
+
printError(`Path not found: ${root}`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
const json = opts.json;
|
|
143
|
+
const out = (s) => { if (!json) process.stdout.write(s); };
|
|
144
|
+
|
|
145
|
+
let policy;
|
|
146
|
+
let policyPath;
|
|
147
|
+
try {
|
|
148
|
+
({ policy, path: policyPath } = loadPolicy(root, opts.policy ? resolve(opts.policy) : null));
|
|
149
|
+
} catch (e) {
|
|
150
|
+
printError(e.message);
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let plan;
|
|
155
|
+
try {
|
|
156
|
+
const info = await npmInfo(root);
|
|
157
|
+
out(`\n ${bold("penthera install")} ${specs.length ? specs.join(" ") : dim("(package.json)")} ${dim(`npm ${info.version}`)}\n`);
|
|
158
|
+
out(` ${bold("Network")} ${opts.offline
|
|
159
|
+
? "npm runs with --offline (its cache only). Penthera makes no network calls."
|
|
160
|
+
: `npm contacts ${cyan(info.registry)} to resolve and download. That is the only egress; Penthera itself makes no network calls.`}\n\n`);
|
|
161
|
+
plan = await planInstall(root, specs, {
|
|
162
|
+
policy,
|
|
163
|
+
info,
|
|
164
|
+
saveDev: opts["save-dev"],
|
|
165
|
+
saveExact: opts["save-exact"],
|
|
166
|
+
offline: opts.offline,
|
|
167
|
+
onPhase: createProgress(json),
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
printError(e.message);
|
|
171
|
+
return 2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!json) printPlan(plan, { policyPath, verbose: opts.verbose });
|
|
175
|
+
const { denied, needsApproval, clean } = plan.verdict;
|
|
176
|
+
const untouched = "Project untouched.";
|
|
177
|
+
const finish = (code, action, extra = {}) => {
|
|
178
|
+
if (json) console.log(JSON.stringify(planJson(plan, { action, ...extra }), null, 2));
|
|
179
|
+
return code;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (opts["dry-run"]) {
|
|
183
|
+
out(` ${dim("--dry-run: nothing installed.")}\n\n`);
|
|
184
|
+
return finish(clean ? 0 : 1, "dry-run");
|
|
185
|
+
}
|
|
186
|
+
if (denied.length) {
|
|
187
|
+
out(` ${red("Blocked")}: ${denied.length} package(s) denied by policy. ${untouched}\n Add them to "allow" in ${POLICY_FILE} if you trust them.\n\n`);
|
|
188
|
+
return finish(1, "blocked");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const scripts = scriptChanges(plan);
|
|
192
|
+
let runScripts = true;
|
|
193
|
+
if (!clean) {
|
|
194
|
+
if (opts.yes) {
|
|
195
|
+
out(` ${red("Blocked")}: ${needsApproval.length} package(s) need approval and --yes only installs when nothing breaches the policy. ${untouched}\n\n`);
|
|
196
|
+
return finish(1, "blocked");
|
|
197
|
+
}
|
|
198
|
+
const withScripts = scripts.length > 0;
|
|
199
|
+
await initPrompt();
|
|
200
|
+
const answer = (await ask(withScripts
|
|
201
|
+
? ` Install anyway? [y]es, run the install scripts / [s]kip scripts / [N]o: `
|
|
202
|
+
: ` Install anyway? [y/N]: `)).toLowerCase();
|
|
203
|
+
closePrompt();
|
|
204
|
+
if (answer === "y" || answer === "yes") runScripts = true;
|
|
205
|
+
else if (withScripts && (answer === "s" || answer === "skip")) runScripts = false;
|
|
206
|
+
else {
|
|
207
|
+
out(`\n Aborted. ${untouched}\n\n`);
|
|
208
|
+
return finish(1, "aborted");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
out(`\n ${dim("▸")} npm install${runScripts ? "" : " --ignore-scripts"} ${dim("(installs the reviewed lockfile)")}\n`);
|
|
213
|
+
let applied;
|
|
214
|
+
try {
|
|
215
|
+
applied = await applyInstall(plan, { runScripts, offline: opts.offline });
|
|
216
|
+
} catch (e) {
|
|
217
|
+
printError(e.message);
|
|
218
|
+
return finish(2, "failed");
|
|
219
|
+
}
|
|
220
|
+
if (applied.allowScriptsAdded.length) out(` ${dim("▸")} Approved install scripts pinned in package.json allowScripts: ${applied.allowScriptsAdded.join(", ")}\n`);
|
|
221
|
+
if (!runScripts && scripts.length) out(` ${yellow("!")} Install scripts skipped for ${scripts.map(at).join(", ")}. Run \`npm rebuild <pkg>\` later if a package needs them.\n`);
|
|
222
|
+
if (applied.drift.length) out(` ${yellow("!")} npm's lockfile differs from the reviewed one: ${applied.drift.slice(0, 5).join(", ")}. Run \`penthera deps --base HEAD\` to review.\n`);
|
|
223
|
+
out(` ${green("✓")} Installed.\n\n`);
|
|
224
|
+
return finish(0, "installed", { scriptsRan: runScripts, allowScriptsAdded: applied.allowScriptsAdded, drift: applied.drift });
|
|
225
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera memory show | verify | forget [--all] | suppress <id> --reason <text>`
|
|
3
|
+
*
|
|
4
|
+
* Selects the project the same way a scan does: --repo <path> (default: the
|
|
5
|
+
* current directory) or --url <url> for URL-only scans.
|
|
6
|
+
*/
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
openMemory, projectIdFor, memoryRoot, forgetAll, buildViews,
|
|
11
|
+
} from "../../lib/memory/index.js";
|
|
12
|
+
|
|
13
|
+
const USAGE = `
|
|
14
|
+
Usage
|
|
15
|
+
$ penthera memory show [--repo <path> | --url <url>] [--json]
|
|
16
|
+
$ penthera memory verify [--repo <path> | --url <url>]
|
|
17
|
+
$ penthera memory forget [--repo <path> | --url <url>] [--all]
|
|
18
|
+
$ penthera memory suppress <finding-id> --reason <text> [--expires <date>]
|
|
19
|
+
|
|
20
|
+
Memory lives in ${memoryRoot()} (override with PENTHERA_HOME).
|
|
21
|
+
It is never read from the scanned repo, and nothing in it is ever executed.
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
// Memory content is data from earlier runs: strip control characters so a
|
|
25
|
+
// stored string cannot drive the terminal (ANSI escapes, cursor moves).
|
|
26
|
+
const clean = (s) => String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
27
|
+
|
|
28
|
+
export async function runMemoryCommand(argv, { out = (s) => process.stdout.write(s + "\n"), err = (s) => process.stderr.write(s + "\n") } = {}) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = parseArgs({
|
|
32
|
+
args: argv,
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
options: {
|
|
35
|
+
repo: { type: "string", short: "r" },
|
|
36
|
+
url: { type: "string" },
|
|
37
|
+
all: { type: "boolean", default: false },
|
|
38
|
+
json: { type: "boolean", default: false },
|
|
39
|
+
reason: { type: "string" },
|
|
40
|
+
expires: { type: "string" },
|
|
41
|
+
help: { type: "boolean", short: "h", default: false },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
err(` ${e.message}`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
const { values: opts, positionals } = parsed;
|
|
49
|
+
const cmd = positionals[0];
|
|
50
|
+
if (opts.help || !cmd) { out(USAGE); return cmd || opts.help ? 0 : 2; }
|
|
51
|
+
|
|
52
|
+
if (cmd === "forget" && opts.all) {
|
|
53
|
+
const root = memoryRoot();
|
|
54
|
+
const existed = forgetAll({ root });
|
|
55
|
+
out(existed ? ` Removed all Penthera memory in ${root}` : ` No memory at ${root}`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let project;
|
|
60
|
+
try {
|
|
61
|
+
project = opts.url ? projectIdFor({ url: opts.url }) : projectIdFor({ repo: resolve(opts.repo || ".") });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
err(` ${e.message}`);
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
const mem = openMemory(project.id, { label: project.label });
|
|
67
|
+
|
|
68
|
+
switch (cmd) {
|
|
69
|
+
case "verify": {
|
|
70
|
+
const r = mem.verify();
|
|
71
|
+
if (r.ok) {
|
|
72
|
+
out(` OK ${r.count} event(s), chain intact${r.count ? `, tip ${r.tip.slice(0, 16)}` : ""}`);
|
|
73
|
+
out(` ${mem.file}`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
err(` BROKEN line ${r.brokenAt}: ${r.reason}`);
|
|
77
|
+
err(` ${mem.file}`);
|
|
78
|
+
err(` Memory is ignored by scans until this is fixed. \`penthera memory forget\` starts over.`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case "show": {
|
|
83
|
+
const r = mem.verify();
|
|
84
|
+
const v = r.ok ? buildViews(mem.events()) : null;
|
|
85
|
+
if (opts.json) {
|
|
86
|
+
out(JSON.stringify({ projectId: project.id, file: mem.file, chain: r, views: v }, null, 2));
|
|
87
|
+
return r.ok ? 0 : 1;
|
|
88
|
+
}
|
|
89
|
+
out(` Project ${clean(project.label)}`);
|
|
90
|
+
out(` Id ${project.id}`);
|
|
91
|
+
out(` Log ${mem.file}`);
|
|
92
|
+
out(` Chain ${r.ok ? `ok, ${r.count} event(s)` : `BROKEN at line ${r.brokenAt}: ${r.reason}`}`);
|
|
93
|
+
if (!v) return r.ok ? 0 : 1;
|
|
94
|
+
out(` Scans ${v.scans}`);
|
|
95
|
+
out(` Median time to fix ${v.medianTimeToFixDays == null ? "-" : `${v.medianTimeToFixDays}d`}`);
|
|
96
|
+
out("");
|
|
97
|
+
out(" Suppressions");
|
|
98
|
+
if (!v.suppressions.length) out(" none");
|
|
99
|
+
for (const s of v.suppressions) {
|
|
100
|
+
out(` ${clean(s.id)} ${clean(s.reason)} (by ${clean(s.actor)}${s.expires ? `, until ${clean(s.expires)}` : ""})`);
|
|
101
|
+
}
|
|
102
|
+
out("");
|
|
103
|
+
out(" Recipe success rate");
|
|
104
|
+
if (!v.recipeSuccess.length) out(" none yet");
|
|
105
|
+
for (const x of v.recipeSuccess) {
|
|
106
|
+
const rate = x.rate == null ? "-" : `${Math.round(x.rate * 100)}%`;
|
|
107
|
+
out(` ${clean(x.framework).padEnd(12)} ${clean(x.recipe).padEnd(28)} ${rate.padStart(4)} (${x.verified} ok / ${x.failed} failed / ${x.applied} applied)`);
|
|
108
|
+
}
|
|
109
|
+
out("");
|
|
110
|
+
out(" Probe yield per stack");
|
|
111
|
+
const stacks = Object.keys(v.probeRanking);
|
|
112
|
+
if (!stacks.length) out(" none yet");
|
|
113
|
+
for (const st of stacks) {
|
|
114
|
+
out(` ${clean(st)}`);
|
|
115
|
+
for (const p of v.probeRanking[st].slice(0, 10)) {
|
|
116
|
+
out(` ${clean(p.probe).padEnd(24)} ${p.yield.toFixed(2)} per run (${p.findings} in ${p.runs})`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "forget": {
|
|
123
|
+
const existed = mem.forget();
|
|
124
|
+
out(existed ? ` Removed memory for ${clean(project.label)}` : ` No memory for ${clean(project.label)}`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "suppress": {
|
|
129
|
+
const id = positionals[1];
|
|
130
|
+
if (!id || !/^[0-9a-f]{16}$/.test(id)) { err(" suppress needs a finding id (16 hex chars, the `fid` in --memory reports)"); return 2; }
|
|
131
|
+
const reason = (opts.reason || "").trim();
|
|
132
|
+
if (!reason) { err(" suppress needs --reason <text>"); return 2; }
|
|
133
|
+
if (opts.expires && Number.isNaN(Date.parse(opts.expires))) { err(" --expires is not a date"); return 2; }
|
|
134
|
+
const r = mem.verify();
|
|
135
|
+
if (!r.ok) { err(` Memory chain is broken at line ${r.brokenAt}; not appending. Run \`penthera memory verify\`.`); return 1; }
|
|
136
|
+
mem.append({ type: "fp_suppressed", data: { id, reason, actor: "user", ...(opts.expires ? { expires: opts.expires } : {}) } });
|
|
137
|
+
out(` Suppressed ${id} for ${clean(project.label)}`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
default:
|
|
142
|
+
err(` Unknown memory command "${clean(cmd)}"`);
|
|
143
|
+
out(USAGE);
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/cli/merge-results.js
CHANGED
|
@@ -31,6 +31,8 @@ export function mergeResults(results) {
|
|
|
31
31
|
apiRoot: results.find((r) => r.apiRoot)?.apiRoot || null,
|
|
32
32
|
machine: results.find((r) => r.machine)?.machine || null,
|
|
33
33
|
executedProbes: results.flatMap((r) => r.executedProbes || []),
|
|
34
|
+
egress: results.find((r) => r.egress)?.egress || null,
|
|
35
|
+
errors: results.flatMap((r) => r.errors || []),
|
|
34
36
|
findings: dedupeFindings(results.flatMap((r) => r.findings || [])),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --audience, --report-template and --evidence for the scan command. Kept out
|
|
3
|
+
* of src/cli.js: prepareReportOptions() runs before the scan (so a bad
|
|
4
|
+
* template or a non-empty evidence directory fails fast), customReport()
|
|
5
|
+
* renders the chosen report, writeEvidence() writes the bundle.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { formatAudienceReport, AUDIENCES } from "../report/audience.js";
|
|
10
|
+
import { buildReportModel } from "../report/model.js";
|
|
11
|
+
import { parseTemplate, renderTemplate } from "../report/template.js";
|
|
12
|
+
import { formatMarkdownReport } from "../report/markdown.js";
|
|
13
|
+
import { formatSarif } from "../reporter.js";
|
|
14
|
+
import { formatReadinessMarkdown } from "../../lib/compliance/readiness.js";
|
|
15
|
+
import { writeEvidenceBundle, assertEmptyDir } from "../report/evidence.js";
|
|
16
|
+
|
|
17
|
+
/** Validate the report flags. Returns { template } or throws with a user-facing message. */
|
|
18
|
+
export function prepareReportOptions(opts) {
|
|
19
|
+
if (opts.audience && !AUDIENCES.includes(opts.audience)) {
|
|
20
|
+
throw new Error(`Unknown --audience "${opts.audience}". Use one of: ${AUDIENCES.join(", ")}.`);
|
|
21
|
+
}
|
|
22
|
+
if (opts.audience && opts["report-template"]) throw new Error("Use --audience or --report-template, not both.");
|
|
23
|
+
if (opts["sign-key"] && !opts.evidence) throw new Error("--sign-key signs an evidence bundle; add --evidence <dir>.");
|
|
24
|
+
if (opts["sign-key"] && !existsSync(resolve(opts["sign-key"]))) throw new Error(`--sign-key ${opts["sign-key"]} not found.`);
|
|
25
|
+
if (opts.evidence) assertEmptyDir(resolve(opts.evidence));
|
|
26
|
+
let template = null;
|
|
27
|
+
if (opts["report-template"]) {
|
|
28
|
+
const src = readFileSync(resolve(opts["report-template"]), "utf-8");
|
|
29
|
+
template = parseTemplate(src); // throws TemplateError with a line number
|
|
30
|
+
}
|
|
31
|
+
return { template };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The --audience or --report-template report as Markdown, or null when neither is set. */
|
|
35
|
+
export function customReport(result, opts, { version, template }) {
|
|
36
|
+
if (template) return renderTemplate(template, buildReportModel(result, { version }));
|
|
37
|
+
if (opts.audience) return formatAudienceReport(result, opts.audience, { version });
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write the evidence bundle: JSON, Markdown, SARIF, the three audience reports,
|
|
43
|
+
* the custom template report, readiness and SBOM when they were produced.
|
|
44
|
+
*/
|
|
45
|
+
export function writeEvidence(result, opts, { version, template, mdOpts, sbom, argv, now = new Date().toISOString() }) {
|
|
46
|
+
const artifacts = [
|
|
47
|
+
{ path: "report.json", kind: "json", content: JSON.stringify(result, null, 2) + "\n" },
|
|
48
|
+
{ path: "report.md", kind: "markdown", content: formatMarkdownReport(result, mdOpts) },
|
|
49
|
+
{ path: "report.sarif", kind: "sarif", content: JSON.stringify(formatSarif(result), null, 2) + "\n" },
|
|
50
|
+
...AUDIENCES.map((a) => ({ path: `report-${a}.md`, kind: `audience:${a}`, content: formatAudienceReport(result, a, { version }) })),
|
|
51
|
+
];
|
|
52
|
+
if (template) artifacts.push({ path: "report-custom.md", kind: "template", content: renderTemplate(template, buildReportModel(result, { version })) });
|
|
53
|
+
if (result.readiness) artifacts.push({ path: "nis2-readiness.md", kind: "readiness", content: formatReadinessMarkdown(result.readiness) });
|
|
54
|
+
if (sbom) artifacts.push({ path: "sbom.cdx.json", kind: "sbom", content: JSON.stringify(sbom, null, 2) + "\n" });
|
|
55
|
+
|
|
56
|
+
return writeEvidenceBundle(opts.evidence, artifacts, {
|
|
57
|
+
version,
|
|
58
|
+
argv,
|
|
59
|
+
target: result.target || "unknown",
|
|
60
|
+
generatedAt: now,
|
|
61
|
+
scan: {
|
|
62
|
+
timestamp: result.timestamp || null,
|
|
63
|
+
modes: result.modes || (result.mode ? [result.mode] : []),
|
|
64
|
+
profile: result.profile || null,
|
|
65
|
+
offline: Boolean(result.egress?.offline),
|
|
66
|
+
findings: (result.findings || []).length,
|
|
67
|
+
suppressed: (result.suppressed || []).length,
|
|
68
|
+
},
|
|
69
|
+
repo: opts.repo ? resolve(opts.repo) : null,
|
|
70
|
+
signKey: opts["sign-key"] || null,
|
|
71
|
+
});
|
|
72
|
+
}
|
package/src/cli/run-scan.js
CHANGED