penthera 1.1.0 → 1.2.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 +104 -3
- package/README.md +151 -249
- package/SECURITY.md +28 -6
- package/docs/compliance-nis2.md +166 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- package/docs/index.md +4 -3
- package/lib/compliance/readiness.js +16 -4
- package/lib/injections.js +103 -34
- package/lib/machine.js +39 -5
- package/lib/nuclei-loader.js +56 -5
- package/lib/retirejs.js +36 -12
- package/lib/templates.js +32 -5
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +7 -9
- package/skills/penthera/SKILL.md +2 -2
- 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/cli/baseline.js +6 -3
- package/src/cli/merge-results.js +3 -1
- package/src/cli.js +1 -1
- package/src/reporter.js +10 -1
- package/src/scan-url.js +22 -1
- package/scripts/package-skill.sh +0 -16
package/lib/retirejs.js
CHANGED
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
* https://raw.githubusercontent.com/nickvergessen/retire.js/master/repository/jsrepository.json
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Upstream RetireJS repository. This previously pointed at a personal fork that
|
|
18
|
+
// now 404s, which made every scan silently report "no vulnerable libraries".
|
|
19
|
+
// A vulnerability database that fails closed and quietly is worse than none.
|
|
20
|
+
const DB_URLS = [
|
|
21
|
+
"https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/gh/RetireJS/retire.js@master/repository/jsrepository.json",
|
|
23
|
+
];
|
|
19
24
|
const DB_CACHE_MS = 3600_000; // 1 hour
|
|
20
25
|
|
|
21
26
|
let cachedDb = null;
|
|
@@ -38,16 +43,22 @@ async function safeFetch(url, timeout = 15_000) {
|
|
|
38
43
|
async function loadDb() {
|
|
39
44
|
if (cachedDb && Date.now() - cachedAt < DB_CACHE_MS) return cachedDb;
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
// Try each mirror in turn so one host being down doesn't disable the check.
|
|
47
|
+
for (const url of DB_URLS) {
|
|
48
|
+
const res = await safeFetch(url);
|
|
49
|
+
if (!res || !res.ok) continue;
|
|
50
|
+
try {
|
|
51
|
+
const db = await res.json();
|
|
52
|
+
if (db && typeof db === "object" && Object.keys(db).length > 0) {
|
|
53
|
+
cachedDb = db;
|
|
54
|
+
cachedAt = Date.now();
|
|
55
|
+
return cachedDb;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Malformed response from this mirror; try the next.
|
|
59
|
+
}
|
|
50
60
|
}
|
|
61
|
+
return null;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
// ── Version comparison ───────────────────────────────────────────────────
|
|
@@ -149,8 +160,21 @@ export async function scanJsLibraries(baseUrl, opts = {}) {
|
|
|
149
160
|
progress("Fetching Retire.js vulnerability database...");
|
|
150
161
|
const db = await loadDb();
|
|
151
162
|
if (!db) {
|
|
152
|
-
return
|
|
163
|
+
// Never return a silent "no vulnerable libraries" when the check could not
|
|
164
|
+
// run. Say so, and tell the caller the probe did not really execute.
|
|
165
|
+
result.dbLoaded = false;
|
|
166
|
+
result.findings.push({
|
|
167
|
+
severity: "info",
|
|
168
|
+
title: "JS dependency check did not run",
|
|
169
|
+
description: "Could not load the Retire.js vulnerability database (offline, or every mirror unreachable). Known-CVE checks on client-side JavaScript were skipped for this scan; this is not a clean result.",
|
|
170
|
+
url: baseUrl,
|
|
171
|
+
category: "config",
|
|
172
|
+
source: "retirejs",
|
|
173
|
+
confidence: "confirmed",
|
|
174
|
+
});
|
|
175
|
+
return result;
|
|
153
176
|
}
|
|
177
|
+
result.dbLoaded = true;
|
|
154
178
|
|
|
155
179
|
// Fetch the target page HTML
|
|
156
180
|
const res = await safeFetch(baseUrl, timeout);
|
package/lib/templates.js
CHANGED
|
@@ -85,6 +85,24 @@ export async function executeTemplate(baseUrl, template) {
|
|
|
85
85
|
/**
|
|
86
86
|
* Run a single matcher against response data.
|
|
87
87
|
*/
|
|
88
|
+
// Community templates are untrusted input: they arrive as YAML from a directory
|
|
89
|
+
// the user points us at, and their regexes run against whole response bodies.
|
|
90
|
+
// A catastrophically-backtracking pattern would hang the scanner, so bound both
|
|
91
|
+
// the input and the pattern. This is a mitigation, not an immunity proof: real
|
|
92
|
+
// guarantees need a non-backtracking engine or a killable worker.
|
|
93
|
+
const REGEX_INPUT_CAP = 256 * 1024; // 256 KB is far past any real matcher's needs
|
|
94
|
+
const NESTED_QUANTIFIER = /(\([^)]*[+*][^)]*\)|\[[^\]]*\][^)]*)[+*]\s*[+*]|\((?:\?:)?[^)]*[+*]\)[+*]/;
|
|
95
|
+
|
|
96
|
+
function safeRegexTest(pattern, target, flags = "") {
|
|
97
|
+
if (NESTED_QUANTIFIER.test(String(pattern))) return false; // refuse the shape that blows up
|
|
98
|
+
try {
|
|
99
|
+
const input = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
100
|
+
return new RegExp(pattern, flags).test(input);
|
|
101
|
+
} catch {
|
|
102
|
+
return false; // invalid pattern in a template must not abort the scan
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
function runMatcher(matcher, ctx) {
|
|
89
107
|
const part = matcher.part || "body";
|
|
90
108
|
const target = part === "header" ? ctx.headers : ctx.body;
|
|
@@ -103,7 +121,7 @@ function runMatcher(matcher, ctx) {
|
|
|
103
121
|
|
|
104
122
|
case "regex": {
|
|
105
123
|
const patterns = matcher.regex || [];
|
|
106
|
-
const check = (p) =>
|
|
124
|
+
const check = (p) => safeRegexTest(p, target, matcher.flags);
|
|
107
125
|
return matcher.condition === "and"
|
|
108
126
|
? patterns.every(check)
|
|
109
127
|
: patterns.some(check);
|
|
@@ -116,7 +134,7 @@ function runMatcher(matcher, ctx) {
|
|
|
116
134
|
|
|
117
135
|
case "negative-regex": {
|
|
118
136
|
const patterns = matcher.regex || [];
|
|
119
|
-
return patterns.every(p => !
|
|
137
|
+
return patterns.every(p => !safeRegexTest(p, target));
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
default:
|
|
@@ -134,7 +152,9 @@ function runExtractor(extractor, ctx) {
|
|
|
134
152
|
switch (extractor.type) {
|
|
135
153
|
case "regex": {
|
|
136
154
|
for (const pattern of extractor.regex || []) {
|
|
137
|
-
|
|
155
|
+
if (NESTED_QUANTIFIER.test(String(pattern))) continue;
|
|
156
|
+
const capped = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
157
|
+
const match = capped.match(new RegExp(pattern));
|
|
138
158
|
if (match) return match[extractor.group || 0];
|
|
139
159
|
}
|
|
140
160
|
return null;
|
|
@@ -182,7 +202,11 @@ export const BUILT_IN_TEMPLATES = [
|
|
|
182
202
|
method: "GET", path: "/.env.local",
|
|
183
203
|
matchers: [
|
|
184
204
|
{ type: "status", status: [200] },
|
|
185
|
-
|
|
205
|
+
// Must look like a dotenv file, not just any 200 containing "=".
|
|
206
|
+
// SPAs serve index.html for unknown paths, and HTML is full of "=",
|
|
207
|
+
// which made this fire critical on healthy sites.
|
|
208
|
+
{ type: "regex", regex: ["(?m)^[A-Z][A-Z0-9_]{2,}="] },
|
|
209
|
+
{ type: "negative-word", words: ["<html", "<!DOCTYPE", "<script"] },
|
|
186
210
|
],
|
|
187
211
|
"matchers-condition": "and",
|
|
188
212
|
}],
|
|
@@ -247,7 +271,10 @@ export const BUILT_IN_TEMPLATES = [
|
|
|
247
271
|
method: "GET", path: "/.json",
|
|
248
272
|
matchers: [
|
|
249
273
|
{ type: "status", status: [200] },
|
|
250
|
-
|
|
274
|
+
// Require an actual JSON body. Without this an SPA's index.html served
|
|
275
|
+
// at /.json counted as an open Firebase database.
|
|
276
|
+
{ type: "regex", regex: ["^\\s*[\\{\\[]"] },
|
|
277
|
+
{ type: "negative-word", words: ["Permission denied", "error", "<html", "<!DOCTYPE"] },
|
|
251
278
|
],
|
|
252
279
|
"matchers-condition": "and",
|
|
253
280
|
}],
|
package/lib/whitebox/secrets.js
CHANGED
|
@@ -21,7 +21,23 @@ const TEST_VALUE = /\b(?:placeholder|example|changeme|dummy|sample|redacted|inva
|
|
|
21
21
|
const SECRET_PATTERNS = [
|
|
22
22
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical" },
|
|
23
23
|
{ name: "GitHub token", pattern: /ghp_[a-zA-Z0-9]{36,}/, severity: "critical" },
|
|
24
|
-
|
|
24
|
+
// AI provider keys. Vibecoded apps leak these more than any other credential,
|
|
25
|
+
// often via a NEXT_PUBLIC_/VITE_ variable or a committed notebook. Specific
|
|
26
|
+
// prefixes come first so a key is reported under its real provider.
|
|
27
|
+
{ name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)\d{2}-[A-Za-z0-9_-]{32,}/, severity: "critical" },
|
|
28
|
+
{ name: "OpenRouter API key", pattern: /sk-or-v1-[a-f0-9]{64}/, severity: "critical" },
|
|
29
|
+
{ name: "OpenAI API key", pattern: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/, severity: "critical" },
|
|
30
|
+
{ name: "OpenAI API key (legacy format)", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "critical" },
|
|
31
|
+
// AIza keys are also Firebase web keys, which are public by design, so this
|
|
32
|
+
// is high rather than critical: it matters if the key has Gemini/Vertex access.
|
|
33
|
+
{ name: "Google API key (Gemini / Firebase / Maps)", pattern: /AIza[0-9A-Za-z_-]{35}/, severity: "high" },
|
|
34
|
+
{ name: "Groq API key", pattern: /gsk_[A-Za-z0-9]{48,}/, severity: "critical" },
|
|
35
|
+
{ name: "Hugging Face token", pattern: /hf_[A-Za-z0-9]{34,}/, severity: "critical" },
|
|
36
|
+
{ name: "Replicate API token", pattern: /r8_[A-Za-z0-9]{37,}/, severity: "critical" },
|
|
37
|
+
{ name: "xAI API key", pattern: /xai-[A-Za-z0-9]{70,}/, severity: "critical" },
|
|
38
|
+
{ name: "Perplexity API key", pattern: /pplx-[A-Za-z0-9]{40,}/, severity: "critical" },
|
|
39
|
+
{ name: "LangSmith API key", pattern: /lsv2_(?:pt|sk)_[a-f0-9]{32}_[a-f0-9]{10}/, severity: "high" },
|
|
40
|
+
{ name: "Pinecone API key", pattern: /pcsk_[A-Za-z0-9_]{50,}/, severity: "high" },
|
|
25
41
|
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/, severity: "critical" },
|
|
26
42
|
{
|
|
27
43
|
name: "Generic API key assignment",
|
|
@@ -35,8 +51,8 @@ const SECRET_PATTERNS = [
|
|
|
35
51
|
severity: "high",
|
|
36
52
|
skipIf: TEST_VALUE,
|
|
37
53
|
},
|
|
38
|
-
{ name: "Private key block", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, severity: "critical" },
|
|
39
54
|
{ name: "GCP/Firebase service account", pattern: /"private_key"\s*:\s*"-----BEGIN/, severity: "critical" },
|
|
55
|
+
{ name: "Private key block", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, severity: "critical" },
|
|
40
56
|
{
|
|
41
57
|
name: "JWT secret assignment",
|
|
42
58
|
pattern: /(?:jwt[_-]?secret|session[_-]?secret)\s*[:=]\s*['"][^'"\s]{8,}['"]/i,
|
|
@@ -48,6 +64,7 @@ const SECRET_PATTERNS = [
|
|
|
48
64
|
const SCAN_EXTENSIONS = new Set([
|
|
49
65
|
".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".java",
|
|
50
66
|
".env", ".yaml", ".yml", ".json", ".toml", ".cfg", ".ini", ".sh",
|
|
67
|
+
".ipynb", // notebooks are a classic place for a pasted provider key
|
|
51
68
|
]);
|
|
52
69
|
|
|
53
70
|
function walkFiles(dir, files = []) {
|
|
@@ -69,9 +86,34 @@ function maskMatch(match) {
|
|
|
69
86
|
return match.length <= 8 ? "***" : match.slice(0, 4) + "…" + match.slice(-4);
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Find secrets in a string of text (a source file, a JS bundle, a config).
|
|
91
|
+
* Returns one hit per rule: `{ name, severity, match }`, with the raw match so
|
|
92
|
+
* callers decide how to mask it. At most one rule fires per provider key: once a
|
|
93
|
+
* specific AI-provider rule matches a span, broader rules skip that span.
|
|
94
|
+
*/
|
|
95
|
+
export function findSecretsInText(content) {
|
|
96
|
+
const hits = [];
|
|
97
|
+
const claimed = [];
|
|
98
|
+
for (const rule of SECRET_PATTERNS) {
|
|
99
|
+
const match = content.match(rule.pattern);
|
|
100
|
+
if (!match) continue;
|
|
101
|
+
// For assignment-style rules, test the placeholder/dummy filter against
|
|
102
|
+
// the assigned value only (not the whole `key = "value"` match), so a
|
|
103
|
+
// variable named e.g. `testToken` can't mask a real secret.
|
|
104
|
+
const value = (match[0].match(/['"]([^'"]+)['"]/) || [, match[0]])[1];
|
|
105
|
+
if (rule.skipIf && rule.skipIf.test(value)) continue;
|
|
106
|
+
const start = match.index;
|
|
107
|
+
const end = start + match[0].length;
|
|
108
|
+
if (claimed.some(([a, b]) => start < b && end > a)) continue;
|
|
109
|
+
claimed.push([start, end]);
|
|
110
|
+
hits.push({ name: rule.name, severity: rule.severity, match: match[0] });
|
|
111
|
+
}
|
|
112
|
+
return hits;
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
export function scanSecrets(repoPath) {
|
|
73
116
|
const findings = [];
|
|
74
|
-
const seen = new Set();
|
|
75
117
|
|
|
76
118
|
for (const filePath of walkFiles(repoPath)) {
|
|
77
119
|
let content;
|
|
@@ -83,23 +125,11 @@ export function scanSecrets(repoPath) {
|
|
|
83
125
|
|
|
84
126
|
const rel = relative(repoPath, filePath);
|
|
85
127
|
|
|
86
|
-
for (const
|
|
87
|
-
const match = content.match(rule.pattern);
|
|
88
|
-
if (!match) continue;
|
|
89
|
-
// For assignment-style rules, test the placeholder/dummy filter against
|
|
90
|
-
// the assigned value only (not the whole `key = "value"` match), so a
|
|
91
|
-
// variable named e.g. `testToken` can't mask a real secret.
|
|
92
|
-
const value = (match[0].match(/['"]([^'"]+)['"]/) || [, match[0]])[1];
|
|
93
|
-
if (rule.skipIf && rule.skipIf.test(value)) continue;
|
|
94
|
-
|
|
95
|
-
const key = `${rel}::${rule.name}`;
|
|
96
|
-
if (seen.has(key)) continue;
|
|
97
|
-
seen.add(key);
|
|
98
|
-
|
|
128
|
+
for (const hit of findSecretsInText(content)) {
|
|
99
129
|
findings.push({
|
|
100
|
-
severity:
|
|
101
|
-
title: `${
|
|
102
|
-
description: `Possible secret in \`${rel}\`: \`${maskMatch(match
|
|
130
|
+
severity: hit.severity,
|
|
131
|
+
title: `${hit.name} in source`,
|
|
132
|
+
description: `Possible secret in \`${rel}\`: \`${maskMatch(hit.match)}\``,
|
|
103
133
|
url: rel,
|
|
104
134
|
category: "secrets",
|
|
105
135
|
source: "secret-scan",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "penthera",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Security scanner for web apps and AI-built code: scan URLs and repos from Claude Code, Cursor or the CLI. OWASP-mapped, SARIF output, NIS2 evidence. Authorized testing only.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/danoszz/penthera.git"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"url": "https://github.com/danoszz/penthera/issues"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"penthera": "
|
|
16
|
-
"penthera-scan": "
|
|
15
|
+
"penthera": "bin/penthera.js",
|
|
16
|
+
"penthera-scan": "bin/scan.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"bin",
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
"lib",
|
|
22
22
|
"skills",
|
|
23
23
|
"docs",
|
|
24
|
-
"scripts/package-skill.sh",
|
|
25
24
|
"pentest.config.example.js",
|
|
26
25
|
"CHANGELOG.md",
|
|
27
26
|
"LICENSE",
|
|
@@ -34,7 +33,7 @@
|
|
|
34
33
|
"./wstg": "./lib/owasp-wstg.js"
|
|
35
34
|
},
|
|
36
35
|
"engines": {
|
|
37
|
-
"node": ">=
|
|
36
|
+
"node": ">=22"
|
|
38
37
|
},
|
|
39
38
|
"scripts": {
|
|
40
39
|
"test": "vitest run --reporter=verbose",
|
|
@@ -42,7 +41,6 @@
|
|
|
42
41
|
"scan:full": "node bin/scan.js",
|
|
43
42
|
"pentest": "vitest run --reporter=verbose",
|
|
44
43
|
"pentest:watch": "vitest --reporter=verbose",
|
|
45
|
-
"pentest:static": "vitest run tests/pentest-static.test.js --reporter=verbose",
|
|
46
44
|
"pentest:live": "vitest run tests/pentest-live.test.js --reporter=verbose",
|
|
47
45
|
"pentest:adaptive": "vitest run tests/pentest-adaptive.test.js --reporter=verbose",
|
|
48
46
|
"pentest:recon": "vitest run tests/pentest-recon.test.js --reporter=verbose",
|
|
@@ -68,9 +66,9 @@
|
|
|
68
66
|
],
|
|
69
67
|
"license": "MIT",
|
|
70
68
|
"devDependencies": {
|
|
71
|
-
"vitest": "^
|
|
69
|
+
"vitest": "^5.0.5"
|
|
72
70
|
},
|
|
73
71
|
"dependencies": {
|
|
74
|
-
"yaml": "^2.
|
|
72
|
+
"yaml": "^2.9.1"
|
|
75
73
|
}
|
|
76
74
|
}
|
package/skills/penthera/SKILL.md
CHANGED
|
@@ -9,11 +9,11 @@ description: >
|
|
|
9
9
|
against a baseline; or find hardcoded secrets before deploy. Do NOT use for targets
|
|
10
10
|
without explicit authorization.
|
|
11
11
|
license: MIT
|
|
12
|
-
compatibility: Node.js
|
|
12
|
+
compatibility: Node.js 22+, network access to target, Penthera CLI (npm link or node bin/penthera.js from repo). macOS only for --machine.
|
|
13
13
|
allowed-tools: "Bash(node:*) Bash(npm:*) Bash(penthera:*) Read Write Edit"
|
|
14
14
|
metadata:
|
|
15
15
|
author: danoszz
|
|
16
|
-
version: 1.
|
|
16
|
+
version: 1.2.0
|
|
17
17
|
category: security
|
|
18
18
|
---
|
|
19
19
|
|
|
@@ -44,59 +44,21 @@ Output includes: new findings count, resolved count, unchanged count. Use for we
|
|
|
44
44
|
|
|
45
45
|
## GitHub Actions
|
|
46
46
|
|
|
47
|
-
**Penthera CI** (`.github/workflows/ci.yml`) runs on every push:
|
|
47
|
+
**Penthera's own CI** (`.github/workflows/ci.yml`) runs on every push and PR:
|
|
48
48
|
|
|
49
|
-
1.
|
|
50
|
-
2.
|
|
51
|
-
3.
|
|
52
|
-
4. `validate-report.mjs` on the JSON output
|
|
53
|
-
5. SARIF upload to GitHub Security tab
|
|
49
|
+
1. The full test suite (`npm test`) on Node 22 and 24
|
|
50
|
+
2. A scan of the deliberately vulnerable mock API, producing JSON + SARIF
|
|
51
|
+
3. `validate-report.mjs` on the JSON output, plus the skill preflight
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
The mock-server SARIF is kept as a build artifact, not uploaded to code scanning,
|
|
54
|
+
because those findings are about the test fixture, not the repo.
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
on
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- cron: "0 6 * * 1"
|
|
64
|
-
|
|
65
|
-
jobs:
|
|
66
|
-
penthera:
|
|
67
|
-
runs-on: ubuntu-latest
|
|
68
|
-
steps:
|
|
69
|
-
- uses: actions/checkout@v4
|
|
70
|
-
|
|
71
|
-
- uses: actions/setup-node@v4
|
|
72
|
-
with:
|
|
73
|
-
node-version: 20
|
|
74
|
-
cache: npm
|
|
75
|
-
|
|
76
|
-
- run: npm ci
|
|
77
|
-
|
|
78
|
-
- name: Scan staging URL
|
|
79
|
-
env:
|
|
80
|
-
PENTEST_URL: ${{ secrets.PENTEST_STAGING_URL }}
|
|
81
|
-
run: |
|
|
82
|
-
node bin/penthera.js "$PENTEST_URL" \
|
|
83
|
-
--profile standard \
|
|
84
|
-
-o reports/scan.json \
|
|
85
|
-
--sarif reports/scan.sarif
|
|
86
|
-
|
|
87
|
-
- uses: github/codeql-action/upload-sarif@v3
|
|
88
|
-
if: always()
|
|
89
|
-
with:
|
|
90
|
-
sarif_file: reports/scan.sarif
|
|
91
|
-
|
|
92
|
-
- uses: actions/upload-artifact@v4
|
|
93
|
-
if: always()
|
|
94
|
-
with:
|
|
95
|
-
name: penthera-reports
|
|
96
|
-
path: reports/
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Store `PENTEST_STAGING_URL` as a repository secret.
|
|
56
|
+
**For your own staging URL**, copy
|
|
57
|
+
[`docs/examples/github-actions-scan.yml`](https://github.com/danoszz/penthera/blob/main/docs/examples/github-actions-scan.yml)
|
|
58
|
+
into your app's repo as `.github/workflows/penthera.yml` and add a
|
|
59
|
+
`PENTEST_STAGING_URL` repository secret. It runs `npx penthera@<version>` on a
|
|
60
|
+
weekly schedule and uploads SARIF to your Security tab. Pin the version so a
|
|
61
|
+
scheduled run never picks up code you have not reviewed.
|
|
100
62
|
|
|
101
63
|
## Agent reporting workflow
|
|
102
64
|
|
|
@@ -21,7 +21,7 @@ penthera https://myapp.com --verbose --timeout 30000
|
|
|
21
21
|
|
|
22
22
|
**Symptoms:** `node: command not found` or engine error
|
|
23
23
|
|
|
24
|
-
**Solution:** Install Node.js
|
|
24
|
+
**Solution:** Install Node.js 22+ from [nodejs.org](https://nodejs.org/). Verify:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
node -v # should be v18.x or higher
|
|
@@ -20,14 +20,14 @@ ok() {
|
|
|
20
20
|
echo "preflight: OK: $*"
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
# Node.js
|
|
23
|
+
# Node.js 22+
|
|
24
24
|
if ! command -v node >/dev/null 2>&1; then
|
|
25
|
-
fail "Node.js not found. Install Node.js
|
|
25
|
+
fail "Node.js not found. Install Node.js 22+ from https://nodejs.org/"
|
|
26
26
|
fi
|
|
27
27
|
|
|
28
28
|
NODE_MAJOR=$(node -e "process.stdout.write(String(process.version).replace(/^v/, '').split('.')[0])")
|
|
29
|
-
if [ "$NODE_MAJOR" -lt
|
|
30
|
-
fail "Node.js $(node -v) is too old. Requires Node.js
|
|
29
|
+
if [ "$NODE_MAJOR" -lt 22 ]; then
|
|
30
|
+
fail "Node.js $(node -v) is too old. Requires Node.js 22+."
|
|
31
31
|
fi
|
|
32
32
|
ok "Node.js $(node -v)"
|
|
33
33
|
|
package/src/cli/baseline.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Compare scan findings against a previous JSON report.
|
|
3
3
|
*/
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fingerprintFinding } from "../../lib/audit-loop.js";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// Shared with dedupe and the audit loop so one finding has one identity
|
|
8
|
+
// everywhere. Deliberately excludes severity: a finding whose severity is
|
|
9
|
+
// re-rated is the same finding, and keying on it made every re-rating show up
|
|
10
|
+
// as one resolved plus one new.
|
|
11
|
+
const findingKey = fingerprintFinding;
|
|
9
12
|
|
|
10
13
|
export function compareWithBaseline(currentFindings, baselinePath) {
|
|
11
14
|
let baseline;
|
package/src/cli/merge-results.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { enrichFindingsWithWstg } from "../../lib/owasp-wstg.js";
|
|
5
5
|
import { stampProvenance } from "../../lib/provenance.js";
|
|
6
|
+
import { fingerprintFinding } from "../../lib/audit-loop.js";
|
|
6
7
|
|
|
7
8
|
export function mergeResults(results) {
|
|
8
9
|
let merged;
|
|
@@ -63,7 +64,8 @@ function mergeEndpoints(results) {
|
|
|
63
64
|
export function dedupeFindings(findings) {
|
|
64
65
|
const seen = new Set();
|
|
65
66
|
return findings.filter((f) => {
|
|
66
|
-
|
|
67
|
+
// Same identity function as the baseline diff and the audit loop.
|
|
68
|
+
const key = fingerprintFinding(f);
|
|
67
69
|
if (seen.has(key)) return false;
|
|
68
70
|
seen.add(key);
|
|
69
71
|
return true;
|
package/src/cli.js
CHANGED
|
@@ -453,7 +453,7 @@ export async function run() {
|
|
|
453
453
|
if (!opts.quiet && !opts.json) {
|
|
454
454
|
const s = merged.readiness.summary;
|
|
455
455
|
process.stderr.write(
|
|
456
|
-
` NIS2 readiness ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["
|
|
456
|
+
` NIS2 readiness ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["not-assessed"]} not assessed\n`,
|
|
457
457
|
);
|
|
458
458
|
process.stderr.write(` Readiness report ${rdPath}\n`);
|
|
459
459
|
}
|
package/src/reporter.js
CHANGED
|
@@ -319,7 +319,11 @@ export function printReport(result, opts = {}) {
|
|
|
319
319
|
for (const f of sorted) {
|
|
320
320
|
const colorFn = SEV_COLOR[f.severity] || c.dim;
|
|
321
321
|
const label = SEV_LABEL[f.severity] || " ";
|
|
322
|
-
|
|
322
|
+
// Flag the ones a scanner genuinely can't adjudicate, so nobody reads a
|
|
323
|
+
// heuristic as a confirmed break. Certainties stay unannotated.
|
|
324
|
+
const needsReview = f.confidence === "needs-human-review" || f.confidence === "potential";
|
|
325
|
+
const tag = needsReview ? c.dim(` [${f.confidence}]`) : "";
|
|
326
|
+
w(` ${colorFn(label)} ${f.title}${tag}`);
|
|
323
327
|
if (f.url) w(` ${" ".repeat(6)} ${c.dim(f.url)}${f.status ? c.dim(` \u2192 ${f.status}`) : ""}`);
|
|
324
328
|
if (f.description && opts.verbose) w(` ${" ".repeat(6)} ${c.dim(f.description)}`);
|
|
325
329
|
}
|
|
@@ -457,7 +461,12 @@ export function writeSarifReport(result, filePath) {
|
|
|
457
461
|
}] : [],
|
|
458
462
|
properties: {
|
|
459
463
|
severity: f.severity,
|
|
464
|
+
// Provenance travels with the finding into GitHub code scanning, so a
|
|
465
|
+
// heuristic result stays labelled as one wherever it is consumed.
|
|
466
|
+
confidence: f.confidence,
|
|
460
467
|
source: f.source,
|
|
468
|
+
method: f.method,
|
|
469
|
+
collectedAt: f.collected_at,
|
|
461
470
|
url: f.url,
|
|
462
471
|
httpStatus: f.status,
|
|
463
472
|
wstg: f.wstg || resolveWstgTags(f),
|
package/src/scan-url.js
CHANGED
|
@@ -76,6 +76,8 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
76
76
|
const startTime = Date.now();
|
|
77
77
|
|
|
78
78
|
const local = isPrivateHost(target);
|
|
79
|
+
// Set false when the Retire.js database can't load, so we don't claim the probe ran.
|
|
80
|
+
let retireJsRan = false;
|
|
79
81
|
const hostname = new URL(target).hostname;
|
|
80
82
|
const isIpHost = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");
|
|
81
83
|
|
|
@@ -385,6 +387,9 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
385
387
|
progress("Scanning for vulnerable JS libraries (Retire.js)...");
|
|
386
388
|
try {
|
|
387
389
|
const jsResult = await scanJsLibraries(target, { onPhase: progress });
|
|
390
|
+
// Only count the probe as run when its database actually loaded, so the
|
|
391
|
+
// WSTG and compliance coverage can't claim a check that was skipped.
|
|
392
|
+
retireJsRan = jsResult.dbLoaded !== false;
|
|
388
393
|
if (jsResult.libraries.length > 0) {
|
|
389
394
|
result.jsLibraries = jsResult.libraries;
|
|
390
395
|
}
|
|
@@ -500,6 +505,22 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
500
505
|
severity: ["critical", "high", "medium"],
|
|
501
506
|
});
|
|
502
507
|
progress(`Loaded ${templates.length} templates, scanning...`);
|
|
508
|
+
// Templates needing Nuclei features this loader doesn't implement won't
|
|
509
|
+
// match even against a vulnerable target. Say so rather than letting the
|
|
510
|
+
// silence read as a clean result.
|
|
511
|
+
const limited = templates.filter((t) => t.unsupported?.length);
|
|
512
|
+
if (limited.length > 0) {
|
|
513
|
+
const reasons = [...new Set(limited.flatMap((t) => t.unsupported))];
|
|
514
|
+
progress(`${limited.length} template(s) use unsupported features and cannot match: ${reasons.join("; ")}`);
|
|
515
|
+
result.findings.push({
|
|
516
|
+
severity: "info",
|
|
517
|
+
title: `${limited.length} community template(s) could not run fully`,
|
|
518
|
+
description: `These templates rely on Nuclei features Penthera does not implement (${reasons.join("; ")}), so they cannot produce a match. Their silence is not a clean result. Templates affected: ${limited.slice(0, 5).map((t) => t.id).join(", ")}${limited.length > 5 ? ", ..." : ""}.`,
|
|
519
|
+
category: "config",
|
|
520
|
+
source: "plugin-loader",
|
|
521
|
+
confidence: "confirmed",
|
|
522
|
+
});
|
|
523
|
+
}
|
|
503
524
|
const pluginFindings = await runTemplateScan(target, templates, { onPhase: progress });
|
|
504
525
|
result.findings.push(...pluginFindings);
|
|
505
526
|
} catch (e) {
|
|
@@ -547,7 +568,7 @@ export async function scanUrl(rawTarget, opts = {}) {
|
|
|
547
568
|
...(result.tls ? ["tls"] : []),
|
|
548
569
|
...(result.fingerprint?.headers ? ["security-headers"] : []),
|
|
549
570
|
...(auth.bearer ? ["jwt"] : []),
|
|
550
|
-
...(!opts.skipRetireJs ? ["retirejs"] : []),
|
|
571
|
+
...(!opts.skipRetireJs && retireJsRan ? ["retirejs"] : []),
|
|
551
572
|
...(!opts.skipParamDiscovery && hadEndpoints ? ["param-discovery"] : []),
|
|
552
573
|
...(opts.recon && !local ? ["recon"] : []),
|
|
553
574
|
...(opts.deep && hadEndpoints ? ["sqli", "ssti", "ssrf", "xss", "cmdi", "open-redirect"] : []),
|
package/scripts/package-skill.sh
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Package the Agent Skill for Claude.ai upload or GitHub Releases.
|
|
3
|
-
set -euo pipefail
|
|
4
|
-
|
|
5
|
-
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
6
|
-
OUT="$ROOT/dist"
|
|
7
|
-
ZIP="$OUT/penthera-skill.zip"
|
|
8
|
-
|
|
9
|
-
mkdir -p "$OUT"
|
|
10
|
-
rm -f "$ZIP"
|
|
11
|
-
|
|
12
|
-
cd "$ROOT/skills"
|
|
13
|
-
zip -r "$ZIP" penthera/ -x "*.DS_Store"
|
|
14
|
-
|
|
15
|
-
echo "Created $ZIP"
|
|
16
|
-
ls -lh "$ZIP"
|