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/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
- const DB_URL =
18
- "https://raw.githubusercontent.com/nickvergessen/retire.js/master/repository/jsrepository.json";
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
- const res = await safeFetch(DB_URL);
42
- if (!res || !res.ok) return null;
43
-
44
- try {
45
- cachedDb = await res.json();
46
- cachedAt = Date.now();
47
- return cachedDb;
48
- } catch {
49
- return null;
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 result; // Can't check without DB
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) => new RegExp(p, matcher.flags || "").test(target);
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 => !new RegExp(p).test(target));
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
- const match = target.match(new RegExp(pattern));
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
- { type: "word", words: ["="], condition: "or" },
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
- { type: "negative-word", words: ["Permission denied", "error"] },
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
  }],
@@ -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
- { name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "critical" },
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 rule of SECRET_PATTERNS) {
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: rule.severity,
101
- title: `${rule.name} in source`,
102
- description: `Possible secret in \`${rel}\`: \`${maskMatch(match[0])}\``,
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.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
- "description": "Lightweight security scanner for web apps — scan URLs or repos from the CLI",
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": "./bin/penthera.js",
16
- "penthera-scan": "./bin/scan.js"
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": ">=18"
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": "^3.1.1"
69
+ "vitest": "^5.0.5"
72
70
  },
73
71
  "dependencies": {
74
- "yaml": "^2.8.3"
72
+ "yaml": "^2.9.1"
75
73
  }
76
74
  }
@@ -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 18+, network access to target, Penthera CLI (npm link or node bin/penthera.js from repo). macOS only for --machine.
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.1.0
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. Unit + mock server tests
50
- 2. Skill preflight check
51
- 3. Scan against the local mock API → JSON + SARIF
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
- For **your staging URL**, copy `.github/workflows/scan.example.yml` to `scan.yml`:
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
- ```yaml
58
- name: Security Scan
59
-
60
- on:
61
- workflow_dispatch:
62
- schedule:
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 18+ from [nodejs.org](https://nodejs.org/). Verify:
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 18+
23
+ # Node.js 22+
24
24
  if ! command -v node >/dev/null 2>&1; then
25
- fail "Node.js not found. Install Node.js 18+ from https://nodejs.org/"
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 18 ]; then
30
- fail "Node.js $(node -v) is too old. Requires Node.js 18+."
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
 
@@ -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
- function findingKey(f) {
7
- return `${f.severity}::${f.title}::${f.url || ""}::${f.source || ""}`;
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;
@@ -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
- const key = `${f.severity}::${f.title}::${f.url || ""}::${f.source || ""}`;
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["n/a"]} n/a\n`,
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
- w(` ${colorFn(label)} ${f.title}`);
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"] : []),
@@ -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"