penthera 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
package/src/cli/rules.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera rules list|verify <dir>`, and loading `--rules <dir>` packs for a scan.
|
|
3
|
+
* Rule packs are data, verified before use; see docs/rule-packs.md.
|
|
4
|
+
*/
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { bold, dim, green, red, yellow } from "./ansi.js";
|
|
8
|
+
|
|
9
|
+
const HELP = `
|
|
10
|
+
${bold("penthera rules")}: signed, versioned detection rule packs (offline)
|
|
11
|
+
|
|
12
|
+
penthera rules list [--rules <dir>]... Packs in effect: bundled, plus any given
|
|
13
|
+
penthera rules verify <dir> [--rules-signers <file>] [--json]
|
|
14
|
+
Check a pack: signature, file hashes,
|
|
15
|
+
schema, ReDoS guard. Exit 0 only if it
|
|
16
|
+
would load without --allow-unsigned-rules.
|
|
17
|
+
|
|
18
|
+
Add packs to a scan with --rules <dir> (repeatable). There is no download
|
|
19
|
+
command: packs arrive by path or by upgrading Penthera.
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const kindsSummary = (rules) => Object.entries(rules || {}).filter(([, v]) => v.length).map(([k, v]) => `${k} ${v.length}`).join(", ");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Verify and activate --rules packs. Throws with a user-facing message on refusal.
|
|
26
|
+
* @returns {string[]} warnings to print
|
|
27
|
+
*/
|
|
28
|
+
export async function activateRules(opts) {
|
|
29
|
+
const dirs = [].concat(opts.rules || []);
|
|
30
|
+
if (dirs.length === 0) return [];
|
|
31
|
+
const { addRulePack, loadSigners } = await import("../../lib/rules/index.js");
|
|
32
|
+
const signers = loadSigners([].concat(opts["rules-signers"] || []).map((f) => resolve(f)));
|
|
33
|
+
const warnings = [];
|
|
34
|
+
for (const d of dirs) {
|
|
35
|
+
const r = addRulePack(resolve(d), { signers, allowUnsigned: !!opts["allow-unsigned-rules"] });
|
|
36
|
+
warnings.push(...r.warnings);
|
|
37
|
+
}
|
|
38
|
+
return warnings;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Print warnings from activateRules to stderr; unsigned-pack warnings are loud on purpose. */
|
|
42
|
+
export function printRuleWarnings(warnings) {
|
|
43
|
+
for (const w of warnings) {
|
|
44
|
+
if (w.startsWith("LOADING AN UNTRUSTED")) {
|
|
45
|
+
process.stderr.write(`\n ${red(bold("!! WARNING !!"))} ${bold(w)}\n\n`);
|
|
46
|
+
} else {
|
|
47
|
+
process.stderr.write(` ${yellow("warn")} rules: ${w}\n`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runRules(argv) {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = parseArgs({
|
|
56
|
+
args: argv,
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
options: {
|
|
59
|
+
rules: { type: "string", multiple: true },
|
|
60
|
+
"rules-signers": { type: "string", multiple: true },
|
|
61
|
+
"allow-unsigned-rules": { type: "boolean", default: false },
|
|
62
|
+
json: { type: "boolean", default: false },
|
|
63
|
+
help: { type: "boolean", short: "h", default: false },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error(` ${red("error")} ${e.message}`);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
const { values: opts, positionals } = parsed;
|
|
71
|
+
const [cmd, ...rest] = positionals;
|
|
72
|
+
if (opts.help || !cmd) {
|
|
73
|
+
console.log(HELP);
|
|
74
|
+
return opts.help ? 0 : 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rules = await import("../../lib/rules/index.js");
|
|
78
|
+
let signers;
|
|
79
|
+
try {
|
|
80
|
+
signers = rules.loadSigners([].concat(opts["rules-signers"] || []).map((f) => resolve(f)));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error(` ${red("error")} ${e.message}`);
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (cmd === "verify") {
|
|
87
|
+
if (rest.length !== 1) {
|
|
88
|
+
console.error(` ${red("error")} usage: penthera rules verify <dir>`);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
const v = rules.verifyPack(rest[0], { signers });
|
|
92
|
+
const trusted = v.ok && v.signature === "valid";
|
|
93
|
+
if (opts.json) {
|
|
94
|
+
const { rules: compiled, manifest, ...out } = v;
|
|
95
|
+
console.log(JSON.stringify({ ...out, trusted, kinds: Object.fromEntries(Object.entries(compiled || {}).map(([k, x]) => [k, x.length])) }, null, 2));
|
|
96
|
+
return trusted ? 0 : 1;
|
|
97
|
+
}
|
|
98
|
+
console.log(`\n ${bold("Rule pack")} ${v.name ? `${v.name}@${v.version}` : rest[0]} ${dim(v.dir)}`);
|
|
99
|
+
const sigLine = v.signature === "valid" ? green(`valid, signed by ${v.signer}`)
|
|
100
|
+
: v.signature === "invalid" ? red(`INVALID: ${v.signatureError}`)
|
|
101
|
+
: yellow("missing (no pack.json.sig)");
|
|
102
|
+
console.log(` signature ${sigLine}`);
|
|
103
|
+
if (v.ok) {
|
|
104
|
+
console.log(` files ${green("all SHA-256 hashes match pack.json")}`);
|
|
105
|
+
console.log(` rules ${green(`${v.count} valid`)} ${dim(`(${kindsSummary(v.rules)})`)}`);
|
|
106
|
+
console.log(` regexes ${green("all pass the ReDoS guard")}`);
|
|
107
|
+
}
|
|
108
|
+
for (const e of v.errors) console.log(` ${red("error")} ${e}`);
|
|
109
|
+
for (const w of v.warnings) console.log(` ${yellow("warn")} ${w}`);
|
|
110
|
+
console.log(trusted
|
|
111
|
+
? `\n ${green("OK")}: this pack loads with --rules ${rest[0]}\n`
|
|
112
|
+
: v.ok
|
|
113
|
+
? `\n ${yellow("NOT TRUSTED")}: well-formed, but it only loads with --allow-unsigned-rules.${rules.bundledPackDirs().includes(v.dir) ? " (Bundled packs load as part of the release; npm run rules:check refuses to publish them unsigned.)" : ""}\n`
|
|
114
|
+
: `\n ${red("REFUSED")}: this pack does not load, with or without --allow-unsigned-rules.\n`);
|
|
115
|
+
return trusted ? 0 : 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (cmd === "list") {
|
|
119
|
+
try {
|
|
120
|
+
printRuleWarnings(await activateRules({ ...opts, rules: opts.rules }));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error(` ${red("error")} ${e.message}`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
const packs = rules.activePacks();
|
|
126
|
+
if (opts.json) {
|
|
127
|
+
console.log(JSON.stringify(packs, null, 2));
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
console.log(`\n ${bold("Rule packs in effect")}\n`);
|
|
131
|
+
for (const p of packs) {
|
|
132
|
+
const sig = p.signature === "valid" ? green(`signed by ${p.signer}`)
|
|
133
|
+
: p.origin === "bundled" ? yellow(`${p.signature === "invalid" ? "signature invalid" : "unsigned"} (bundled; release check requires a signature)`)
|
|
134
|
+
: red("UNSIGNED (--allow-unsigned-rules)");
|
|
135
|
+
console.log(` ${bold(`${p.name}@${p.version}`)} ${dim(p.origin)} ${p.count} rules ${sig}`);
|
|
136
|
+
console.log(` ${dim(p.dir)}`);
|
|
137
|
+
}
|
|
138
|
+
console.log("");
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.error(` ${red("error")} unknown rules command "${cmd}" (list, verify)`);
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
package/src/cli/ui.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { mkdtempSync, openSync, writeSync, closeSync, rmSync, constants as FS } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
9
12
|
import { bold, dim, red, green } from "./ansi.js";
|
|
10
13
|
|
|
11
14
|
const HELP = `
|
|
@@ -17,25 +20,55 @@ const HELP = `
|
|
|
17
20
|
|
|
18
21
|
${dim("Options")}
|
|
19
22
|
--port <n> Port to listen on (default: 0, a random free port)
|
|
20
|
-
--open Open the one-time URL in your browser
|
|
23
|
+
--open Open the one-time URL in your browser (through a
|
|
24
|
+
private launcher file, so the token is not on a
|
|
25
|
+
command line)
|
|
21
26
|
--project <dir> Folder the dashboard may read and run the agent loop in
|
|
22
27
|
(repeatable; default: the current folder)
|
|
23
28
|
--reports <dir> Report JSON folder (default: <first project>/reports)
|
|
24
29
|
--demo Seed fake data; nothing is read from disk
|
|
25
30
|
-h, --help Show this help
|
|
26
31
|
|
|
27
|
-
The URL holds a one-time token
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
The URL holds a one-time token after the #, which the browser never sends
|
|
33
|
+
over the network. The page swaps it for a session that lives in that tab
|
|
34
|
+
only; the token then stops working. Nothing outside the project folders and
|
|
35
|
+
~/.penthera is read, and the server never contacts the network. Ctrl-C
|
|
36
|
+
stops it.
|
|
30
37
|
`;
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Write a small page that forwards to `url`, in a folder only this user can
|
|
41
|
+
* read, and return its path. --open hands the browser this path instead of
|
|
42
|
+
* the URL, so the token never appears in a command line (`ps` shows every
|
|
43
|
+
* process's arguments to every local user).
|
|
44
|
+
*/
|
|
45
|
+
export function writeLauncher(url, dir = mkdtempSync(join(tmpdir(), "penthera-ui-"))) {
|
|
46
|
+
const file = join(dir, "open.html");
|
|
47
|
+
const safe = url.replace(/[^A-Za-z0-9:/#=._-]/g, "");
|
|
48
|
+
const html = `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer">`
|
|
49
|
+
+ `<meta http-equiv="refresh" content="0;url=${safe}"><title>penthera ui</title>`
|
|
50
|
+
+ `<p>Opening penthera ui. If nothing happens, use the link printed in the terminal.</p>\n`;
|
|
51
|
+
const fd = openSync(file, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW || 0), 0o600);
|
|
52
|
+
try { writeSync(fd, html); } finally { closeSync(fd); }
|
|
53
|
+
return { file, dir };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function openBrowser(url, session) {
|
|
57
|
+
let launcher;
|
|
58
|
+
try { launcher = writeLauncher(url); } catch { return; /* the URL is printed anyway */ }
|
|
59
|
+
const target = launcher.file;
|
|
60
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [target]]
|
|
61
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", target]]
|
|
62
|
+
: ["xdg-open", [target]];
|
|
36
63
|
try {
|
|
37
64
|
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
38
65
|
} catch { /* the URL is printed anyway */ }
|
|
66
|
+
// The launcher is useless once the link is redeemed; remove it then, or after two minutes.
|
|
67
|
+
const cleanup = () => { clearInterval(poll); rmSync(launcher.dir, { recursive: true, force: true }); };
|
|
68
|
+
const poll = setInterval(() => { if (session.redeemed) cleanup(); }, 500);
|
|
69
|
+
poll.unref();
|
|
70
|
+
setTimeout(cleanup, 120_000).unref();
|
|
71
|
+
process.once("exit", cleanup);
|
|
39
72
|
}
|
|
40
73
|
|
|
41
74
|
export async function runUi(argv, { version }) {
|
|
@@ -76,9 +109,9 @@ export async function runUi(argv, { version }) {
|
|
|
76
109
|
|
|
77
110
|
process.stdout.write(`\n ${bold("penthera ui")}${opts.demo ? dim(" (demo data)") : ""}\n`);
|
|
78
111
|
process.stdout.write(` ${green(ui.url)}\n`);
|
|
79
|
-
process.stdout.write(dim(` one-time link: it signs
|
|
112
|
+
process.stdout.write(dim(` one-time link: it signs one browser tab in, then stops working\n`));
|
|
80
113
|
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);
|
|
114
|
+
if (opts.open) openBrowser(ui.url, ui.session);
|
|
82
115
|
|
|
83
116
|
await new Promise((resolve) => {
|
|
84
117
|
const stop = () => { process.stdout.write("\n Stopped.\n"); ui.close().then(resolve); };
|
package/src/cli.js
CHANGED
|
@@ -51,7 +51,7 @@ function shouldRunOnboarding(positionals, opts) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const HELP = `
|
|
54
|
-
${bold("penthera")} v${pkg.version}:
|
|
54
|
+
${bold("penthera")} v${pkg.version}: your open-source cyber security agent. The security review you never had time for
|
|
55
55
|
|
|
56
56
|
${dim("Usage")}
|
|
57
57
|
$ penthera Setup wizard: detect, check, set up (TTY, no args)
|
|
@@ -66,6 +66,7 @@ const HELP = `
|
|
|
66
66
|
$ penthera memory show|verify|forget Local learning store (never leaves the machine)
|
|
67
67
|
$ penthera fix --plan Plan local fix recipes (penthera fix --help)
|
|
68
68
|
$ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
|
|
69
|
+
$ penthera rules list|verify <dir> Signed detection rule packs (penthera rules --help)
|
|
69
70
|
$ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
|
|
70
71
|
$ penthera ui [--open] [--demo] Local dashboard on 127.0.0.1 (penthera ui --help)
|
|
71
72
|
|
|
@@ -78,6 +79,9 @@ const HELP = `
|
|
|
78
79
|
--fuzz + Property-based API fuzzing
|
|
79
80
|
--nuclei <path> + Community Nuclei YAML templates (alias for --templates)
|
|
80
81
|
--templates <paths> Comma-separated dirs of Nuclei-compatible YAML templates
|
|
82
|
+
--rules <dir> + Signed rule pack (repeatable; scans, agents, deps)
|
|
83
|
+
--rules-signers <file> Also trust packs signed by these keys (allowed_signers)
|
|
84
|
+
--allow-unsigned-rules Load a pack whose signature does not verify (warns)
|
|
81
85
|
--adaptive Knowledge-graph adaptive probes on discovered routes
|
|
82
86
|
--all Enable all modes (recon + deep + fuzz)
|
|
83
87
|
--machine macOS machine audit (keyloggers, trojans, rootkits)
|
|
@@ -210,6 +214,11 @@ export async function run() {
|
|
|
210
214
|
return runFix(process.argv.slice(3));
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
if (process.argv[2] === "rules") {
|
|
218
|
+
const { runRules } = await import("./cli/rules.js");
|
|
219
|
+
process.exit(await runRules(process.argv.slice(3)));
|
|
220
|
+
}
|
|
221
|
+
|
|
213
222
|
if (process.argv[2] === "ui") {
|
|
214
223
|
const { runUi } = await import("./cli/ui.js");
|
|
215
224
|
process.exit(await runUi(process.argv.slice(3), { version: pkg.version }));
|
|
@@ -268,6 +277,9 @@ export async function run() {
|
|
|
268
277
|
fuzz: { type: "boolean", default: false },
|
|
269
278
|
nuclei: { type: "string" },
|
|
270
279
|
templates: { type: "string" },
|
|
280
|
+
rules: { type: "string", multiple: true },
|
|
281
|
+
"rules-signers": { type: "string", multiple: true },
|
|
282
|
+
"allow-unsigned-rules": { type: "boolean", default: false },
|
|
271
283
|
adaptive: { type: "boolean", default: false },
|
|
272
284
|
all: { type: "boolean", default: false },
|
|
273
285
|
machine: { type: "boolean", default: false },
|
|
@@ -299,6 +311,17 @@ export async function run() {
|
|
|
299
311
|
process.exit(0);
|
|
300
312
|
}
|
|
301
313
|
|
|
314
|
+
// --rules <dir>: verified rule packs, added before any engine runs.
|
|
315
|
+
if (opts.rules) {
|
|
316
|
+
const { activateRules, printRuleWarnings } = await import("./cli/rules.js");
|
|
317
|
+
try {
|
|
318
|
+
printRuleWarnings(await activateRules(opts));
|
|
319
|
+
} catch (e) {
|
|
320
|
+
printError(e.message);
|
|
321
|
+
process.exit(2);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
302
325
|
if (opts["assessment-init"]) {
|
|
303
326
|
const path = resolve(opts["assessment-init"]);
|
|
304
327
|
writeFileSync(path, JSON.stringify(blankAssessment(), null, 2) + "\n");
|
package/src/report/evidence.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
13
|
import {
|
|
14
|
-
readFileSync,
|
|
14
|
+
readFileSync, mkdirSync, existsSync, readdirSync, lstatSync, mkdtempSync, realpathSync, renameSync, rmdirSync,
|
|
15
|
+
rmSync, chmodSync, openSync, writeSync, closeSync, fstatSync, readSync, constants as FS,
|
|
15
16
|
} from "node:fs";
|
|
16
|
-
import { join, resolve, relative, sep } from "node:path";
|
|
17
|
+
import { join, resolve, relative, sep, dirname, basename } from "node:path";
|
|
17
18
|
import { createGit } from "../agent/git.js";
|
|
18
19
|
|
|
19
20
|
export const SCHEMA = "penthera-evidence/1";
|
|
@@ -21,6 +22,9 @@ export const NAMESPACE = "penthera-evidence";
|
|
|
21
22
|
export const MANIFEST = "manifest.json";
|
|
22
23
|
export const SIGNATURE = "manifest.json.sig";
|
|
23
24
|
|
|
25
|
+
/** Largest artifact or manifest verify will read; a bundle holds reports, not data dumps. */
|
|
26
|
+
export const MAX_VERIFY_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
|
|
24
28
|
const SAFE_PATH = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
|
|
25
29
|
const SECRET_FLAG = /^--?(?:auth-cookie|auth-bearer|cookie|[\w-]*(?:token|secret|password|passwd|api-key|apikey)[\w-]*)$/i;
|
|
26
30
|
const SECRET_PARAM = /^(?:[\w-]*(?:token|secret|password|passwd|key|sig|signature|auth|session|code)[\w-]*)$/i;
|
|
@@ -106,26 +110,66 @@ export function assertEmptyDir(dir) {
|
|
|
106
110
|
if (readdirSync(dir).length) throw new Error(`--evidence ${dir} is not empty; use a new directory per run`);
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
/** Create `file` and write `buf`: never over an existing file, never through a symlink. */
|
|
114
|
+
function writeExclusive(file, buf) {
|
|
115
|
+
const fd = openSync(file, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW || 0), 0o644);
|
|
116
|
+
try {
|
|
117
|
+
let off = 0;
|
|
118
|
+
while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
|
|
119
|
+
} finally {
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
/**
|
|
110
|
-
* Write a bundle.
|
|
125
|
+
* Write a bundle. It is built in a private temp folder next to `dir` and
|
|
126
|
+
* renamed into place at the end, so a half-written bundle is never visible
|
|
127
|
+
* and no write lands outside that folder.
|
|
111
128
|
* @param {string} dir
|
|
112
129
|
* @param {{ path: string, content: string|Buffer, kind?: string }[]} artifacts
|
|
113
130
|
* @param {{ version: string, argv: string[], target: string, generatedAt: string,
|
|
114
|
-
* scan?: object, repo?: string|null, signKey?: string|null
|
|
131
|
+
* scan?: object, repo?: string|null, signKey?: string|null,
|
|
132
|
+
* parentReal?: string|null }} meta parentReal: the real path the
|
|
133
|
+
* parent folder must still resolve to (the dashboard's reports/evidence)
|
|
115
134
|
* @returns {{ manifest: object, signed: boolean, dir: string }}
|
|
116
135
|
*/
|
|
117
136
|
export function writeEvidenceBundle(dir, artifacts, meta) {
|
|
118
137
|
const root = resolve(dir);
|
|
119
138
|
assertEmptyDir(root);
|
|
120
|
-
|
|
139
|
+
for (const a of artifacts) {
|
|
140
|
+
if (!SAFE_PATH.test(a.path) || a.path === MANIFEST || a.path === SIGNATURE) throw new Error(`Bad artifact path ${a.path}`);
|
|
141
|
+
}
|
|
142
|
+
const parent = dirname(root);
|
|
143
|
+
mkdirSync(parent, { recursive: true });
|
|
144
|
+
const parentReal = realpathSync(parent);
|
|
145
|
+
const checkParent = () => {
|
|
146
|
+
const now = realpathSync(parent);
|
|
147
|
+
if (now !== parentReal || (meta.parentReal && now !== meta.parentReal)) {
|
|
148
|
+
throw new Error(`Refusing to write the bundle: ${parent} now resolves to ${now}`);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
checkParent();
|
|
152
|
+
const tmp = mkdtempSync(join(parentReal, `.${basename(root)}.tmp-`));
|
|
153
|
+
try {
|
|
154
|
+
const out = buildBundle(tmp, artifacts, meta);
|
|
155
|
+
chmodSync(tmp, 0o755);
|
|
156
|
+
checkParent();
|
|
157
|
+
if (existsSync(root)) rmdirSync(root); // assertEmptyDir allowed an existing empty folder
|
|
158
|
+
renameSync(tmp, join(parentReal, basename(root)));
|
|
159
|
+
return { ...out, dir: root };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
121
165
|
|
|
166
|
+
function buildBundle(root, artifacts, meta) {
|
|
122
167
|
const entries = [];
|
|
123
168
|
for (const a of artifacts) {
|
|
124
|
-
if (!SAFE_PATH.test(a.path) || a.path === MANIFEST || a.path === SIGNATURE) throw new Error(`Bad artifact path ${a.path}`);
|
|
125
169
|
const buf = Buffer.isBuffer(a.content) ? a.content : Buffer.from(String(a.content), "utf-8");
|
|
126
170
|
const file = join(root, ...a.path.split("/"));
|
|
127
|
-
mkdirSync(
|
|
128
|
-
|
|
171
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
172
|
+
writeExclusive(file, buf);
|
|
129
173
|
entries.push({ path: a.path, kind: a.kind || null, bytes: buf.length, sha256: sha256(buf) });
|
|
130
174
|
}
|
|
131
175
|
entries.sort((x, y) => x.path.localeCompare(y.path));
|
|
@@ -145,10 +189,10 @@ export function writeEvidenceBundle(dir, artifacts, meta) {
|
|
|
145
189
|
signature: signKey ? { namespace: NAMESPACE, file: SIGNATURE, public_key: publicKeyFor(signKey) } : null,
|
|
146
190
|
};
|
|
147
191
|
const manifestPath = join(root, MANIFEST);
|
|
148
|
-
|
|
192
|
+
writeExclusive(manifestPath, Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf-8"));
|
|
149
193
|
|
|
150
194
|
if (signKey) signManifest(manifestPath, signKey);
|
|
151
|
-
return { manifest, signed: Boolean(signKey)
|
|
195
|
+
return { manifest, signed: Boolean(signKey) };
|
|
152
196
|
}
|
|
153
197
|
|
|
154
198
|
/** ssh-keygen -Y sign; writes <manifest>.sig. A passphrase prompt goes to the terminal. */
|
|
@@ -161,6 +205,42 @@ export function signManifest(manifestPath, keyPath) {
|
|
|
161
205
|
if (r.status !== 0) throw new Error(`ssh-keygen -Y sign failed: ${(r.stderr || "").trim()}`);
|
|
162
206
|
}
|
|
163
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Read `rel` (a SAFE_PATH below `root`) only when every component is a real
|
|
210
|
+
* folder or, for the last, a regular file no larger than MAX_VERIFY_BYTES.
|
|
211
|
+
* Returns { buf } or { reason }.
|
|
212
|
+
*/
|
|
213
|
+
function readInside(root, rel, maxBytes = MAX_VERIFY_BYTES) {
|
|
214
|
+
let cur = root;
|
|
215
|
+
const parts = rel.split("/");
|
|
216
|
+
for (let i = 0; i < parts.length; i++) {
|
|
217
|
+
cur = join(cur, parts[i]);
|
|
218
|
+
let st;
|
|
219
|
+
try { st = lstatSync(cur); } catch { return { reason: "missing" }; }
|
|
220
|
+
const last = i === parts.length - 1;
|
|
221
|
+
if (st.isSymbolicLink()) return { reason: last ? "not a regular file" : "inside a symlinked folder" };
|
|
222
|
+
if (!last && !st.isDirectory()) return { reason: "missing" };
|
|
223
|
+
if (last && !st.isFile()) return { reason: "not a regular file" };
|
|
224
|
+
if (last && st.size > maxBytes) return { reason: `larger than ${maxBytes} bytes` };
|
|
225
|
+
}
|
|
226
|
+
let fd;
|
|
227
|
+
try { fd = openSync(cur, FS.O_RDONLY | (FS.O_NOFOLLOW || 0)); } catch { return { reason: "unreadable" }; }
|
|
228
|
+
try {
|
|
229
|
+
const size = fstatSync(fd).size;
|
|
230
|
+
if (size > maxBytes) return { reason: `larger than ${maxBytes} bytes` };
|
|
231
|
+
const buf = Buffer.alloc(size);
|
|
232
|
+
let off = 0;
|
|
233
|
+
while (off < size) {
|
|
234
|
+
const n = readSync(fd, buf, off, size - off, off);
|
|
235
|
+
if (!n) break;
|
|
236
|
+
off += n;
|
|
237
|
+
}
|
|
238
|
+
return { buf: buf.subarray(0, off) };
|
|
239
|
+
} finally {
|
|
240
|
+
closeSync(fd);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
164
244
|
function listFiles(root, dir = root, out = []) {
|
|
165
245
|
for (const name of readdirSync(dir)) {
|
|
166
246
|
const p = join(dir, name);
|
|
@@ -218,17 +298,20 @@ function checkSignature(root, manifestBuf, manifest, allowedSigners) {
|
|
|
218
298
|
* `warnings` carries the UNSIGNED notice for a bundle that passes without a
|
|
219
299
|
* signature; `requireSignature` turns that into a failure.
|
|
220
300
|
* @param {string} dir
|
|
221
|
-
* @param {{ allowedSigners?: string, requireSignature?: boolean }} opts
|
|
301
|
+
* @param {{ allowedSigners?: string, requireSignature?: boolean, maxBytes?: number }} opts
|
|
302
|
+
* maxBytes: largest file read (default MAX_VERIFY_BYTES); every path
|
|
303
|
+
* component is lstat'ed, so a symlinked folder is never followed
|
|
222
304
|
*/
|
|
223
|
-
export function verifyEvidence(dir, { allowedSigners = null, requireSignature = false } = {}) {
|
|
305
|
+
export function verifyEvidence(dir, { allowedSigners = null, requireSignature = false, maxBytes = MAX_VERIFY_BYTES } = {}) {
|
|
224
306
|
const root = resolve(dir);
|
|
225
307
|
const problems = [];
|
|
226
308
|
const files = [];
|
|
227
309
|
const fail = (msg) => ({ ok: false, problems: [msg], warnings: [], files, unlisted: [], signature: null, manifest: null });
|
|
228
310
|
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
231
|
-
|
|
311
|
+
const read = readInside(root, MANIFEST, maxBytes);
|
|
312
|
+
if (read.reason === "missing") return fail(`${MANIFEST} not found in ${dir}`);
|
|
313
|
+
if (!read.buf) return fail(`${MANIFEST}: ${read.reason}`);
|
|
314
|
+
const manifestBuf = read.buf;
|
|
232
315
|
let manifest;
|
|
233
316
|
try {
|
|
234
317
|
manifest = JSON.parse(manifestBuf.toString("utf-8"));
|
|
@@ -238,18 +321,19 @@ export function verifyEvidence(dir, { allowedSigners = null, requireSignature =
|
|
|
238
321
|
if (manifest?.schema !== SCHEMA || !Array.isArray(manifest.artifacts)) return fail(`${MANIFEST} is not a ${SCHEMA} manifest`);
|
|
239
322
|
|
|
240
323
|
const listed = new Set();
|
|
324
|
+
const folded = new Set(); // case-insensitive file systems: REPORT.json is report.json
|
|
241
325
|
for (const a of manifest.artifacts) {
|
|
242
326
|
const path = a?.path;
|
|
243
|
-
|
|
327
|
+
const lower = typeof path === "string" ? path.toLowerCase() : null;
|
|
328
|
+
if (typeof path !== "string" || !SAFE_PATH.test(path) || lower === MANIFEST || lower === SIGNATURE || folded.has(lower)) {
|
|
244
329
|
problems.push(`unsafe or duplicate artifact path ${JSON.stringify(path)}`);
|
|
245
330
|
continue;
|
|
246
331
|
}
|
|
247
332
|
listed.add(path);
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
if (!
|
|
251
|
-
|
|
252
|
-
const actual = sha256(readFileSync(file));
|
|
333
|
+
folded.add(lower);
|
|
334
|
+
const got = readInside(root, path, maxBytes);
|
|
335
|
+
if (!got.buf) { files.push({ path, ok: false, reason: got.reason }); continue; }
|
|
336
|
+
const actual = sha256(got.buf);
|
|
253
337
|
if (typeof a.sha256 !== "string" || actual !== a.sha256.toLowerCase()) {
|
|
254
338
|
files.push({ path, ok: false, reason: "hash mismatch", expected: a.sha256, actual });
|
|
255
339
|
} else {
|
package/src/report/footer.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* report, audience reports, the PR comment and the evidence bundle README.
|
|
4
4
|
* Never added to SARIF or JSON. `--no-footer` turns it off.
|
|
5
5
|
*/
|
|
6
|
-
export const FOOTER = "Checked by Penthera, open-source security
|
|
6
|
+
export const FOOTER = "Checked by Penthera, an open-source cyber security agent · https://github.com/danoszz/penthera";
|
|
7
7
|
|
|
8
8
|
/** Lines to push at the very end of a Markdown document, or none when opted out. */
|
|
9
9
|
export function footerLines({ footer = true } = {}) {
|
package/src/report/md-escape.js
CHANGED
|
@@ -14,3 +14,11 @@ export function mdInline(s) {
|
|
|
14
14
|
.replace(/>/g, ">")
|
|
15
15
|
.replace(/\|/g, "\\|");
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A Markdown table cell for our own CI reports (text we wrote, not scanned
|
|
20
|
+
* input): backslashes first, then pipes, newlines folded to a space.
|
|
21
|
+
*/
|
|
22
|
+
export function mdCell(s) {
|
|
23
|
+
return String(s).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n|\r/g, " ");
|
|
24
|
+
}
|
package/src/scan-repo.js
CHANGED
|
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
import { discoverAttackSurface, groupByTrustBoundary, findRiskyRoutes } from "../lib/attack-surface.js";
|
|
13
13
|
import { scanSecrets } from "../lib/whitebox/secrets.js";
|
|
14
14
|
import { discoverFrameworkRoutes } from "../lib/whitebox/frameworks.js";
|
|
15
|
+
import { runtimeAdvisoriesForRepo } from "../lib/advisories/index.js";
|
|
15
16
|
|
|
16
17
|
/** Auto-detect API routes directory (Next.js, Express-style, etc.). */
|
|
17
18
|
function findApiRoot(repoPath) {
|
|
@@ -62,6 +63,11 @@ export async function scanRepo(repoPath, opts = {}) {
|
|
|
62
63
|
result.findings.push(...scanSecrets(repoPath));
|
|
63
64
|
result.executedProbes.push("secret-scanning");
|
|
64
65
|
|
|
66
|
+
// ── Phase 1b: Runtime pins (Dockerfile, .tool-versions, ...) vs advisories
|
|
67
|
+
progress("Checking pinned runtime versions against advisories...");
|
|
68
|
+
result.findings.push(...runtimeAdvisoriesForRepo(repoPath));
|
|
69
|
+
result.executedProbes.push("runtime-advisories");
|
|
70
|
+
|
|
65
71
|
// ── Phase 2: Locate API root ───────────────────────────────────────────
|
|
66
72
|
progress("Locating API routes directory...");
|
|
67
73
|
const apiRoot = opts.apiRoot || findApiRoot(repoPath);
|
package/src/scan-url.js
CHANGED
|
@@ -51,12 +51,13 @@ import { scanOpenApi, probeAuthEndpoints } from "../lib/blackbox/openapi.js";
|
|
|
51
51
|
import { probeIdorBola } from "../lib/blackbox/idor.js";
|
|
52
52
|
import { probeOAuthMisconfig } from "../lib/blackbox/oauth.js";
|
|
53
53
|
import { auditSecurityHeaders } from "../lib/blackbox/headers.js";
|
|
54
|
+
import { runtimeAdvisoriesForResponse } from "../lib/advisories/index.js";
|
|
54
55
|
import { probeClientSideAuth } from "../lib/blackbox/client-auth.js";
|
|
55
56
|
import { probeJwt } from "../lib/blackbox/jwt.js";
|
|
56
57
|
import { probeEmailDns } from "../lib/blackbox/email-dns.js";
|
|
57
58
|
import { resolveAuth } from "./utils/auth.js";
|
|
58
59
|
import { normalizeBaseUrl, isPrivateHost, joinUrl } from "./utils/url.js";
|
|
59
|
-
import { safeFetch, createEgressContext, runWithEgress, egressSummary, probeErrors } from "./utils/http.js";
|
|
60
|
+
import { safeFetch, readBody, createEgressContext, runWithEgress, egressSummary, probeErrors } from "./utils/http.js";
|
|
60
61
|
import { RECON_HOSTS } from "../lib/recon.js";
|
|
61
62
|
import { dedupeFindings } from "./cli/merge-results.js";
|
|
62
63
|
|
|
@@ -181,6 +182,13 @@ async function scanUrlInContext(target, opts) {
|
|
|
181
182
|
result.findings.push(...auditSecurityHeaders(result.fingerprint.headers, target, { local }));
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// ── Phase 4b2: Runtime advisories (PHP, nginx, Apache, ... versions) ───
|
|
186
|
+
progress("Checking runtime and server versions against advisories...");
|
|
187
|
+
if (result.fingerprint?.headers) {
|
|
188
|
+
const phpPaths = filtered.filter((ep) => ep.status >= 200 && ep.status < 400 && /\.php(?:$|[?#])/i.test(ep.path)).map((ep) => ep.path);
|
|
189
|
+
result.findings.push(...runtimeAdvisoriesForResponse(result.fingerprint, { phpPaths, url: target }));
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
// ── Phase 4c: OpenAPI / auth probes ────────────────────────────────────
|
|
185
193
|
progress("Scanning OpenAPI spec and auth endpoints...");
|
|
186
194
|
const openApiResult = await scanOpenApi(target, { timeout });
|
|
@@ -261,7 +269,7 @@ async function scanUrlInContext(target, opts) {
|
|
|
261
269
|
unchecked.map(async (file) => {
|
|
262
270
|
const res = await safeFetch(joinUrl(target, file.path), { timeout: 5_000, probe: "sensitive-files" });
|
|
263
271
|
if (!res || res.status !== 200) return;
|
|
264
|
-
const text = await res
|
|
272
|
+
const text = await readBody(res).catch(() => "");
|
|
265
273
|
|
|
266
274
|
if (file.severity === "info") {
|
|
267
275
|
result.findings.push({
|
|
@@ -594,7 +602,7 @@ async function scanUrlInContext(target, opts) {
|
|
|
594
602
|
"client-auth", "idor", "oauth", "sensitive-files", "cors", "cookie",
|
|
595
603
|
...(!local && !isIpHost ? ["email-dns"] : []),
|
|
596
604
|
...(result.tls ? ["tls"] : []),
|
|
597
|
-
...(result.fingerprint?.headers ? ["security-headers"] : []),
|
|
605
|
+
...(result.fingerprint?.headers ? ["security-headers", "runtime-advisories"] : []),
|
|
598
606
|
...(auth.bearer ? ["jwt"] : []),
|
|
599
607
|
...(!opts.skipRetireJs && retireJsRan ? ["retirejs"] : []),
|
|
600
608
|
...(!opts.skipParamDiscovery && hadEndpoints ? ["param-discovery"] : []),
|