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,374 @@
1
+ {
2
+ "_comment": "categories: finding category -> 1-3 keys of docs/references.bib (hand-maintained). sources: generated from the .bib by scripts/build-references.mjs; do not edit by hand.",
3
+ "categories": {
4
+ "headers": [
5
+ "weichselbaum2016csp",
6
+ "hodges2012rfc6797",
7
+ "owaspwstg42"
8
+ ],
9
+ "transport": [
10
+ "hodges2012rfc6797",
11
+ "owaspwstg42"
12
+ ],
13
+ "tls": [
14
+ "hodges2012rfc6797",
15
+ "owaspwstg42"
16
+ ],
17
+ "cookie": [
18
+ "barth2008robust",
19
+ "owaspwstg42"
20
+ ],
21
+ "cors": [
22
+ "chen2018cors",
23
+ "owaspwstg42"
24
+ ],
25
+ "cors-misconfiguration": [
26
+ "chen2018cors",
27
+ "owaspwstg42"
28
+ ],
29
+ "secrets": [
30
+ "meli2019bad",
31
+ "basak2023secretbench"
32
+ ],
33
+ "sqli": [
34
+ "owaspwstg42",
35
+ "owasp2025top10",
36
+ "crosby2009opportunities"
37
+ ],
38
+ "cmdi": [
39
+ "owaspwstg42",
40
+ "owasp2025top10",
41
+ "crosby2009opportunities"
42
+ ],
43
+ "ssti": [
44
+ "owaspwstg42",
45
+ "owasp2025top10"
46
+ ],
47
+ "ssrf": [
48
+ "owaspwstg42",
49
+ "owasp2021top10"
50
+ ],
51
+ "xss": [
52
+ "lekies2013flows",
53
+ "weichselbaum2016csp",
54
+ "owaspwstg42"
55
+ ],
56
+ "injection": [
57
+ "owaspwstg42",
58
+ "owasp2025top10"
59
+ ],
60
+ "open-redirect": [
61
+ "lodderstedt2025rfc9700",
62
+ "owaspwstg42"
63
+ ],
64
+ "auth": [
65
+ "owasp2023api",
66
+ "fett2016comprehensive",
67
+ "sheffer2020rfc8725"
68
+ ],
69
+ "auth-bypass": [
70
+ "owasp2023api",
71
+ "owaspwstg42"
72
+ ],
73
+ "rate-limiting": [
74
+ "owasp2023api",
75
+ "owaspwstg42"
76
+ ],
77
+ "timing": [
78
+ "crosby2009opportunities",
79
+ "kocher1996timing"
80
+ ],
81
+ "cve": [
82
+ "zimmermann2019small",
83
+ "ladisa2023sok"
84
+ ],
85
+ "js-vulnerability": [
86
+ "lauinger2017thou",
87
+ "zimmermann2019small"
88
+ ],
89
+ "email-auth": [
90
+ "kitterman2014rfc7208",
91
+ "kucherawy2015rfc7489"
92
+ ],
93
+ "exposure": [
94
+ "owaspwstg42",
95
+ "owasp2025top10"
96
+ ],
97
+ "disclosure": [
98
+ "owaspwstg42",
99
+ "owasp2025top10"
100
+ ],
101
+ "info-leak": [
102
+ "owaspwstg42",
103
+ "owasp2025top10"
104
+ ],
105
+ "config": [
106
+ "owaspwstg42",
107
+ "owasp2025top10"
108
+ ],
109
+ "method-confusion": [
110
+ "owaspwstg42"
111
+ ],
112
+ "content-type-confusion": [
113
+ "owaspwstg42"
114
+ ],
115
+ "code-pattern": [
116
+ "mitrecwe",
117
+ "owaspwstg42"
118
+ ],
119
+ "fuzzing": [
120
+ "owaspwstg42"
121
+ ],
122
+ "parameter-discovery": [
123
+ "owaspwstg42"
124
+ ],
125
+ "recon": [
126
+ "owaspwstg42"
127
+ ],
128
+ "attack-chain": [
129
+ "shostack2014threat"
130
+ ]
131
+ },
132
+ "sources": {
133
+ "bau2010state": {
134
+ "cite": "Bau et al. 2010",
135
+ "title": "State of the Art: Automated Black-Box Web Application Vulnerability Testing",
136
+ "url": "https://doi.org/10.1109/SP.2010.27"
137
+ },
138
+ "doupe2010johnny": {
139
+ "cite": "Doupé et al. 2010",
140
+ "title": "Why Johnny Can't Pentest: An Analysis of Black-Box Web Vulnerability Scanners",
141
+ "url": "https://doi.org/10.1007/978-3-642-14215-4_7"
142
+ },
143
+ "antunes2010benchmarking": {
144
+ "cite": "Antunes & Vieira 2010",
145
+ "title": "Benchmarking Vulnerability Detection Tools for Web Services",
146
+ "url": "https://doi.org/10.1109/ICWS.2010.76"
147
+ },
148
+ "antunes2015assessing": {
149
+ "cite": "Antunes & Vieira 2015",
150
+ "title": "Assessing and Comparing Vulnerability Detection Tools for Web Services: Benchmarking Approach and Examples",
151
+ "url": "https://doi.org/10.1109/TSC.2014.2310221"
152
+ },
153
+ "owaspbenchmark": {
154
+ "cite": "OWASP Benchmark",
155
+ "title": "OWASP Benchmark Project",
156
+ "url": "https://owasp.org/www-project-benchmark/"
157
+ },
158
+ "scarfone2008sp800115": {
159
+ "cite": "NIST SP 800-115",
160
+ "title": "Technical Guide to Information Security Testing and Assessment",
161
+ "url": "https://doi.org/10.6028/NIST.SP.800-115"
162
+ },
163
+ "saltzer1975protection": {
164
+ "cite": "Saltzer & Schroeder 1975",
165
+ "title": "The Protection of Information in Computer Systems",
166
+ "url": "https://doi.org/10.1109/PROC.1975.9939"
167
+ },
168
+ "shostack2014threat": {
169
+ "cite": "Shostack 2014",
170
+ "title": "Threat Modeling: Designing for Security",
171
+ "url": "https://www.wiley-vch.de/de/fachgebiete/computer-und-informatik/threat-modeling-978-1-118-80999-0"
172
+ },
173
+ "kohnfelder1999threats": {
174
+ "cite": "Kohnfelder & Garg 1999",
175
+ "title": "The Threats to Our Products",
176
+ "url": "https://shostack.org/files/microsoft/The-Threats-To-Our-Products.docx"
177
+ },
178
+ "ladisa2023sok": {
179
+ "cite": "Ladisa et al. 2023",
180
+ "title": "SoK: Taxonomy of Attacks on Open-Source Software Supply Chains",
181
+ "url": "https://doi.org/10.1109/SP46215.2023.10179304"
182
+ },
183
+ "ohm2020backstabber": {
184
+ "cite": "Ohm et al. 2020",
185
+ "title": "Backstabber's Knife Collection: A Review of Open Source Software Supply Chain Attacks",
186
+ "url": "https://doi.org/10.1007/978-3-030-52683-2_2"
187
+ },
188
+ "zimmermann2019small": {
189
+ "cite": "Zimmermann et al. 2019",
190
+ "title": "Small World with High Risks: A Study of Security Threats in the npm Ecosystem",
191
+ "url": "https://www.usenix.org/conference/usenixsecurity19/presentation/zimmerman"
192
+ },
193
+ "lauinger2017thou": {
194
+ "cite": "Lauinger et al. 2017",
195
+ "title": "Thou Shalt Not Depend on Me: Analysing the Use of Outdated JavaScript Libraries on the Web",
196
+ "url": "https://doi.org/10.14722/ndss.2017.23414"
197
+ },
198
+ "thompson1984reflections": {
199
+ "cite": "Thompson 1984",
200
+ "title": "Reflections on Trusting Trust",
201
+ "url": "https://doi.org/10.1145/358198.358210"
202
+ },
203
+ "torresarias2019intoto": {
204
+ "cite": "Torres-Arias et al. 2019",
205
+ "title": "in-toto: Providing farm-to-table guarantees for bits and bytes",
206
+ "url": "https://www.usenix.org/conference/usenixsecurity19/presentation/torres-arias"
207
+ },
208
+ "samuel2010survivable": {
209
+ "cite": "Samuel et al. 2010",
210
+ "title": "Survivable Key Compromise in Software Update Systems",
211
+ "url": "https://doi.org/10.1145/1866307.1866315"
212
+ },
213
+ "meli2019bad": {
214
+ "cite": "Meli et al. 2019",
215
+ "title": "How Bad Can It Git? Characterizing Secret Leakage in Public GitHub Repositories",
216
+ "url": "https://doi.org/10.14722/ndss.2019.23418"
217
+ },
218
+ "basak2023secretbench": {
219
+ "cite": "Basak et al. 2023",
220
+ "title": "SecretBench: A Dataset of Software Secrets",
221
+ "url": "https://doi.org/10.1109/MSR59073.2023.00053"
222
+ },
223
+ "weichselbaum2016csp": {
224
+ "cite": "Weichselbaum et al. 2016",
225
+ "title": "CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy",
226
+ "url": "https://doi.org/10.1145/2976749.2978363"
227
+ },
228
+ "kocher1996timing": {
229
+ "cite": "Kocher 1996",
230
+ "title": "Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems",
231
+ "url": "https://doi.org/10.1007/3-540-68697-5_9"
232
+ },
233
+ "crosby2009opportunities": {
234
+ "cite": "Crosby et al. 2009",
235
+ "title": "Opportunities and Limits of Remote Timing Attacks",
236
+ "url": "https://doi.org/10.1145/1455526.1455530"
237
+ },
238
+ "lekies2013flows": {
239
+ "cite": "Lekies et al. 2013",
240
+ "title": "25 Million Flows Later: Large-scale Detection of DOM-based XSS",
241
+ "url": "https://doi.org/10.1145/2508859.2516703"
242
+ },
243
+ "stock2014precise": {
244
+ "cite": "Stock et al. 2014",
245
+ "title": "Precise Client-side Protection against DOM-based Cross-Site Scripting",
246
+ "url": "https://www.usenix.org/conference/usenixsecurity14/technical-sessions/presentation/stock"
247
+ },
248
+ "barth2008robust": {
249
+ "cite": "Barth et al. 2008",
250
+ "title": "Robust Defenses for Cross-Site Request Forgery",
251
+ "url": "https://doi.org/10.1145/1455770.1455782"
252
+ },
253
+ "fett2016comprehensive": {
254
+ "cite": "Fett et al. 2016",
255
+ "title": "A Comprehensive Formal Security Analysis of OAuth 2.0",
256
+ "url": "https://doi.org/10.1145/2976749.2978385"
257
+ },
258
+ "hodges2012rfc6797": {
259
+ "cite": "RFC 6797 (2012)",
260
+ "title": "HTTP Strict Transport Security (HSTS)",
261
+ "url": "https://doi.org/10.17487/RFC6797"
262
+ },
263
+ "jones2015rfc7519": {
264
+ "cite": "RFC 7519 (2015)",
265
+ "title": "JSON Web Token (JWT)",
266
+ "url": "https://doi.org/10.17487/RFC7519"
267
+ },
268
+ "sakimura2015rfc7636": {
269
+ "cite": "RFC 7636 (2015)",
270
+ "title": "Proof Key for Code Exchange by OAuth Public Clients",
271
+ "url": "https://doi.org/10.17487/RFC7636"
272
+ },
273
+ "lodderstedt2025rfc9700": {
274
+ "cite": "RFC 9700 (2025)",
275
+ "title": "Best Current Practice for OAuth 2.0 Security",
276
+ "url": "https://doi.org/10.17487/RFC9700"
277
+ },
278
+ "chen2018cors": {
279
+ "cite": "Chen et al. 2018",
280
+ "title": "We Still Don't Have Secure Cross-Domain Requests: an Empirical Study of CORS",
281
+ "url": "https://www.usenix.org/conference/usenixsecurity18/presentation/chen-jianjun"
282
+ },
283
+ "sheffer2020rfc8725": {
284
+ "cite": "RFC 8725 (2020)",
285
+ "title": "JSON Web Token Best Current Practices",
286
+ "url": "https://doi.org/10.17487/RFC8725"
287
+ },
288
+ "owasp2023api": {
289
+ "cite": "OWASP API Top 10:2023",
290
+ "title": "OWASP Top 10 API Security Risks - 2023",
291
+ "url": "https://owasp.org/API-Security/editions/2023/en/0x11-t10/"
292
+ },
293
+ "kitterman2014rfc7208": {
294
+ "cite": "RFC 7208 (2014)",
295
+ "title": "Sender Policy Framework (SPF) for Authorizing Use of Domains in Email, Version 1",
296
+ "url": "https://doi.org/10.17487/RFC7208"
297
+ },
298
+ "kucherawy2015rfc7489": {
299
+ "cite": "RFC 7489 (2015)",
300
+ "title": "Domain-based Message Authentication, Reporting, and Conformance (DMARC)",
301
+ "url": "https://doi.org/10.17487/RFC7489"
302
+ },
303
+ "owasp2021top10": {
304
+ "cite": "OWASP Top 10:2021",
305
+ "title": "OWASP Top 10:2021",
306
+ "url": "https://top10.owasp.org/2021"
307
+ },
308
+ "owasp2025top10": {
309
+ "cite": "OWASP Top 10:2025",
310
+ "title": "OWASP Top 10:2025",
311
+ "url": "https://top10.owasp.org/2025"
312
+ },
313
+ "owaspwstg42": {
314
+ "cite": "OWASP WSTG v4.2",
315
+ "title": "OWASP Web Security Testing Guide, Version 4.2",
316
+ "url": "https://wstg.owasp.org/v4.2/"
317
+ },
318
+ "greshake2023signed": {
319
+ "cite": "Greshake et al. 2023",
320
+ "title": "Not What You've Signed Up For: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection",
321
+ "url": "https://doi.org/10.1145/3605764.3623985"
322
+ },
323
+ "spracklen2025package": {
324
+ "cite": "Spracklen et al. 2025",
325
+ "title": "We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs",
326
+ "url": "https://www.usenix.org/conference/usenixsecurity25/presentation/spracklen"
327
+ },
328
+ "perez2022ignore": {
329
+ "cite": "Perez & Ribeiro 2022",
330
+ "title": "Ignore Previous Prompt: Attack Techniques For Language Models",
331
+ "url": "https://doi.org/10.48550/arXiv.2211.09527"
332
+ },
333
+ "liu2023prompt": {
334
+ "cite": "Liu et al. 2023",
335
+ "title": "Prompt Injection attack against LLM-integrated Applications",
336
+ "url": "https://doi.org/10.48550/arXiv.2306.05499"
337
+ },
338
+ "owasp2024llmtop10": {
339
+ "cite": "OWASP LLM Top 10 (2025)",
340
+ "title": "OWASP Top 10 for LLM Applications 2025",
341
+ "url": "https://genai.owasp.org/resource/owasp-top-10-for-llm-applications-2025/"
342
+ },
343
+ "owasp2025agentic": {
344
+ "cite": "OWASP Agentic Top 10 (2026)",
345
+ "title": "OWASP Top 10 for Agentic Applications for 2026",
346
+ "url": "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/"
347
+ },
348
+ "owasp2025mcptop10": {
349
+ "cite": "OWASP MCP Top 10",
350
+ "title": "OWASP MCP Top 10",
351
+ "url": "https://owasp.org/www-project-mcp-top-10/"
352
+ },
353
+ "mitreatlas": {
354
+ "cite": "MITRE ATLAS",
355
+ "title": "MITRE ATLAS: Adversarial Threat Landscape for AI Systems",
356
+ "url": "https://atlas.mitre.org/"
357
+ },
358
+ "oasis2023sarif": {
359
+ "cite": "SARIF 2.1.0",
360
+ "title": "Static Analysis Results Interchange Format (SARIF) Version 2.1.0 Plus Errata 01",
361
+ "url": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/sarif-v2.1.0-errata01-os-complete.html"
362
+ },
363
+ "eu2022nis2": {
364
+ "cite": "NIS2 Directive",
365
+ "title": "Directive (EU) 2022/2555 of the European Parliament and of the Council of 14 December 2022 on measures for a high common level of cybersecurity across the Union, amending Regulation (EU) No 910/2014 and Directive (EU) 2018/1972, and repealing Directive (EU) 2016/1148 (NIS 2 Directive)",
366
+ "url": "https://eur-lex.europa.eu/eli/dir/2022/2555/oj"
367
+ },
368
+ "mitrecwe": {
369
+ "cite": "MITRE CWE",
370
+ "title": "CWE: Common Weakness Enumeration",
371
+ "url": "https://cwe.mitre.org/"
372
+ }
373
+ }
374
+ }
@@ -7,6 +7,7 @@
7
7
  * do for you. This is the structured data the report renders and that the Agent
8
8
  * Skill uses to draft questionnaire answers and remediation documents.
9
9
  */
10
+ import { referenceKeysFor, SOURCES } from "../references.js";
10
11
 
11
12
  // Each rule matches a finding and yields remediation guidance. First match wins,
12
13
  // so order specific rules (by title) before broad ones (by category).
@@ -110,6 +111,15 @@ const RULES = [
110
111
  effort: "quick",
111
112
  ownerAction: false,
112
113
  },
114
+ {
115
+ id: "dependency-risk",
116
+ match: (f) => f.category === "dependency-risk",
117
+ action: "Review what the flagged dependency can do",
118
+ why: "Every package in node_modules runs with your app's access to files, network, environment and, through install scripts, your machine and CI. Supply-chain attacks arrive as a new dependency or an upgrade that quietly gains network, shell or install-time code.",
119
+ fix: "Open the file:line evidence and decide whether the capability fits what the package is for. If it does not: pin the previous version in package.json, remove the package, or replace it. Install with `npm ci --ignore-scripts` where you can, and run `penthera deps --diff` on every lockfile change.",
120
+ effort: "moderate",
121
+ ownerAction: false,
122
+ },
113
123
  {
114
124
  id: "cors",
115
125
  match: (f) => f.category === "cors",
@@ -128,6 +138,15 @@ const RULES = [
128
138
  effort: "quick",
129
139
  ownerAction: false,
130
140
  },
141
+ {
142
+ id: "agent-config",
143
+ match: (f) => f.category === "agent-config",
144
+ action: "Harden the AI agent configuration",
145
+ why: "Skills, MCP servers and hooks run with your agent's access to code, shell and credentials. Hidden instructions, unpinned packages and auto-approving hooks let someone else steer that access.",
146
+ fix: "Remove hidden or encoded content from skills and rule files, pin MCP packages to exact versions, move inline secrets to environment variables (and rotate them), narrow wildcard permissions, pin plugin marketplaces to a commit, then run `penthera agents --pin` and review `penthera.lock` diffs from then on.",
147
+ effort: "moderate",
148
+ ownerAction: false,
149
+ },
131
150
  {
132
151
  id: "exposure",
133
152
  match: (f) => f.category === "exposure" || f.category === "disclosure" || f.category === "config",
@@ -173,10 +192,14 @@ export function buildActionPlan(findings) {
173
192
  effort: rule.effort,
174
193
  ownerAction: rule.ownerAction,
175
194
  severity: f.severity,
195
+ references: [],
176
196
  findings: [],
177
197
  });
178
198
  }
179
199
  const g = groups.get(rule.id);
200
+ for (const key of referenceKeysFor(f)) {
201
+ if (!g.references.includes(key)) g.references.push(key);
202
+ }
180
203
  if ((SEV_ORDER[f.severity] ?? 5) < (SEV_ORDER[g.severity] ?? 5)) g.severity = f.severity;
181
204
  g.findings.push({ title: f.title, url: f.url || null, severity: f.severity });
182
205
  }
@@ -210,6 +233,11 @@ export function formatActionPlanMarkdown(plan) {
210
233
  L.push("");
211
234
  L.push(`**Fix:** ${a.fix}`);
212
235
  L.push("");
236
+ const refs = (a.references || []).filter((k) => SOURCES[k]).slice(0, 3);
237
+ if (refs.length) {
238
+ L.push(`**Sources:** ${refs.map((k) => `[${SOURCES[k].cite}](${SOURCES[k].url})`).join(" · ")}`);
239
+ L.push("");
240
+ }
213
241
  if (a.ownerAction) {
214
242
  L.push("> This is an owner action (e.g. rotate a secret, renew a certificate, change DNS). Penthera flags it but will not do it for you.");
215
243
  L.push("");
package/lib/retirejs.js CHANGED
@@ -10,9 +10,15 @@
10
10
  * 3. Hash matching for minified files (sha1)
11
11
  * 4. URI path matching (/3.5.1/jquery.min.js)
12
12
  *
13
- * Database source:
14
- * https://raw.githubusercontent.com/nickvergessen/retire.js/master/repository/jsrepository.json
13
+ * Database source, in order:
14
+ * 1. live upstream (two mirrors), unless the scan runs with --offline;
15
+ * 2. the snapshot vendored in lib/data/, refreshed at release time by
16
+ * scripts/refresh-retire-db.mjs (source URL, date and SHA-256 recorded
17
+ * next to it). Used under --offline and whenever the live fetch fails.
15
18
  */
19
+ import { readFileSync } from "node:fs";
20
+ import { gunzipSync } from "node:zlib";
21
+ import { safeFetch, currentEgress } from "../src/utils/http.js";
16
22
 
17
23
  // Upstream RetireJS repository. This previously pointed at a personal fork that
18
24
  // now 404s, which made every scan silently report "no vulnerable libraries".
@@ -22,37 +28,43 @@ const DB_URLS = [
22
28
  "https://cdn.jsdelivr.net/gh/RetireJS/retire.js@master/repository/jsrepository.json",
23
29
  ];
24
30
  const DB_CACHE_MS = 3600_000; // 1 hour
31
+ const SNAPSHOT_URL = new URL("./data/retire-jsrepository.json.gz", import.meta.url);
32
+ const SNAPSHOT_META_URL = new URL("./data/retire-jsrepository.meta.json", import.meta.url);
25
33
 
26
- let cachedDb = null;
34
+ let cachedLive = null;
27
35
  let cachedAt = 0;
36
+ let cachedSnapshot = null;
28
37
 
29
- async function safeFetch(url, timeout = 15_000) {
30
- const controller = new AbortController();
31
- const timer = setTimeout(() => controller.abort(), timeout);
38
+ // ── Database loading ─────────────────────────────────────────────────────
39
+
40
+ /** The vendored database, or null if the files are missing or corrupt. */
41
+ export function loadSnapshot() {
42
+ if (cachedSnapshot) return cachedSnapshot;
32
43
  try {
33
- return await fetch(url, { signal: controller.signal });
44
+ const db = JSON.parse(gunzipSync(readFileSync(SNAPSHOT_URL)).toString("utf-8"));
45
+ const meta = JSON.parse(readFileSync(SNAPSHOT_META_URL, "utf-8"));
46
+ cachedSnapshot = { db, source: "snapshot", fetchedAt: meta.fetchedAt, sha256: meta.sha256 };
47
+ return cachedSnapshot;
34
48
  } catch {
35
49
  return null;
36
- } finally {
37
- clearTimeout(timer);
38
50
  }
39
51
  }
40
52
 
41
- // ── Database loading ─────────────────────────────────────────────────────
42
-
43
- async function loadDb() {
44
- if (cachedDb && Date.now() - cachedAt < DB_CACHE_MS) return cachedDb;
53
+ async function loadLive() {
54
+ if (cachedLive && Date.now() - cachedAt < DB_CACHE_MS) return cachedLive;
45
55
 
46
56
  // Try each mirror in turn so one host being down doesn't disable the check.
47
57
  for (const url of DB_URLS) {
48
- const res = await safeFetch(url);
58
+ // probe: false, a failed mirror is not an inconclusive probe: the
59
+ // vendored snapshot takes over.
60
+ const res = await safeFetch(url, { timeout: 15_000, probe: false, redirect: "follow" });
49
61
  if (!res || !res.ok) continue;
50
62
  try {
51
63
  const db = await res.json();
52
64
  if (db && typeof db === "object" && Object.keys(db).length > 0) {
53
- cachedDb = db;
65
+ cachedLive = { db, source: "live", url };
54
66
  cachedAt = Date.now();
55
- return cachedDb;
67
+ return cachedLive;
56
68
  }
57
69
  } catch {
58
70
  // Malformed response from this mirror; try the next.
@@ -61,6 +73,19 @@ async function loadDb() {
61
73
  return null;
62
74
  }
63
75
 
76
+ /**
77
+ * Load the database: live unless offline, the vendored snapshot otherwise or
78
+ * as fallback. Returns `{ db, source, ... }` or null.
79
+ */
80
+ export async function loadDb(opts = {}) {
81
+ const offline = opts.offline ?? currentEgress()?.offline ?? false;
82
+ if (!offline) {
83
+ const live = await loadLive();
84
+ if (live) return live;
85
+ }
86
+ return loadSnapshot();
87
+ }
88
+
64
89
  // ── Version comparison ───────────────────────────────────────────────────
65
90
 
66
91
  function parseVersion(v) {
@@ -157,16 +182,16 @@ export async function scanJsLibraries(baseUrl, opts = {}) {
157
182
  const result = { libraries: [], findings: [] };
158
183
 
159
184
  // Load Retire.js database
160
- progress("Fetching Retire.js vulnerability database...");
161
- const db = await loadDb();
162
- if (!db) {
185
+ progress("Loading Retire.js vulnerability database...");
186
+ const loaded = await loadDb({ offline: opts.offline });
187
+ if (!loaded) {
163
188
  // Never return a silent "no vulnerable libraries" when the check could not
164
189
  // run. Say so, and tell the caller the probe did not really execute.
165
190
  result.dbLoaded = false;
166
191
  result.findings.push({
167
192
  severity: "info",
168
193
  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.",
194
+ description: "Could not load the Retire.js vulnerability database (every mirror unreachable and the vendored snapshot missing). Known-CVE checks on client-side JavaScript were skipped for this scan; this is not a clean result.",
170
195
  url: baseUrl,
171
196
  category: "config",
172
197
  source: "retirejs",
@@ -175,9 +200,12 @@ export async function scanJsLibraries(baseUrl, opts = {}) {
175
200
  return result;
176
201
  }
177
202
  result.dbLoaded = true;
203
+ result.dbSource = loaded.source;
204
+ if (loaded.source === "snapshot") result.dbSnapshotDate = loaded.fetchedAt;
205
+ const db = loaded.db;
178
206
 
179
207
  // Fetch the target page HTML
180
- const res = await safeFetch(baseUrl, timeout);
208
+ const res = await safeFetch(baseUrl, { timeout, probe: "retirejs", redirect: "follow" });
181
209
  if (!res || !res.ok) return result;
182
210
  const html = await res.text().catch(() => "");
183
211
 
@@ -216,7 +244,7 @@ export async function scanJsLibraries(baseUrl, opts = {}) {
216
244
  const lower = scriptUrl.toLowerCase();
217
245
  if (!lower.includes(libName.toLowerCase().replace(/\./g, ""))) continue;
218
246
 
219
- const jsRes = await safeFetch(scriptUrl, 5_000);
247
+ const jsRes = await safeFetch(scriptUrl, { timeout: 5_000, probe: "retirejs", redirect: "follow" });
220
248
  if (!jsRes || !jsRes.ok) continue;
221
249
  const jsContent = await jsRes.text().catch(() => "");
222
250
  if (jsContent.length > 0) {
package/lib/templates.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Compatible with a subset of Nuclei's template format.
6
6
  */
7
7
  import { joinUrl } from "../src/utils/url.js";
8
+ import { request } from "../src/utils/http.js";
8
9
 
9
10
  /**
10
11
  * Execute a single template check against a target.
@@ -18,15 +19,14 @@ export async function executeTemplate(baseUrl, template) {
18
19
 
19
20
  for (const req of template.http || []) {
20
21
  const url = joinUrl(baseUrl, req.path.replace("{{BaseURL}}", ""));
21
- const controller = new AbortController();
22
- const timeout = setTimeout(() => controller.abort(), 10_000);
23
22
 
24
23
  try {
25
24
  const fetchOpts = {
26
25
  method: req.method || "GET",
27
26
  headers: req.headers || {},
28
- signal: controller.signal,
29
27
  redirect: "manual",
28
+ timeout: 10_000,
29
+ probe: "templates",
30
30
  };
31
31
  if (req.body && ["POST", "PUT", "PATCH"].includes(fetchOpts.method)) {
32
32
  fetchOpts.body = req.body;
@@ -35,7 +35,7 @@ export async function executeTemplate(baseUrl, template) {
35
35
  }
36
36
  }
37
37
 
38
- const res = await fetch(url, fetchOpts);
38
+ const res = await request(url, fetchOpts);
39
39
  const body = await res.text().catch(() => "");
40
40
  const headerString = [...res.headers.entries()]
41
41
  .map(([k, v]) => `${k}: ${v}`).join("\n");
@@ -70,12 +70,9 @@ export async function executeTemplate(baseUrl, template) {
70
70
  extracted,
71
71
  });
72
72
  }
73
- } catch (err) {
74
- if (err.name !== "AbortError") {
75
- // Network error, skip
76
- }
77
- } finally {
78
- clearTimeout(timeout);
73
+ } catch {
74
+ // Timeout, network error or egress refusal: recorded by the HTTP client
75
+ // in result.errors, so the miss shows up as inconclusive.
79
76
  }
80
77
  }
81
78
 
@@ -65,7 +65,7 @@ function extractRoutesFromSource(source, filePath, repoPath) {
65
65
  return routes;
66
66
  }
67
67
 
68
- function detectFramework(source) {
68
+ export function detectFramework(source) {
69
69
  if (/from\s+['"]hono['"]|new Hono/.test(source)) return "hono";
70
70
  if (/from\s+['"]fastify['"]|fastify\(/.test(source)) return "fastify";
71
71
  if (/from\s+['"]express['"]|require\s*\(\s*['"]express['"]\)/.test(source)) return "express";