penthera 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
package/src/cli/deps.js
CHANGED
|
@@ -8,8 +8,21 @@ import { mergeResults } from "./merge-results.js";
|
|
|
8
8
|
import { buildActionPlan } from "../../lib/remediation/index.js";
|
|
9
9
|
import { printReport, printFindings, writeJsonReport, writeSarifReport, createProgress, printError } from "../reporter.js";
|
|
10
10
|
import { writeMarkdownReport, markdownPathFromJson } from "../report/markdown.js";
|
|
11
|
+
import { prepareReportOptions, customReport, writeEvidence } from "./report-outputs.js";
|
|
12
|
+
import { compareWithBaseline } from "./baseline.js";
|
|
11
13
|
|
|
12
|
-
export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod", "license-policy"];
|
|
14
|
+
export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod", "license-policy", "venv"];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan-command flags that mean nothing for a dependency scan. Rejected rather
|
|
18
|
+
* than ignored: a flag that silently does nothing reads as a check that ran.
|
|
19
|
+
* (--audience, --report-template, --evidence, --sign-key and --baseline work.)
|
|
20
|
+
*/
|
|
21
|
+
export const DEPS_UNSUPPORTED = [
|
|
22
|
+
"memory", "framework", "readiness", "assessment", "questionnaire", "policy-pack", "org", "history",
|
|
23
|
+
"sbom", "aibom", "repo", "api-root", "recon", "deep", "fuzz", "nuclei", "templates", "adaptive", "all",
|
|
24
|
+
"machine", "home", "pin", "profile", "auth-cookie", "auth-bearer",
|
|
25
|
+
];
|
|
13
26
|
|
|
14
27
|
/**
|
|
15
28
|
* The license policy for this run: `--license-policy <preset|file>`, else the
|
|
@@ -38,11 +51,23 @@ export async function runDeps(args, opts, { version }) {
|
|
|
38
51
|
printError("Use --diff <lockfile|dir> or --base <git-ref>, not both.");
|
|
39
52
|
process.exit(2);
|
|
40
53
|
}
|
|
54
|
+
const unsupported = DEPS_UNSUPPORTED.filter((f) => opts[f] !== undefined && opts[f] !== false);
|
|
55
|
+
if (unsupported.length) {
|
|
56
|
+
printError(`${unsupported.map((f) => `--${f}`).join(", ")} ${unsupported.length > 1 ? "do" : "does"} not apply to \`penthera deps\` (URL and --repo scans only). Deps supports --diff, --base, --online, --prod, --venv, --pr-comment, --license-policy, -o, --markdown, --sarif, --audience, --report-template, --evidence, --sign-key and --baseline.`);
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
41
59
|
const root = resolve(args[0] || ".");
|
|
42
60
|
if (!existsSync(root)) {
|
|
43
61
|
printError(`Path not found: ${root}`);
|
|
44
62
|
process.exit(2);
|
|
45
63
|
}
|
|
64
|
+
let reportCtx;
|
|
65
|
+
try {
|
|
66
|
+
reportCtx = prepareReportOptions(opts);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
printError(e.message);
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
46
71
|
const quiet = opts.quiet || opts.json;
|
|
47
72
|
if (opts.online && !quiet) {
|
|
48
73
|
process.stderr.write("\n \x1b[33mNOTE\x1b[0m --online fetches public package metadata from the npm registry.\n" +
|
|
@@ -59,6 +84,7 @@ export async function runDeps(args, opts, { version }) {
|
|
|
59
84
|
base: opts.base || null,
|
|
60
85
|
online: !!opts.online,
|
|
61
86
|
prod: !!opts.prod,
|
|
87
|
+
venv: opts.venv || null,
|
|
62
88
|
onPhase: createProgress(quiet),
|
|
63
89
|
});
|
|
64
90
|
} catch (e) {
|
|
@@ -69,11 +95,30 @@ export async function runDeps(args, opts, { version }) {
|
|
|
69
95
|
const merged = mergeResults([result]);
|
|
70
96
|
merged.actionPlan = buildActionPlan(merged.findings);
|
|
71
97
|
|
|
98
|
+
let baselineStats = null;
|
|
99
|
+
if (opts.baseline) {
|
|
100
|
+
let comparison;
|
|
101
|
+
try {
|
|
102
|
+
comparison = compareWithBaseline(merged.findings, resolve(opts.baseline));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
printError(`--baseline: ${e.message}`);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
baselineStats = comparison.stats;
|
|
108
|
+
if (opts.quiet) merged.findings = comparison.newFindings;
|
|
109
|
+
else if (!opts.json) {
|
|
110
|
+
process.stderr.write(`\n Baseline: ${comparison.stats.newCount} new, ${comparison.stats.resolvedCount} resolved, ${comparison.stats.unchangedCount} unchanged\n\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const custom = customReport(merged, opts, { version, template: reportCtx.template });
|
|
114
|
+
|
|
72
115
|
if (opts.json) console.log(JSON.stringify(merged, null, 2));
|
|
116
|
+
else if (custom && !opts.markdown) process.stdout.write(custom);
|
|
73
117
|
else if (opts.quiet) printFindings(merged.findings);
|
|
74
118
|
else printReport(merged, { verbose: opts.verbose });
|
|
75
119
|
|
|
76
|
-
const
|
|
120
|
+
const footer = !opts["no-footer"];
|
|
121
|
+
const mdOpts = { version, baseline: baselineStats, footer };
|
|
77
122
|
const note = (s) => { if (!quiet) process.stderr.write(s); };
|
|
78
123
|
if (opts.output) {
|
|
79
124
|
writeJsonReport(merged, opts.output);
|
|
@@ -82,7 +127,8 @@ export async function runDeps(args, opts, { version }) {
|
|
|
82
127
|
note(` JSON report ${opts.output}\n Markdown report ${md}\n`);
|
|
83
128
|
}
|
|
84
129
|
if (opts.markdown) {
|
|
85
|
-
|
|
130
|
+
if (custom) writeFileSync(resolve(opts.markdown), custom);
|
|
131
|
+
else writeMarkdownReport(merged, opts.markdown, mdOpts);
|
|
86
132
|
note(` Markdown report ${opts.markdown}\n`);
|
|
87
133
|
}
|
|
88
134
|
if (opts.sarif) {
|
|
@@ -93,10 +139,19 @@ export async function runDeps(args, opts, { version }) {
|
|
|
93
139
|
const { formatPrComment } = await import("../../lib/deps/report.js");
|
|
94
140
|
const path = resolve(opts["pr-comment"]);
|
|
95
141
|
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
-
writeFileSync(path, formatPrComment(merged, { version }));
|
|
142
|
+
writeFileSync(path, formatPrComment(merged, { version, footer }));
|
|
97
143
|
note(` PR comment ${opts["pr-comment"]}\n`);
|
|
98
144
|
}
|
|
99
|
-
if (opts.
|
|
145
|
+
if (opts.evidence) {
|
|
146
|
+
try {
|
|
147
|
+
const bundle = writeEvidence(merged, { ...opts, repo: root }, { version, template: reportCtx.template, mdOpts, sbom: null, argv: process.argv.slice(2) });
|
|
148
|
+
note(` Evidence bundle ${bundle.manifest.artifacts.length} files${bundle.signed ? ", signed" : ""} → ${opts.evidence}/ (penthera evidence verify ${opts.evidence})\n`);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
printError(`Could not write the evidence bundle: ${e.message}`);
|
|
151
|
+
process.exit(2);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (opts.output || opts.markdown || opts.sarif || opts["pr-comment"] || opts.evidence) note("\n");
|
|
100
155
|
|
|
101
156
|
// exitCode, not exit(): large --json output to a pipe must drain first.
|
|
102
157
|
const hasSevere = merged.findings.some((f) => f.severity === "critical" || f.severity === "high");
|
package/src/cli/evidence.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `penthera evidence verify <dir> [--allowed-signers <file>] [--json]`
|
|
2
|
+
* `penthera evidence verify <dir> [--allowed-signers <file>] [--require-signature] [--json]`
|
|
3
3
|
*
|
|
4
4
|
* Recomputes every artifact hash in an evidence bundle and checks the SSH
|
|
5
5
|
* signature over manifest.json. Exit 0 when the bundle verifies, 1 when it
|
|
@@ -9,12 +9,16 @@ import { parseArgs } from "node:util";
|
|
|
9
9
|
import { verifyEvidence } from "../report/evidence.js";
|
|
10
10
|
|
|
11
11
|
const HELP = `
|
|
12
|
-
penthera evidence verify <dir> [--allowed-signers <file>] [--json]
|
|
12
|
+
penthera evidence verify <dir> [--allowed-signers <file>] [--require-signature] [--json]
|
|
13
13
|
|
|
14
14
|
Recomputes the SHA-256 of every file in an evidence bundle (written with
|
|
15
15
|
--evidence <dir>), fails on any changed, missing or extra file, and checks
|
|
16
16
|
manifest.json.sig with ssh-keygen against the allowed signers file.
|
|
17
17
|
|
|
18
|
+
A signed bundle only verifies with --allowed-signers (a valid signature by
|
|
19
|
+
an unknown key proves nothing). An unsigned bundle passes with an UNSIGNED
|
|
20
|
+
warning; --require-signature makes it fail.
|
|
21
|
+
|
|
18
22
|
Exit codes: 0 verified, 1 verification failed, 2 usage error.
|
|
19
23
|
See docs/evidence.md.
|
|
20
24
|
`;
|
|
@@ -27,6 +31,7 @@ export function runEvidence(argv, { out = process.stdout } = {}) {
|
|
|
27
31
|
allowPositionals: true,
|
|
28
32
|
options: {
|
|
29
33
|
"allowed-signers": { type: "string" },
|
|
34
|
+
"require-signature": { type: "boolean", default: false },
|
|
30
35
|
json: { type: "boolean", default: false },
|
|
31
36
|
help: { type: "boolean", short: "h", default: false },
|
|
32
37
|
},
|
|
@@ -41,7 +46,11 @@ export function runEvidence(argv, { out = process.stdout } = {}) {
|
|
|
41
46
|
return values.help ? 0 : 2;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
if (values["require-signature"] && !values["allowed-signers"]) {
|
|
50
|
+
process.stderr.write(` --require-signature needs --allowed-signers <file>: a signature is only worth something against keys you trust.\n${HELP}`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
const r = verifyEvidence(positionals[1], { allowedSigners: values["allowed-signers"] || null, requireSignature: values["require-signature"] });
|
|
45
54
|
if (values.json) {
|
|
46
55
|
const { manifest, ...rest } = r;
|
|
47
56
|
out.write(JSON.stringify({ ...rest, target: manifest?.target || null, generated_at: manifest?.generated_at || null }, null, 2) + "\n");
|
|
@@ -62,6 +71,7 @@ export function runEvidence(argv, { out = process.stdout } = {}) {
|
|
|
62
71
|
const label = r.signature.ok ? (r.signature.status === "valid" ? "ok " : "warn") : "FAIL";
|
|
63
72
|
out.write(` ${label} signature: ${r.signature.detail}\n`);
|
|
64
73
|
}
|
|
65
|
-
|
|
74
|
+
for (const w of r.warnings || []) process.stderr.write(`\n WARNING ${w}\n`);
|
|
75
|
+
out.write(r.ok ? `\n Verified: every artifact matches the manifest${r.signature?.status === "valid" ? `, signed by ${r.signature.principal}` : " (UNSIGNED)"}.\n\n` : `\n NOT VERIFIED:\n${r.problems.map((p) => ` - ${p}\n`).join("")}\n`);
|
|
66
76
|
return r.ok ? 0 : 1;
|
|
67
77
|
}
|
package/src/cli/install.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Shows what an install would change (new packages, capabilities gained, new
|
|
5
5
|
* install scripts and their commands), applies penthera.policy.json, and only
|
|
6
|
-
* then runs the
|
|
6
|
+
* then runs `npm ci` on the reviewed lockfile. Exit codes: 0 installed (or a clean
|
|
7
7
|
* --dry-run), 1 blocked or aborted (project untouched), 2 error.
|
|
8
8
|
*/
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
@@ -30,6 +30,7 @@ const HELP = `
|
|
|
30
30
|
-D, --save-dev Save as devDependencies
|
|
31
31
|
-E, --save-exact Save exact versions
|
|
32
32
|
--offline npm resolves from its cache only (no registry traffic at all)
|
|
33
|
+
--allow-non-registry Accept git and tarball-URL packages (refused by default)
|
|
33
34
|
--json Print the plan as JSON
|
|
34
35
|
-v, --verbose Show more evidence per capability
|
|
35
36
|
-h, --help Show this help
|
|
@@ -38,13 +39,17 @@ const HELP = `
|
|
|
38
39
|
npm resolves the new tree in a temporary copy with scripts disabled, new
|
|
39
40
|
tarballs go into npm's cache (integrity-verified, never unpacked or run),
|
|
40
41
|
and the \`penthera deps\` engine diffs capabilities against your lockfile.
|
|
41
|
-
Nothing in your project changes until you approve.
|
|
42
|
-
run; otherwise the
|
|
42
|
+
Nothing in your project changes until you approve. Then \`npm ci\` installs
|
|
43
|
+
exactly the reviewed lockfile. Approved install scripts run; otherwise the
|
|
44
|
+
install uses --ignore-scripts. A package Penthera could not read is held for
|
|
45
|
+
approval whatever the policy says, and one whose lockfile claims an install
|
|
46
|
+
script the scan did not find is always refused.
|
|
43
47
|
|
|
44
48
|
${dim("Policy")} ${dim(`(${POLICY_FILE})`)}
|
|
45
49
|
{ "allow": ["@acme/*", "esbuild"], "deny": ["token-harvest"],
|
|
46
50
|
"requireApproval": ["install-scripts", "ai-config-write", "ai-cli-invoke",
|
|
47
51
|
"token-harvest", "obfuscated", "typosquat", "unscanned"] }
|
|
52
|
+
"script-mismatch" is always denied and "unscanned" always needs approval.
|
|
48
53
|
The requireApproval list above is the default.
|
|
49
54
|
`;
|
|
50
55
|
|
|
@@ -70,6 +75,7 @@ function printPlan(plan, { policyPath, verbose }) {
|
|
|
70
75
|
: dim(i.change === "upgraded" ? (i.compared ? "no new capabilities" : "old version not readable") : "no notable capabilities");
|
|
71
76
|
L.push(` ${head} ${note}`);
|
|
72
77
|
if (i.typosquatOf) L.push(` ${yellow(`looks like ${i.typosquatOf}`)}: check this is the package you meant`);
|
|
78
|
+
if (i.unscanned) L.push(` ${red("not scanned")}: ${i.unscanned}`);
|
|
73
79
|
if (i.flags.includes("install-scripts")) {
|
|
74
80
|
for (const [hook, cmd] of Object.entries(i.installScripts || {})) {
|
|
75
81
|
const was = i.previousInstallScripts?.[hook];
|
|
@@ -120,6 +126,7 @@ export async function runInstall(argv) {
|
|
|
120
126
|
"save-dev": { type: "boolean", short: "D", default: false },
|
|
121
127
|
"save-exact": { type: "boolean", short: "E", default: false },
|
|
122
128
|
offline: { type: "boolean", default: false },
|
|
129
|
+
"allow-non-registry": { type: "boolean", default: false },
|
|
123
130
|
json: { type: "boolean", default: false },
|
|
124
131
|
verbose: { type: "boolean", short: "v", default: false },
|
|
125
132
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -164,6 +171,7 @@ export async function runInstall(argv) {
|
|
|
164
171
|
saveDev: opts["save-dev"],
|
|
165
172
|
saveExact: opts["save-exact"],
|
|
166
173
|
offline: opts.offline,
|
|
174
|
+
allowNonRegistry: opts["allow-non-registry"],
|
|
167
175
|
onPhase: createProgress(json),
|
|
168
176
|
});
|
|
169
177
|
} catch (e) {
|
|
@@ -195,7 +203,8 @@ export async function runInstall(argv) {
|
|
|
195
203
|
out(` ${red("Blocked")}: ${needsApproval.length} package(s) need approval and --yes only installs when nothing breaches the policy. ${untouched}\n\n`);
|
|
196
204
|
return finish(1, "blocked");
|
|
197
205
|
}
|
|
198
|
-
|
|
206
|
+
// A package that could not be read may have install scripts nobody saw.
|
|
207
|
+
const withScripts = scripts.length > 0 || needsApproval.some((i) => i.approval.includes("unscanned"));
|
|
199
208
|
await initPrompt();
|
|
200
209
|
const answer = (await ask(withScripts
|
|
201
210
|
? ` Install anyway? [y]es, run the install scripts / [s]kip scripts / [N]o: `
|
|
@@ -209,7 +218,7 @@ export async function runInstall(argv) {
|
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
|
|
212
|
-
out(`\n ${dim("▸")} npm
|
|
221
|
+
out(`\n ${dim("▸")} npm ci${runScripts ? "" : " --ignore-scripts"} ${dim("(installs exactly the reviewed lockfile)")}\n`);
|
|
213
222
|
let applied;
|
|
214
223
|
try {
|
|
215
224
|
applied = await applyInstall(plan, { runScripts, offline: opts.offline });
|
|
@@ -13,6 +13,7 @@ import { formatMarkdownReport } from "../report/markdown.js";
|
|
|
13
13
|
import { formatSarif } from "../reporter.js";
|
|
14
14
|
import { formatReadinessMarkdown } from "../../lib/compliance/readiness.js";
|
|
15
15
|
import { writeEvidenceBundle, assertEmptyDir } from "../report/evidence.js";
|
|
16
|
+
import { footerLines } from "../report/footer.js";
|
|
16
17
|
|
|
17
18
|
/** Validate the report flags. Returns { template } or throws with a user-facing message. */
|
|
18
19
|
export function prepareReportOptions(opts) {
|
|
@@ -34,7 +35,7 @@ export function prepareReportOptions(opts) {
|
|
|
34
35
|
/** The --audience or --report-template report as Markdown, or null when neither is set. */
|
|
35
36
|
export function customReport(result, opts, { version, template }) {
|
|
36
37
|
if (template) return renderTemplate(template, buildReportModel(result, { version }));
|
|
37
|
-
if (opts.audience) return formatAudienceReport(result, opts.audience, { version });
|
|
38
|
+
if (opts.audience) return formatAudienceReport(result, opts.audience, { version, footer: !opts["no-footer"] });
|
|
38
39
|
return null;
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -43,11 +44,13 @@ export function customReport(result, opts, { version, template }) {
|
|
|
43
44
|
* the custom template report, readiness and SBOM when they were produced.
|
|
44
45
|
*/
|
|
45
46
|
export function writeEvidence(result, opts, { version, template, mdOpts, sbom, argv, now = new Date().toISOString() }) {
|
|
47
|
+
const footer = !opts["no-footer"];
|
|
46
48
|
const artifacts = [
|
|
49
|
+
{ path: "README.md", kind: "readme", content: evidenceReadme(result, { version, footer }) },
|
|
47
50
|
{ path: "report.json", kind: "json", content: JSON.stringify(result, null, 2) + "\n" },
|
|
48
51
|
{ path: "report.md", kind: "markdown", content: formatMarkdownReport(result, mdOpts) },
|
|
49
52
|
{ 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 }) })),
|
|
53
|
+
...AUDIENCES.map((a) => ({ path: `report-${a}.md`, kind: `audience:${a}`, content: formatAudienceReport(result, a, { version, footer }) })),
|
|
51
54
|
];
|
|
52
55
|
if (template) artifacts.push({ path: "report-custom.md", kind: "template", content: renderTemplate(template, buildReportModel(result, { version })) });
|
|
53
56
|
if (result.readiness) artifacts.push({ path: "nis2-readiness.md", kind: "readiness", content: formatReadinessMarkdown(result.readiness) });
|
|
@@ -70,3 +73,23 @@ export function writeEvidence(result, opts, { version, template, mdOpts, sbom, a
|
|
|
70
73
|
signKey: opts["sign-key"] || null,
|
|
71
74
|
});
|
|
72
75
|
}
|
|
76
|
+
|
|
77
|
+
/** README.md at the root of an evidence bundle: what is in it and how to check it. */
|
|
78
|
+
export function evidenceReadme(result, { version, footer = true } = {}) {
|
|
79
|
+
return [
|
|
80
|
+
"# Penthera evidence bundle",
|
|
81
|
+
"",
|
|
82
|
+
`Target: \`${String(result.target || "unknown").replace(/`/g, "")}\` · Penthera v${version}`,
|
|
83
|
+
"",
|
|
84
|
+
"- `manifest.json` lists every file with its SHA-256; `manifest.json.sig` is its signature when the bundle was signed.",
|
|
85
|
+
"- `report.json` and `report.sarif` are the machine-readable results; `report.md` and `report-<audience>.md` are for people.",
|
|
86
|
+
"",
|
|
87
|
+
"Check that nothing changed since the bundle was written:",
|
|
88
|
+
"",
|
|
89
|
+
"```",
|
|
90
|
+
"penthera evidence verify <this-dir> --allowed-signers <file>",
|
|
91
|
+
"```",
|
|
92
|
+
"",
|
|
93
|
+
...footerLines({ footer }),
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
package/src/cli/ui.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera ui [--port 0] [--open] [--project <dir>]... [--reports <dir>] [--demo]`
|
|
3
|
+
*
|
|
4
|
+
* Starts the local dashboard on 127.0.0.1 and prints a one-time URL. See
|
|
5
|
+
* docs/dashboard.md for the security model.
|
|
6
|
+
*/
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { bold, dim, red, green } from "./ansi.js";
|
|
10
|
+
|
|
11
|
+
const HELP = `
|
|
12
|
+
${bold("penthera ui")}: the local dashboard, on 127.0.0.1 only
|
|
13
|
+
|
|
14
|
+
${dim("Usage")}
|
|
15
|
+
$ penthera ui [--open] [--port <n>] [--project <dir>]... [--reports <dir>]
|
|
16
|
+
$ penthera ui --demo Realistic fake data, for a first look or screenshots
|
|
17
|
+
|
|
18
|
+
${dim("Options")}
|
|
19
|
+
--port <n> Port to listen on (default: 0, a random free port)
|
|
20
|
+
--open Open the one-time URL in your browser
|
|
21
|
+
--project <dir> Folder the dashboard may read and run the agent loop in
|
|
22
|
+
(repeatable; default: the current folder)
|
|
23
|
+
--reports <dir> Report JSON folder (default: <first project>/reports)
|
|
24
|
+
--demo Seed fake data; nothing is read from disk
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
|
|
27
|
+
The URL holds a one-time token. Opening it sets a session cookie; the token
|
|
28
|
+
then stops working. Nothing outside the project folders and ~/.penthera is
|
|
29
|
+
read, and the server never contacts the network. Ctrl-C stops it.
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
function openBrowser(url) {
|
|
33
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
|
|
34
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
35
|
+
: ["xdg-open", [url]];
|
|
36
|
+
try {
|
|
37
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
38
|
+
} catch { /* the URL is printed anyway */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runUi(argv, { version }) {
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = parseArgs({
|
|
45
|
+
args: argv,
|
|
46
|
+
allowPositionals: false,
|
|
47
|
+
options: {
|
|
48
|
+
port: { type: "string", default: "0" },
|
|
49
|
+
open: { type: "boolean", default: false },
|
|
50
|
+
project: { type: "string", multiple: true },
|
|
51
|
+
reports: { type: "string" },
|
|
52
|
+
demo: { type: "boolean", default: false },
|
|
53
|
+
help: { type: "boolean", short: "h", default: false },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
} catch (e) {
|
|
57
|
+
process.stderr.write(` ${red("Error")} ${e.message}\n${HELP}`);
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
const opts = parsed.values;
|
|
61
|
+
if (opts.help) { console.log(HELP); return 0; }
|
|
62
|
+
const port = Number(opts.port);
|
|
63
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
64
|
+
process.stderr.write(` ${red("Error")} --port must be 0-65535\n`);
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { startUiServer } = await import("../ui/server.js");
|
|
69
|
+
let ui;
|
|
70
|
+
try {
|
|
71
|
+
ui = await startUiServer({ port, projects: opts.project, reportsDir: opts.reports, demo: opts.demo, version });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
process.stderr.write(` ${red("Not started")} ${e.message}\n`);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
process.stdout.write(`\n ${bold("penthera ui")}${opts.demo ? dim(" (demo data)") : ""}\n`);
|
|
78
|
+
process.stdout.write(` ${green(ui.url)}\n`);
|
|
79
|
+
process.stdout.write(dim(` one-time link: it signs this browser in once, then stops working\n`));
|
|
80
|
+
process.stdout.write(dim(` listening on 127.0.0.1:${ui.port} only. Ctrl-C to stop\n\n`));
|
|
81
|
+
if (opts.open) openBrowser(ui.url);
|
|
82
|
+
|
|
83
|
+
await new Promise((resolve) => {
|
|
84
|
+
const stop = () => { process.stdout.write("\n Stopped.\n"); ui.close().then(resolve); };
|
|
85
|
+
process.once("SIGINT", stop);
|
|
86
|
+
process.once("SIGTERM", stop);
|
|
87
|
+
});
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -59,12 +59,14 @@ const HELP = `
|
|
|
59
59
|
$ penthera --repo <path> Scan a local repo (white-box)
|
|
60
60
|
$ penthera <url> --repo <path> Full scan (both)
|
|
61
61
|
$ penthera agents [path] Audit AI agent config: skills, MCP, hooks (offline)
|
|
62
|
-
$ penthera deps [path] What your npm dependencies can do (offline)
|
|
62
|
+
$ penthera deps [path] What your npm and Python dependencies can do (offline)
|
|
63
63
|
$ penthera install <pkg...> Check what an npm install adds before anything runs
|
|
64
|
+
$ penthera hook install Pre-commit hook: staged secrets, agent config, lockfiles
|
|
64
65
|
$ penthera memory show|verify|forget Local learning store (never leaves the machine)
|
|
65
66
|
$ penthera fix --plan Plan local fix recipes (penthera fix --help)
|
|
66
67
|
$ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
|
|
67
68
|
$ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
|
|
69
|
+
$ penthera ui [--open] [--demo] Local dashboard on 127.0.0.1 (penthera ui --help)
|
|
68
70
|
|
|
69
71
|
${dim("Scan modes")}
|
|
70
72
|
--profile <name> quick | standard | deep (default: standard)
|
|
@@ -79,17 +81,19 @@ const HELP = `
|
|
|
79
81
|
--all Enable all modes (recon + deep + fuzz)
|
|
80
82
|
--machine macOS machine audit (keyloggers, trojans, rootkits)
|
|
81
83
|
--offline Contact only the scan target: no recon sources, no
|
|
82
|
-
Retire.js download (uses the vendored snapshot)
|
|
84
|
+
Retire.js download (uses the vendored snapshot),
|
|
85
|
+
no DNS lookups for SPF/DMARC
|
|
83
86
|
|
|
84
87
|
${dim("Agents mode")} ${dim("(penthera agents [path], offline, read-only)")}
|
|
85
88
|
--home Also audit ~/.claude, ~/.cursor and Claude Desktop config
|
|
86
89
|
--pin Write penthera.lock (SHA-256 of skills, MCP servers, hooks)
|
|
87
90
|
|
|
88
|
-
${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json
|
|
91
|
+
${dim("Deps mode")} ${dim("(penthera deps [path], reads package-lock.json, Python lockfiles or a .venv, offline)")}
|
|
89
92
|
--diff <lock|dir> Capability diff against an old lockfile or installed tree
|
|
90
93
|
--base <git-ref> Capability diff against the lockfile at a git ref
|
|
91
94
|
--online Also check npm registry metadata (the only network use)
|
|
92
|
-
--prod Skip
|
|
95
|
+
--prod Skip dev dependencies
|
|
96
|
+
--venv <path> Python virtualenv to read (default: .venv or venv)
|
|
93
97
|
--pr-comment <file> Write a short Markdown capability diff for a PR comment
|
|
94
98
|
--license-policy <p> permissive | weak-copyleft-ok | no-agpl, or a policy file
|
|
95
99
|
|
|
@@ -108,6 +112,7 @@ const HELP = `
|
|
|
108
112
|
--audience <who> developer | owner | auditor report (for --markdown or stdout)
|
|
109
113
|
--report-template <file.md> Your own Markdown template ({{summary.critical}}, {{#each findings}})
|
|
110
114
|
--evidence <dir> Evidence bundle: all reports + manifest.json with SHA-256s
|
|
115
|
+
--no-footer Leave the one-line "Checked by Penthera" footer off Markdown output
|
|
111
116
|
--sign-key <key> Sign the bundle's manifest with an SSH key (ssh-keygen -Y sign)
|
|
112
117
|
--baseline <file> Compare against previous JSON report
|
|
113
118
|
--framework <name> Add a compliance mapping to reports (nis2)
|
|
@@ -191,6 +196,11 @@ export async function run() {
|
|
|
191
196
|
return runFix(process.argv.slice(3));
|
|
192
197
|
}
|
|
193
198
|
|
|
199
|
+
if (process.argv[2] === "ui") {
|
|
200
|
+
const { runUi } = await import("./cli/ui.js");
|
|
201
|
+
process.exit(await runUi(process.argv.slice(3), { version: pkg.version }));
|
|
202
|
+
}
|
|
203
|
+
|
|
194
204
|
if (process.argv[2] === "agent") {
|
|
195
205
|
const { runAgent } = await import("./cli/agent.js");
|
|
196
206
|
process.exit(await runAgent(process.argv.slice(3)));
|
|
@@ -207,6 +217,7 @@ export async function run() {
|
|
|
207
217
|
markdown: { type: "string" },
|
|
208
218
|
sarif: { type: "string" },
|
|
209
219
|
audience: { type: "string" },
|
|
220
|
+
"no-footer": { type: "boolean", default: false },
|
|
210
221
|
"report-template": { type: "string" },
|
|
211
222
|
evidence: { type: "string" },
|
|
212
223
|
"sign-key": { type: "string" },
|
|
@@ -233,6 +244,7 @@ export async function run() {
|
|
|
233
244
|
"pr-comment": { type: "string" },
|
|
234
245
|
prod: { type: "boolean", default: false },
|
|
235
246
|
"license-policy": { type: "string" },
|
|
247
|
+
venv: { type: "string" },
|
|
236
248
|
profile: { type: "string" },
|
|
237
249
|
"auth-cookie": { type: "string" },
|
|
238
250
|
"auth-bearer": { type: "string" },
|
|
@@ -371,8 +383,8 @@ export async function run() {
|
|
|
371
383
|
await runDeps(positionals.slice(1), opts, { version: pkg.version });
|
|
372
384
|
return;
|
|
373
385
|
}
|
|
374
|
-
if (opts.diff || opts.base || opts.online || opts["pr-comment"] || opts.prod || opts["license-policy"]) {
|
|
375
|
-
printError("--diff, --base, --online, --prod, --pr-comment
|
|
386
|
+
if (opts.diff || opts.base || opts.online || opts["pr-comment"] || opts.prod || opts["license-policy"] || opts.venv) {
|
|
387
|
+
printError("--diff, --base, --online, --prod, --pr-comment, --license-policy and --venv only apply to `penthera deps`.");
|
|
376
388
|
process.exit(2);
|
|
377
389
|
}
|
|
378
390
|
|
|
@@ -582,7 +594,7 @@ export async function run() {
|
|
|
582
594
|
}
|
|
583
595
|
}
|
|
584
596
|
|
|
585
|
-
const mdOpts = { version: pkg.version, baseline: baselineStats };
|
|
597
|
+
const mdOpts = { version: pkg.version, baseline: baselineStats, footer: !opts["no-footer"] };
|
|
586
598
|
const custom = customReport(merged, opts, { version: pkg.version, template: reportCtx.template });
|
|
587
599
|
|
|
588
600
|
// Output
|
package/src/report/audience.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* probes, suppressions (who and why), loop timeline, NIS2 and OWASP
|
|
9
9
|
*/
|
|
10
10
|
import { buildReportModel, CONFIDENCE_LABELS } from "./model.js";
|
|
11
|
+
import { mdInline } from "./md-escape.js";
|
|
12
|
+
import { footerLines } from "./footer.js";
|
|
11
13
|
|
|
12
14
|
export const AUDIENCES = ["developer", "owner", "auditor"];
|
|
13
15
|
|
|
@@ -24,10 +26,7 @@ const NIS2_STATUS = {
|
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/** Inline text: one line, no table breaks, no raw HTML. */
|
|
27
|
-
|
|
28
|
-
return String(s ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/\r?\n|\r/g, " ")
|
|
29
|
-
.replace(/\\/g, "\\\\").replace(/</g, "<").replace(/>/g, ">").replace(/\|/g, "\\|");
|
|
30
|
-
}
|
|
29
|
+
const md = mdInline;
|
|
31
30
|
/** Penthera's own remediation text (lib/remediation): may hold code spans, so only fold lines. */
|
|
32
31
|
function prose(s) {
|
|
33
32
|
return String(s ?? "").replace(/\r?\n|\r/g, " ");
|
|
@@ -51,8 +50,9 @@ function header(L, title, m) {
|
|
|
51
50
|
L.push(`> ${md(m.target)} · scanned ${when(m.timestamp)} · Penthera v${m.tool.version}`, "");
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
function footer(L) {
|
|
53
|
+
function footer(L, m) {
|
|
55
54
|
L.push("---", "", "*Authorized security testing only. See the Penthera README for ethical use guidelines.*", "");
|
|
55
|
+
L.push(...footerLines(m));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function timelineSection(L, t, { heading = "## Fix timeline" } = {}) {
|
|
@@ -111,7 +111,7 @@ export function formatDeveloperReport(m) {
|
|
|
111
111
|
for (const x of m.suppressed) L.push(`- ${md(x.title)} (${code(x.fid)}): ${md(x.reason)} (${md(x.actor)}, ${x.by})`);
|
|
112
112
|
L.push("");
|
|
113
113
|
}
|
|
114
|
-
footer(L);
|
|
114
|
+
footer(L, m);
|
|
115
115
|
return L.join("\n");
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -167,7 +167,7 @@ export function formatOwnerReport(m) {
|
|
|
167
167
|
L.push("## Fixes", "");
|
|
168
168
|
L.push(`${t.verified} ${t.verified === 1 ? "fix" : "fixes"} verified by a re-scan, ${t.failed} failed, ${t.open} still open. Typical time to fix: ${t.medianTimeToFix}.`, "");
|
|
169
169
|
}
|
|
170
|
-
footer(L);
|
|
170
|
+
footer(L, m);
|
|
171
171
|
return L.join("\n");
|
|
172
172
|
}
|
|
173
173
|
|
|
@@ -256,15 +256,15 @@ export function formatAuditorReport(m) {
|
|
|
256
256
|
|
|
257
257
|
L.push("## Verifying this report", "");
|
|
258
258
|
L.push(`If this report came in an evidence bundle, check that nothing in it changed since it was produced: ${code("penthera evidence verify <dir> --allowed-signers <file>")}. See [docs/evidence.md](${EVIDENCE_URL}).`, "");
|
|
259
|
-
footer(L);
|
|
259
|
+
footer(L, m);
|
|
260
260
|
return L.join("\n");
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
const FORMATTERS = { developer: formatDeveloperReport, owner: formatOwnerReport, auditor: formatAuditorReport };
|
|
264
264
|
|
|
265
265
|
/** Render the report for one audience. Throws on an unknown audience. */
|
|
266
|
-
export function formatAudienceReport(result, audience, { version } = {}) {
|
|
266
|
+
export function formatAudienceReport(result, audience, { version, footer = true } = {}) {
|
|
267
267
|
const fmt = FORMATTERS[audience];
|
|
268
268
|
if (!fmt) throw new Error(`Unknown --audience "${audience}". Use one of: ${AUDIENCES.join(", ")}.`);
|
|
269
|
-
return fmt(buildReportModel(result, { version }));
|
|
269
|
+
return fmt({ ...buildReportModel(result, { version }), footer });
|
|
270
270
|
}
|
package/src/report/evidence.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* See docs/evidence.md.
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
|
-
import {
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
13
|
import {
|
|
14
14
|
readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import { join, resolve, relative, sep } from "node:path";
|
|
17
|
+
import { createGit } from "../agent/git.js";
|
|
17
18
|
|
|
18
19
|
export const SCHEMA = "penthera-evidence/1";
|
|
19
20
|
export const NAMESPACE = "penthera-evidence";
|
|
@@ -75,8 +76,10 @@ function quote(a) {
|
|
|
75
76
|
export function gitInfo(dir) {
|
|
76
77
|
if (!dir) return null;
|
|
77
78
|
try {
|
|
78
|
-
|
|
79
|
-
const
|
|
79
|
+
// The scanned repo is untrusted: no fsmonitor, no hooks, no transports.
|
|
80
|
+
const git = createGit(dir);
|
|
81
|
+
const commit = git.run("rev-parse", "HEAD").trim();
|
|
82
|
+
const status = git.run("status", "--porcelain");
|
|
80
83
|
return /^[0-9a-f]{40,64}$/.test(commit) ? { commit, dirty: status.trim().length > 0 } : null;
|
|
81
84
|
} catch {
|
|
82
85
|
return null;
|
|
@@ -174,19 +177,28 @@ function sshVerify(args, manifestBuf) {
|
|
|
174
177
|
return { ok: r.status === 0, out: `${r.stdout || ""}${r.stderr || ""}`.trim() };
|
|
175
178
|
}
|
|
176
179
|
|
|
177
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Signature status. A bundle that says it is signed (manifest.signature, or a
|
|
182
|
+
* .sig file) only passes when the signature verifies against --allowed-signers:
|
|
183
|
+
* anyone can re-sign a rewritten manifest with their own key, so a signature
|
|
184
|
+
* whose signer was not checked proves nothing and must not read as "verified".
|
|
185
|
+
*/
|
|
186
|
+
function checkSignature(root, manifestBuf, manifest, allowedSigners) {
|
|
178
187
|
const sigPath = join(root, SIGNATURE);
|
|
188
|
+
const declared = manifest.signature != null;
|
|
179
189
|
if (!existsSync(sigPath)) {
|
|
190
|
+
if (declared) return { status: "missing", ok: false, detail: `the manifest declares a signature but ${SIGNATURE} is missing` };
|
|
180
191
|
return allowedSigners
|
|
181
192
|
? { status: "missing", ok: false, detail: `--allowed-signers was given but there is no ${SIGNATURE}` }
|
|
182
|
-
: { status: "unsigned", ok: true, detail: "
|
|
193
|
+
: { status: "unsigned", ok: true, detail: "UNSIGNED: hashes only prove the bundle is internally consistent, not who wrote it" };
|
|
194
|
+
}
|
|
195
|
+
if (lstatSync(sigPath).isSymbolicLink() || !lstatSync(sigPath).isFile()) {
|
|
196
|
+
return { status: "invalid", ok: false, detail: `${SIGNATURE} is not a regular file` };
|
|
183
197
|
}
|
|
184
198
|
if (!allowedSigners) {
|
|
185
199
|
const r = sshVerify(["check-novalidate", "-n", NAMESPACE, "-s", sigPath], manifestBuf);
|
|
186
|
-
if (r.missing) return { status: "
|
|
187
|
-
return
|
|
188
|
-
? { status: "valid-unknown-signer", ok: true, detail: "signature is valid, but the signer was not checked: pass --allowed-signers" }
|
|
189
|
-
: { status: "invalid", ok: false, detail: r.out };
|
|
200
|
+
if (!r.missing && !r.ok) return { status: "invalid", ok: false, detail: r.out };
|
|
201
|
+
return { status: "unverified", ok: false, detail: "the bundle is signed, but the signer was not checked: pass --allowed-signers <file> listing the keys you trust" };
|
|
190
202
|
}
|
|
191
203
|
const allowed = resolve(allowedSigners);
|
|
192
204
|
if (!existsSync(allowed)) return { status: "invalid", ok: false, detail: `allowed signers file ${allowedSigners} not found` };
|
|
@@ -203,14 +215,16 @@ function checkSignature(root, manifestBuf, allowedSigners) {
|
|
|
203
215
|
|
|
204
216
|
/**
|
|
205
217
|
* Verify a bundle. Never throws on a bad bundle; returns { ok, ... }.
|
|
218
|
+
* `warnings` carries the UNSIGNED notice for a bundle that passes without a
|
|
219
|
+
* signature; `requireSignature` turns that into a failure.
|
|
206
220
|
* @param {string} dir
|
|
207
|
-
* @param {{ allowedSigners?: string }} opts
|
|
221
|
+
* @param {{ allowedSigners?: string, requireSignature?: boolean }} opts
|
|
208
222
|
*/
|
|
209
|
-
export function verifyEvidence(dir, { allowedSigners = null } = {}) {
|
|
223
|
+
export function verifyEvidence(dir, { allowedSigners = null, requireSignature = false } = {}) {
|
|
210
224
|
const root = resolve(dir);
|
|
211
225
|
const problems = [];
|
|
212
226
|
const files = [];
|
|
213
|
-
const fail = (msg) => ({ ok: false, problems: [msg], files, unlisted: [], signature: null, manifest: null });
|
|
227
|
+
const fail = (msg) => ({ ok: false, problems: [msg], warnings: [], files, unlisted: [], signature: null, manifest: null });
|
|
214
228
|
|
|
215
229
|
const manifestPath = join(root, MANIFEST);
|
|
216
230
|
if (!existsSync(manifestPath)) return fail(`${MANIFEST} not found in ${dir}`);
|
|
@@ -246,8 +260,13 @@ export function verifyEvidence(dir, { allowedSigners = null } = {}) {
|
|
|
246
260
|
for (const p of unlisted) problems.push(`${p} is in the bundle but not in the manifest`);
|
|
247
261
|
for (const f of files) if (!f.ok) problems.push(`${f.path}: ${f.reason}`);
|
|
248
262
|
|
|
249
|
-
const signature = checkSignature(root, manifestBuf, allowedSigners);
|
|
263
|
+
const signature = checkSignature(root, manifestBuf, manifest, allowedSigners);
|
|
250
264
|
if (!signature.ok) problems.push(`signature: ${signature.detail}`);
|
|
265
|
+
const warnings = [];
|
|
266
|
+
if (signature.status === "unsigned") {
|
|
267
|
+
if (requireSignature) problems.push("signature: --require-signature was given but the bundle is not signed");
|
|
268
|
+
else warnings.push("UNSIGNED bundle: anyone could have written it. Ask for a signed bundle and verify with --allowed-signers --require-signature.");
|
|
269
|
+
}
|
|
251
270
|
|
|
252
|
-
return { ok: problems.length === 0, problems, files, unlisted, signature, manifest };
|
|
271
|
+
return { ok: problems.length === 0, problems, warnings, files, unlisted, signature, manifest };
|
|
253
272
|
}
|