penthera 1.1.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +269 -3
  2. package/README.md +489 -286
  3. package/SECURITY.md +28 -6
  4. package/docs/METHODOLOGY.md +321 -0
  5. package/docs/compliance-nis2.md +166 -0
  6. package/docs/evidence.md +158 -0
  7. package/docs/examples/github-actions-deps.yml +105 -0
  8. package/docs/examples/github-actions-scan.yml +55 -0
  9. package/docs/examples/pr-dependency-review.yml +56 -0
  10. package/docs/funding/due-diligence-checklist.md +79 -0
  11. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  12. package/docs/funding/partner-one-pager.md +84 -0
  13. package/docs/funding/sponsor-tiers.md +81 -0
  14. package/docs/index.md +15 -3
  15. package/docs/local-loop.md +276 -0
  16. package/docs/references.bib +539 -0
  17. package/docs/references.md +77 -0
  18. package/docs/release-plan.md +228 -0
  19. package/lib/adaptive-probes.js +8 -5
  20. package/lib/agents/discover.js +384 -0
  21. package/lib/agents/hidden-content.js +387 -0
  22. package/lib/agents/index.js +286 -0
  23. package/lib/agents/lock.js +115 -0
  24. package/lib/agents/owasp.js +83 -0
  25. package/lib/agents/rules.js +376 -0
  26. package/lib/crawler.js +5 -15
  27. package/lib/data/retire-jsrepository.json.gz +0 -0
  28. package/lib/data/retire-jsrepository.meta.json +7 -0
  29. package/lib/deps/capabilities.js +523 -0
  30. package/lib/deps/diff.js +84 -0
  31. package/lib/deps/index.js +416 -0
  32. package/lib/deps/lockfile.js +106 -0
  33. package/lib/deps/online.js +132 -0
  34. package/lib/deps/popular-packages.json +1 -0
  35. package/lib/deps/report.js +173 -0
  36. package/lib/deps/source.js +171 -0
  37. package/lib/deps/typosquat.js +113 -0
  38. package/lib/fix/apply.js +95 -0
  39. package/lib/fix/diff.js +199 -0
  40. package/lib/fix/index.js +129 -0
  41. package/lib/fix/model.js +108 -0
  42. package/lib/fix/recipes/cookies.js +130 -0
  43. package/lib/fix/recipes/cors.js +116 -0
  44. package/lib/fix/recipes/headers.js +250 -0
  45. package/lib/fix/recipes/rate-limit.js +211 -0
  46. package/lib/fix/recipes/secrets.js +110 -0
  47. package/lib/fix/util.js +231 -0
  48. package/lib/fuzzer.js +14 -8
  49. package/lib/injections.js +19 -15
  50. package/lib/install/index.js +271 -0
  51. package/lib/install/policy.js +117 -0
  52. package/lib/memory/history.js +52 -0
  53. package/lib/memory/index.js +111 -0
  54. package/lib/memory/repo-suppress.js +64 -0
  55. package/lib/memory/store.js +325 -0
  56. package/lib/memory/views.js +198 -0
  57. package/lib/params.js +4 -12
  58. package/lib/provenance.js +2 -0
  59. package/lib/recon.js +12 -20
  60. package/lib/references.js +31 -0
  61. package/lib/references.json +374 -0
  62. package/lib/remediation/index.js +28 -0
  63. package/lib/retirejs.js +50 -22
  64. package/lib/templates.js +7 -10
  65. package/lib/whitebox/frameworks.js +1 -1
  66. package/lib/whitebox/secrets.js +49 -19
  67. package/package.json +19 -9
  68. package/skills/penthera/SKILL.md +104 -23
  69. package/skills/penthera/references/output-and-ci.md +12 -50
  70. package/skills/penthera/references/troubleshooting.md +1 -1
  71. package/skills/penthera/scripts/preflight.sh +4 -4
  72. package/src/agent/git.js +87 -0
  73. package/src/agent/loop.js +519 -0
  74. package/src/agent/tests.js +48 -0
  75. package/src/cli/agent.js +183 -0
  76. package/src/cli/deps.js +84 -0
  77. package/src/cli/evidence.js +67 -0
  78. package/src/cli/fix.js +191 -0
  79. package/src/cli/install.js +225 -0
  80. package/src/cli/memory.js +146 -0
  81. package/src/cli/merge-results.js +2 -0
  82. package/src/cli/report-outputs.js +72 -0
  83. package/src/cli/run-scan.js +2 -0
  84. package/src/cli.js +152 -6
  85. package/src/report/audience.js +270 -0
  86. package/src/report/evidence.js +253 -0
  87. package/src/report/markdown.js +65 -2
  88. package/src/report/model.js +212 -0
  89. package/src/report/template.js +175 -0
  90. package/src/report/timeline.js +145 -0
  91. package/src/reporter.js +109 -5
  92. package/src/scan-url.js +31 -7
  93. package/src/utils/http.js +252 -11
  94. package/src/utils/url.js +23 -0
  95. package/scripts/package-skill.sh +0 -16
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Read-only views over memory events, plus the two pure helpers scans use:
3
+ * rankFindings() re-orders, isSuppressed() hides. Neither can add a finding,
4
+ * change a severity, or run anything: memory content is only ever compared and
5
+ * counted. Every field read from an event is type-checked, so a malformed or
6
+ * hostile event degrades to "ignored", never to behaviour.
7
+ */
8
+ import { fingerprintFinding } from "../audit-loop.js";
9
+ import { sha256 } from "./store.js";
10
+
11
+ const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
12
+ const MAX_STR = 500;
13
+
14
+ /** Short, stable id for a finding: sha256 of the audit-loop fingerprint. */
15
+ export function findingId(f) {
16
+ return sha256(fingerprintFinding(f)).slice(0, 16);
17
+ }
18
+
19
+ const str = (x) => (typeof x === "string" && x.length > 0 && x.length <= MAX_STR ? x : null);
20
+ const int = (x) => (Number.isInteger(x) && x >= 0 ? x : null);
21
+ const time = (x) => { const t = Date.parse(x); return Number.isNaN(t) ? null : t; };
22
+ const key = (x) => (str(x) ? x.toLowerCase() : "unknown");
23
+
24
+ function median(nums) {
25
+ if (!nums.length) return null;
26
+ const s = [...nums].sort((a, b) => a - b);
27
+ const mid = Math.floor(s.length / 2);
28
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
29
+ }
30
+
31
+ /**
32
+ * Build the views from a verified event list.
33
+ * @param {object[]} events parsed events from openMemory().events()
34
+ * @param {{ now?: string }} opts `now` decides which suppressions have expired
35
+ */
36
+ export function buildViews(events, { now = new Date().toISOString() } = {}) {
37
+ const nowMs = time(now) ?? Date.now();
38
+ const recipes = new Map(); // `${framework}\0${recipe}` -> stats
39
+ const lastApplied = new Map(); // finding id -> { recipe, framework, category, ts }
40
+ const firstSeen = new Map(); // finding id -> ms
41
+ const seenCount = new Map(); // finding id -> scans it appeared in
42
+ const fixDurations = [];
43
+ const suppressions = new Map(); // finding id -> latest suppression
44
+ const probes = new Map(); // `${stack}\0${probe}` -> stats
45
+ let scans = 0;
46
+
47
+ const recipeStats = (framework, recipe) => {
48
+ const k = `${framework}\0${recipe}`;
49
+ if (!recipes.has(k)) recipes.set(k, { framework, recipe, applied: 0, verified: 0, failed: 0, categories: new Set() });
50
+ return recipes.get(k);
51
+ };
52
+
53
+ for (const ev of events || []) {
54
+ const d = ev?.data;
55
+ if (!d || typeof d !== "object") continue;
56
+ const ts = time(ev.ts);
57
+ switch (ev.type) {
58
+ case "scan": {
59
+ scans++;
60
+ const ids = Array.isArray(d.ids) ? d.ids.filter(str) : [];
61
+ for (const id of ids) {
62
+ if (!firstSeen.has(id) && ts != null) firstSeen.set(id, ts);
63
+ seenCount.set(id, (seenCount.get(id) || 0) + 1);
64
+ }
65
+ break;
66
+ }
67
+ case "fix_applied": {
68
+ const id = str(d.id) || str(d.fingerprint);
69
+ const recipe = str(d.recipe);
70
+ if (!recipe) break;
71
+ const framework = key(d.framework);
72
+ const s = recipeStats(framework, recipe);
73
+ s.applied++;
74
+ if (str(d.category)) s.categories.add(d.category);
75
+ if (id) lastApplied.set(id, { recipe, framework, category: str(d.category), ts });
76
+ break;
77
+ }
78
+ case "fix_verified":
79
+ case "fix_failed": {
80
+ const id = str(d.id) || str(d.fingerprint);
81
+ const prior = id ? lastApplied.get(id) : null;
82
+ const recipe = str(d.recipe) || prior?.recipe;
83
+ if (!recipe) break;
84
+ const framework = str(d.framework) ? key(d.framework) : (prior?.framework || "unknown");
85
+ const s = recipeStats(framework, recipe);
86
+ if (ev.type === "fix_verified") {
87
+ s.verified++;
88
+ if (id && ts != null && firstSeen.has(id)) fixDurations.push(Math.max(0, ts - firstSeen.get(id)));
89
+ } else {
90
+ s.failed++;
91
+ }
92
+ break;
93
+ }
94
+ case "fp_suppressed": {
95
+ const id = str(d.id) || (str(d.fingerprint) ? sha256(d.fingerprint).slice(0, 16) : null);
96
+ const reason = str(d.reason);
97
+ if (!id || !reason) break; // a suppression without a reason is ignored
98
+ const expires = str(d.expires);
99
+ if (expires && (time(expires) == null || time(expires) <= nowMs)) { suppressions.delete(id); break; }
100
+ suppressions.set(id, {
101
+ id, reason, actor: str(d.actor) || "unknown", since: ev.ts, expires: expires || null, source: "memory",
102
+ });
103
+ break;
104
+ }
105
+ case "probe_yield": {
106
+ const probe = str(d.probe);
107
+ const findings = int(d.findings);
108
+ if (!probe || findings == null) break;
109
+ const stack = key(d.stack);
110
+ const k = `${stack}\0${probe}`;
111
+ if (!probes.has(k)) probes.set(k, { stack, probe, runs: 0, findings: 0 });
112
+ const p = probes.get(k);
113
+ p.runs++;
114
+ p.findings += findings;
115
+ break;
116
+ }
117
+ default:
118
+ break;
119
+ }
120
+ }
121
+
122
+ const recipeSuccess = [...recipes.values()].map((s) => {
123
+ const decided = s.verified + s.failed;
124
+ return {
125
+ framework: s.framework, recipe: s.recipe, applied: s.applied, verified: s.verified, failed: s.failed,
126
+ rate: decided ? s.verified / decided : null, categories: [...s.categories].sort(),
127
+ };
128
+ }).sort((a, b) => a.framework.localeCompare(b.framework) || (b.rate ?? -1) - (a.rate ?? -1) || a.recipe.localeCompare(b.recipe));
129
+
130
+ const probeRanking = {};
131
+ for (const p of probes.values()) {
132
+ (probeRanking[p.stack] ||= []).push({ probe: p.probe, runs: p.runs, findings: p.findings, yield: p.findings / p.runs });
133
+ }
134
+ for (const list of Object.values(probeRanking)) {
135
+ list.sort((a, b) => b.yield - a.yield || b.findings - a.findings || a.probe.localeCompare(b.probe));
136
+ }
137
+
138
+ const med = median(fixDurations);
139
+ return {
140
+ scans,
141
+ recipeSuccess,
142
+ suppressions: [...suppressions.values()],
143
+ probeRanking,
144
+ medianTimeToFixMs: med,
145
+ medianTimeToFixDays: med == null ? null : Math.round((med / 86_400_000) * 10) / 10,
146
+ seen: Object.fromEntries(seenCount),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Add repo-file suppressions to a views object. They keep their own source so
152
+ * reports can say "suppressed by repo file". A local-memory suppression for the
153
+ * same id wins (the user decided it, not the repo).
154
+ */
155
+ export function withRepoSuppressions(views, repoEntries) {
156
+ const have = new Set((views?.suppressions || []).map((s) => s.id));
157
+ const extra = (repoEntries || []).filter((s) => !have.has(s.id)).map((s) => ({ ...s, source: "repo-file" }));
158
+ return { ...(views || {}), suppressions: [...(views?.suppressions || []), ...extra] };
159
+ }
160
+
161
+ /**
162
+ * Is this finding suppressed? Returns the suppression record or null.
163
+ * Matches on the finding's id (or its full fingerprint, for repo files).
164
+ */
165
+ export function isSuppressed(finding, views) {
166
+ const list = views?.suppressions;
167
+ if (!list?.length) return null;
168
+ const fp = fingerprintFinding(finding);
169
+ const id = sha256(fp).slice(0, 16);
170
+ return list.find((s) => s.id === id || (s.fingerprint && s.fingerprint === fp)) || null;
171
+ }
172
+
173
+ /**
174
+ * Re-order findings using what memory learned. Severity always comes first and
175
+ * is never changed. Within a severity:
176
+ * 1. findings whose category has a recipe with a proven fix rate (quick wins),
177
+ * 2. findings seen in more past scans (persistent problems),
178
+ * 3. then the original order (the sort is stable).
179
+ * Returns a new array with exactly the same findings.
180
+ */
181
+ export function rankFindings(findings, views, { framework = null } = {}) {
182
+ const fw = framework ? String(framework).toLowerCase() : null;
183
+ const bestRate = new Map(); // category -> best verified rate
184
+ for (const r of views?.recipeSuccess || []) {
185
+ if (r.rate == null || (fw && r.framework !== fw && r.framework !== "unknown")) continue;
186
+ for (const c of r.categories || []) bestRate.set(c, Math.max(bestRate.get(c) ?? 0, r.rate));
187
+ }
188
+ const seen = views?.seen || {};
189
+ return (findings || [])
190
+ .map((f, i) => ({
191
+ f, i,
192
+ sev: SEV_ORDER[f.severity] ?? 5,
193
+ fix: bestRate.get(f.category) ?? 0,
194
+ seen: seen[findingId(f)] || 0,
195
+ }))
196
+ .sort((a, b) => a.sev - b.sev || b.fix - a.fix || b.seen - a.seen || a.i - b.i)
197
+ .map((x) => x.f);
198
+ }
package/lib/params.js CHANGED
@@ -14,20 +14,12 @@
14
14
  * Inspired by: Arjun (6.2k stars), ParamSpider
15
15
  */
16
16
 
17
+ import { fetchText } from "../src/utils/http.js";
18
+
17
19
  const PARAM_TIMEOUT = 8_000;
18
20
 
19
- async function safeFetch(url, opts = {}) {
20
- const controller = new AbortController();
21
- const timeout = setTimeout(() => controller.abort(), opts.timeout || PARAM_TIMEOUT);
22
- try {
23
- const res = await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
24
- const body = await res.text().catch(() => "");
25
- return { status: res.status, body, size: body.length, ok: true };
26
- } catch {
27
- return { status: 0, body: "", size: 0, ok: false };
28
- } finally {
29
- clearTimeout(timeout);
30
- }
21
+ function safeFetch(url, opts = {}) {
22
+ return fetchText(url, { timeout: PARAM_TIMEOUT, probe: "param-discovery", ...opts });
31
23
  }
32
24
 
33
25
  function simpleHash(str) {
package/lib/provenance.js CHANGED
@@ -26,6 +26,7 @@ const SOURCE_CONFIDENCE = {
26
26
  "openapi-scan": "confirmed",
27
27
  "email-dns-probe": "confirmed",
28
28
  "framework-check": "likely",
29
+ "deps-scan": "likely",
29
30
  "static-analysis": "likely",
30
31
  "trust-boundary": "likely",
31
32
  "auth-probe": "likely",
@@ -59,6 +60,7 @@ const CATEGORY_CONFIDENCE = {
59
60
  ssrf: "likely",
60
61
  "open-redirect": "likely",
61
62
  fuzzing: "potential",
63
+ "dependency-risk": "likely",
62
64
  };
63
65
 
64
66
  /** Honest-confidence label for a finding. An explicit finding.confidence wins. */
package/lib/recon.js CHANGED
@@ -12,19 +12,16 @@
12
12
  * Inspired by: subfinder, gau, waybackurls, ParamSpider
13
13
  */
14
14
 
15
+ import { safeFetch } from "../src/utils/http.js";
16
+ import { registrableDomain } from "../src/utils/url.js";
17
+
15
18
  const RECON_TIMEOUT = 15_000;
16
19
 
17
- async function safeFetch(url, opts = {}) {
18
- const controller = new AbortController();
19
- const timeout = setTimeout(() => controller.abort(), opts.timeout || RECON_TIMEOUT);
20
- try {
21
- return await fetch(url, { signal: controller.signal, ...opts });
22
- } catch {
23
- return null;
24
- } finally {
25
- clearTimeout(timeout);
26
- }
27
- }
20
+ /**
21
+ * Third-party hosts this module queries. Under --offline the scan skips recon
22
+ * entirely, and the HTTP client would refuse these hosts anyway.
23
+ */
24
+ export const RECON_HOSTS = ["crt.sh", "web.archive.org", "otx.alienvault.com"];
28
25
 
29
26
  // ── Subdomain Discovery via Certificate Transparency ─────────────────────
30
27
 
@@ -38,7 +35,7 @@ async function safeFetch(url, opts = {}) {
38
35
  export async function discoverSubdomains(domain) {
39
36
  const res = await safeFetch(
40
37
  `https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`,
41
- { timeout: 20_000 },
38
+ { timeout: 20_000, probe: "recon", redirect: "follow" },
42
39
  );
43
40
  if (!res || !res.ok) return [];
44
41
 
@@ -77,7 +74,7 @@ export async function fetchWaybackUrls(domain, opts = {}) {
77
74
  const limit = opts.limit || 1000;
78
75
  const res = await safeFetch(
79
76
  `https://web.archive.org/cdx/search/cdx?url=*.${encodeURIComponent(domain)}/*&output=json&fl=original&collapse=urlkey&limit=${limit}`,
80
- { timeout: 20_000 },
77
+ { timeout: 20_000, probe: "recon", redirect: "follow" },
81
78
  );
82
79
  if (!res || !res.ok) return [];
83
80
 
@@ -103,6 +100,7 @@ export async function fetchOtxUrls(domain, opts = {}) {
103
100
  const limit = opts.limit || 200;
104
101
  const res = await safeFetch(
105
102
  `https://otx.alienvault.com/api/v1/indicators/domain/${encodeURIComponent(domain)}/url_list?limit=${limit}`,
103
+ { timeout: RECON_TIMEOUT, probe: "recon", redirect: "follow" },
106
104
  );
107
105
  if (!res || !res.ok) return [];
108
106
 
@@ -180,13 +178,7 @@ export async function runRecon(domain, opts = {}) {
180
178
  */
181
179
  export function extractDomain(urlString) {
182
180
  try {
183
- const hostname = new URL(urlString).hostname;
184
- const parts = hostname.split(".");
185
- // Handle two-part TLDs like .co.uk, .com.au
186
- if (parts.length >= 3 && parts[parts.length - 2].length <= 3) {
187
- return parts.slice(-3).join(".");
188
- }
189
- return parts.slice(-2).join(".");
181
+ return registrableDomain(new URL(urlString).hostname);
190
182
  } catch {
191
183
  return urlString;
192
184
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Research and standards references for findings.
3
+ *
4
+ * lib/references.json maps each finding category to 1-3 keys of
5
+ * docs/references.bib, and carries a short record for each key so reports can
6
+ * cite without parsing BibTeX. Every entry was checked against the publisher or
7
+ * standards body before it was added; see docs/references.md.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+
11
+ const DATA = JSON.parse(readFileSync(new URL("./references.json", import.meta.url), "utf-8"));
12
+
13
+ export const SOURCES = DATA.sources;
14
+ export const CATEGORY_REFERENCES = DATA.categories;
15
+
16
+ /** Reference keys for a finding, by category. Unknown categories get []. */
17
+ export function referenceKeysFor(finding) {
18
+ return CATEGORY_REFERENCES[finding?.category] || [];
19
+ }
20
+
21
+ /** Resolved source records ({ key, cite, title, url }) for a finding. */
22
+ export function referencesFor(finding) {
23
+ return referenceKeysFor(finding)
24
+ .map((key) => (SOURCES[key] ? { key, ...SOURCES[key] } : null))
25
+ .filter(Boolean);
26
+ }
27
+
28
+ /** One-line Markdown list of sources, e.g. "[Weichselbaum et al. 2016](https://doi.org/...)". */
29
+ export function formatSourcesMarkdown(refs) {
30
+ return refs.map((r) => `[${r.cite}](${r.url})`).join(" · ");
31
+ }