penthera 1.1.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 (74) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +561 -0
  4. package/SECURITY.md +31 -0
  5. package/bin/penthera.js +3 -0
  6. package/bin/scan.js +11 -0
  7. package/docs/index.md +60 -0
  8. package/docs/owasp-wstg-coverage.md +79 -0
  9. package/lib/adaptive-probes.js +334 -0
  10. package/lib/attack-surface.js +149 -0
  11. package/lib/audit-loop.js +170 -0
  12. package/lib/blackbox/adaptive-scan.js +137 -0
  13. package/lib/blackbox/client-auth.js +60 -0
  14. package/lib/blackbox/email-dns.js +135 -0
  15. package/lib/blackbox/headers.js +91 -0
  16. package/lib/blackbox/idor.js +113 -0
  17. package/lib/blackbox/jwt.js +73 -0
  18. package/lib/blackbox/oauth.js +50 -0
  19. package/lib/blackbox/openapi.js +227 -0
  20. package/lib/compliance/incident.js +169 -0
  21. package/lib/compliance/index.js +163 -0
  22. package/lib/compliance/policy-pack.js +293 -0
  23. package/lib/compliance/questionnaire.js +113 -0
  24. package/lib/compliance/readiness.js +229 -0
  25. package/lib/compliance/self-assessment.js +66 -0
  26. package/lib/crawler.js +257 -0
  27. package/lib/fuzzer.js +233 -0
  28. package/lib/injections.js +676 -0
  29. package/lib/jurisdictions/nl.json +48 -0
  30. package/lib/knowledge-graph.js +232 -0
  31. package/lib/machine.js +814 -0
  32. package/lib/nuclei-loader.js +283 -0
  33. package/lib/owasp-wstg.js +204 -0
  34. package/lib/params.js +235 -0
  35. package/lib/plugins.js +70 -0
  36. package/lib/provenance.js +82 -0
  37. package/lib/recon.js +193 -0
  38. package/lib/remediation/index.js +219 -0
  39. package/lib/retirejs.js +239 -0
  40. package/lib/supply-chain.js +131 -0
  41. package/lib/templates.js +364 -0
  42. package/lib/tls.js +145 -0
  43. package/lib/whitebox/frameworks.js +139 -0
  44. package/lib/whitebox/sbom.js +144 -0
  45. package/lib/whitebox/secrets.js +111 -0
  46. package/package.json +76 -0
  47. package/pentest.config.example.js +82 -0
  48. package/scripts/package-skill.sh +16 -0
  49. package/skills/penthera/SKILL.md +272 -0
  50. package/skills/penthera/references/authorization.md +36 -0
  51. package/skills/penthera/references/output-and-ci.md +109 -0
  52. package/skills/penthera/references/profiles-and-flags.md +74 -0
  53. package/skills/penthera/references/remediation.md +267 -0
  54. package/skills/penthera/references/reporting.md +113 -0
  55. package/skills/penthera/references/secure-defaults.md +41 -0
  56. package/skills/penthera/references/troubleshooting.md +100 -0
  57. package/skills/penthera/scripts/preflight.sh +66 -0
  58. package/skills/penthera/scripts/validate-report.mjs +53 -0
  59. package/skills/penthera/scripts/validate-report.sh +8 -0
  60. package/src/cli/ansi.js +70 -0
  61. package/src/cli/baseline.js +37 -0
  62. package/src/cli/merge-results.js +71 -0
  63. package/src/cli/onboarding.js +216 -0
  64. package/src/cli/profiles.js +53 -0
  65. package/src/cli/prompt.js +64 -0
  66. package/src/cli/run-scan.js +152 -0
  67. package/src/cli.js +536 -0
  68. package/src/report/markdown.js +182 -0
  69. package/src/reporter.js +512 -0
  70. package/src/scan-repo.js +205 -0
  71. package/src/scan-url.js +563 -0
  72. package/src/utils/auth.js +13 -0
  73. package/src/utils/http.js +38 -0
  74. package/src/utils/url.js +54 -0
package/src/cli.js ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Penthera, CLI
3
+ *
4
+ * Argument parsing, dispatch to scanners, report output.
5
+ */
6
+ import { parseArgs } from "node:util";
7
+ import { resolve, dirname } from "node:path";
8
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { randomUUID } from "node:crypto";
10
+ import { scanUrl } from "./scan-url.js";
11
+ import { scanRepo } from "./scan-repo.js";
12
+ import { mergeResults } from "./cli/merge-results.js";
13
+ import { resolveScanOptions } from "./cli/profiles.js";
14
+ import { compareWithBaseline } from "./cli/baseline.js";
15
+ import { normalizeBaseUrl } from "./utils/url.js";
16
+ import { printReport, printFindings, writeJsonReport, writeSarifReport, createProgress, printError } from "./reporter.js";
17
+ import { writeMarkdownReport, markdownPathFromJson } from "./report/markdown.js";
18
+ import { parseTemplatePaths } from "../lib/plugins.js";
19
+ import { buildComplianceCoverage, listFrameworks } from "../lib/compliance/index.js";
20
+ import { buildActionPlan } from "../lib/remediation/index.js";
21
+ import { buildReadiness, formatReadinessMarkdown } from "../lib/compliance/readiness.js";
22
+ import { blankAssessment } from "../lib/compliance/self-assessment.js";
23
+ import { buildPolicyPack, policyPackIndex } from "../lib/compliance/policy-pack.js";
24
+ import { parseQuestionnaire, answerQuestionnaire, formatQuestionnaireMarkdown } from "../lib/compliance/questionnaire.js";
25
+ import { buildIncidentReports, formatIncidentMarkdown, incidentTemplate } from "../lib/compliance/incident.js";
26
+ import { buildSbom } from "../lib/whitebox/sbom.js";
27
+ import { emptyHistory, updateHistory, buildAuditLoop } from "../lib/audit-loop.js";
28
+ import { rateSuppliers, parseSupplierList, formatSuppliersMarkdown } from "../lib/supply-chain.js";
29
+
30
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
31
+
32
+ // Jurisdiction data (NL today), loaded once. Add BE/others as more JSON files.
33
+ let _jurisdiction = null;
34
+ function loadJurisdiction() {
35
+ if (!_jurisdiction) {
36
+ _jurisdiction = JSON.parse(
37
+ readFileSync(new URL("../lib/jurisdictions/nl.json", import.meta.url), "utf-8"),
38
+ );
39
+ }
40
+ return _jurisdiction;
41
+ }
42
+
43
+ function shouldRunOnboarding(positionals, opts) {
44
+ if (process.env.PENTHERA_NO_ONBOARDING) return false;
45
+ if (positionals.length > 0) return false;
46
+ if (opts.repo || opts.machine) return false;
47
+ return process.stdin.isTTY && process.stdout.isTTY;
48
+ }
49
+
50
+ const HELP = `
51
+ ${bold("penthera")} v${pkg.version} \u2014 lightweight security scanner
52
+
53
+ ${dim("Usage")}
54
+ $ penthera Interactive setup (TTY, no args needed)
55
+ $ penthera <url> Scan a live URL (black-box)
56
+ $ penthera --repo <path> Scan a local repo (white-box)
57
+ $ penthera <url> --repo <path> Full scan (both)
58
+
59
+ ${dim("Scan modes")}
60
+ --profile <name> quick | standard | deep (default: standard)
61
+ ${dim("(default)")} TLS + fingerprint + discovery + templates + CORS
62
+ + cookies + Retire.js + param discovery + OpenAPI/auth
63
+ --recon + OSINT recon (subdomains, historical URLs)
64
+ --deep + Injection probes (SQLi, SSTI, SSRF, XSS, CMDi)
65
+ --fuzz + Property-based API fuzzing
66
+ --nuclei <path> + Community Nuclei YAML templates (alias for --templates)
67
+ --templates <paths> Comma-separated dirs of Nuclei-compatible YAML templates
68
+ --adaptive Knowledge-graph adaptive probes on discovered routes
69
+ --all Enable all modes (recon + deep + fuzz)
70
+ --machine macOS machine audit (keyloggers, trojans, rootkits)
71
+
72
+ ${dim("Options")}
73
+ -r, --repo <path> Path to repo for source analysis
74
+ --api-root <path> API routes directory (default: auto-detect)
75
+ -o, --output <file> Write JSON report (+ companion .md if .json)
76
+ --markdown <file> Write Markdown report (human-readable)
77
+ --sarif <file> Write SARIF report (GitHub Security tab)
78
+ --baseline <file> Compare against previous JSON report
79
+ --framework <name> Add a compliance mapping to reports (nis2)
80
+ --readiness NIS2 readiness report (scan + self-assessment)
81
+ --assessment <file> Self-assessment answers JSON (used by --readiness)
82
+ --questionnaire <file> Draft answers to a customer security questionnaire
83
+ --assessment-init <file> Write a blank self-assessment template and exit
84
+ --policy-pack <dir> Generate proportionate NIS2 policy templates into <dir>
85
+ --org <name> Organisation name for readiness/policy documents
86
+ --incident <file> Generate NIS2 incident-report drafts + timeline
87
+ --incident-init <file> Write a blank incident template and exit
88
+ --sbom <file> Write a CycloneDX software bill of materials (from --repo)
89
+ --history <file> Track findings over time (time-to-fix, ageing, drift)
90
+ --suppliers <file> Passively rate suppliers' public domains (RAG, measure d)
91
+ --auth-cookie <v> Cookie header for authenticated scans
92
+ --auth-bearer <v> Bearer token for authenticated scans
93
+ --json Output JSON to stdout
94
+ --timeout <ms> Request timeout (default: 10000)
95
+ --concurrency <n> Concurrent requests (default: 15)
96
+ -v, --verbose Detailed output
97
+ -q, --quiet Findings only (for piping)
98
+ -h, --help Show this help
99
+ --version Show version
100
+
101
+ ${dim("Examples")}
102
+ $ penthera https://myapp.com
103
+ $ penthera https://myapp.com --recon --deep
104
+ $ penthera https://myapp.com --profile quick -o report.json
105
+ $ penthera https://myapp.com --profile deep -o report.json --markdown report.md
106
+ $ penthera https://myapp.com --baseline reports/previous.json
107
+ $ penthera --repo ./my-nextjs-app
108
+ $ penthera https://staging.myapp.com --repo . --fuzz
109
+ $ penthera https://myapp.com --adaptive -o report.json
110
+ $ penthera https://myapp.com --templates ./my-templates -o report.json
111
+ $ penthera https://myapp.com --sarif results.sarif --json | jq '.findings[]'
112
+
113
+ ${dim("Exit codes")}
114
+ 0 No critical or high findings
115
+ 1 Critical or high findings detected
116
+ 2 Scan failed (unreachable target, bad config)
117
+ `;
118
+
119
+ function bold(s) { return `\x1b[1m${s}\x1b[0m`; }
120
+ function dim(s) { return `\x1b[2m${s}\x1b[0m`; }
121
+
122
+ export async function run() {
123
+ let parsed;
124
+ try {
125
+ parsed = parseArgs({
126
+ allowPositionals: true,
127
+ options: {
128
+ repo: { type: "string", short: "r" },
129
+ "api-root": { type: "string" },
130
+ output: { type: "string", short: "o" },
131
+ markdown: { type: "string" },
132
+ sarif: { type: "string" },
133
+ baseline: { type: "string" },
134
+ framework: { type: "string" },
135
+ readiness: { type: "boolean", default: false },
136
+ assessment: { type: "string" },
137
+ questionnaire: { type: "string" },
138
+ "assessment-init": { type: "string" },
139
+ "policy-pack": { type: "string" },
140
+ org: { type: "string" },
141
+ incident: { type: "string" },
142
+ "incident-init": { type: "string" },
143
+ sbom: { type: "string" },
144
+ history: { type: "string" },
145
+ suppliers: { type: "string" },
146
+ profile: { type: "string" },
147
+ "auth-cookie": { type: "string" },
148
+ "auth-bearer": { type: "string" },
149
+ json: { type: "boolean", default: false },
150
+ recon: { type: "boolean", default: false },
151
+ deep: { type: "boolean", default: false },
152
+ fuzz: { type: "boolean", default: false },
153
+ nuclei: { type: "string" },
154
+ templates: { type: "string" },
155
+ adaptive: { type: "boolean", default: false },
156
+ all: { type: "boolean", default: false },
157
+ machine: { type: "boolean", default: false },
158
+ timeout: { type: "string", default: "10000" },
159
+ concurrency: { type: "string", default: "15" },
160
+ verbose: { type: "boolean", short: "v", default: false },
161
+ quiet: { type: "boolean", short: "q", default: false },
162
+ help: { type: "boolean", short: "h", default: false },
163
+ version: { type: "boolean", default: false },
164
+ },
165
+ });
166
+ } catch (e) {
167
+ printError(e.message);
168
+ process.exit(2);
169
+ }
170
+
171
+ const { values: opts, positionals } = parsed;
172
+
173
+ if (opts.help) {
174
+ console.log(HELP);
175
+ process.exit(0);
176
+ }
177
+ if (opts.version) {
178
+ console.log(`penthera v${pkg.version}`);
179
+ process.exit(0);
180
+ }
181
+
182
+ if (opts["assessment-init"]) {
183
+ const path = resolve(opts["assessment-init"]);
184
+ writeFileSync(path, JSON.stringify(blankAssessment(), null, 2) + "\n");
185
+ console.log(`Wrote a blank NIS2 self-assessment to ${path}`);
186
+ console.log(`Fill each answer (yes | partial | no | na), then run:`);
187
+ console.log(` penthera <url> --repo . --readiness --assessment ${opts["assessment-init"]}`);
188
+ process.exit(0);
189
+ }
190
+
191
+ if (opts["incident-init"]) {
192
+ const path = resolve(opts["incident-init"]);
193
+ writeFileSync(path, JSON.stringify(incidentTemplate(), null, 2) + "\n");
194
+ console.log(`Wrote a blank incident template to ${path}`);
195
+ console.log(`Fill it in (set aware_at, ISO 8601), then run: penthera --incident ${opts["incident-init"]}`);
196
+ process.exit(0);
197
+ }
198
+
199
+ if (opts.incident) {
200
+ let incident;
201
+ try {
202
+ incident = JSON.parse(readFileSync(resolve(opts.incident), "utf-8"));
203
+ } catch (e) {
204
+ printError(`Could not read --incident file: ${e.message}`);
205
+ process.exit(2);
206
+ }
207
+ const jurisdiction = loadJurisdiction();
208
+ const result = buildIncidentReports(incident, jurisdiction);
209
+ const nowIso = new Date().toISOString();
210
+ result.generated_at = nowIso;
211
+ const outPath = resolve("incident-reports.md");
212
+ writeFileSync(outPath, formatIncidentMarkdown(result, { now: nowIso }));
213
+ console.log(`Incident report drafts written to ${outPath}`);
214
+ for (const t of result.timeline) {
215
+ console.log(` ${t.within} (${t.id}): due ${t.dueBy || "set aware_at"}`);
216
+ }
217
+ process.exit(0);
218
+ }
219
+
220
+ if (opts.suppliers) {
221
+ let text;
222
+ try {
223
+ text = readFileSync(resolve(opts.suppliers), "utf-8");
224
+ } catch {
225
+ text = opts.suppliers; // treat the value itself as an inline list
226
+ }
227
+ const domains = parseSupplierList(text);
228
+ if (domains.length === 0) {
229
+ printError("No supplier domains found in --suppliers input.");
230
+ process.exit(2);
231
+ }
232
+ process.stderr.write(` Rating ${domains.length} supplier domain(s), passive, public-data-only...\n`);
233
+ const report = await rateSuppliers(domains, { generatedAt: new Date().toISOString() });
234
+ const outPath = resolve("supplier-ratings.md");
235
+ writeFileSync(outPath, formatSuppliersMarkdown(report));
236
+ console.log(`Supplier ratings written to ${outPath}`);
237
+ for (const r of report.suppliers) {
238
+ console.log(` ${String(r.rating).padEnd(7)} ${r.domain}${r.score != null ? ` (${r.score})` : ""}`);
239
+ }
240
+ process.exit(0);
241
+ }
242
+
243
+ if (shouldRunOnboarding(positionals, opts)) {
244
+ const { runOnboarding } = await import("./cli/onboarding.js");
245
+ await runOnboarding();
246
+ return;
247
+ }
248
+
249
+ const url = positionals[0] ? normalizeBaseUrl(positionals[0]) : null;
250
+ const repo = opts.repo || null;
251
+
252
+ if (!url && !repo && !opts.machine && !opts.readiness && !opts["policy-pack"] && !opts.sbom && !opts.questionnaire) {
253
+ printError("No target specified. Provide a URL, --repo, --machine, --readiness, --policy-pack, --sbom, --questionnaire, or combine them.");
254
+ console.log(HELP);
255
+ process.exit(2);
256
+ }
257
+
258
+ // Resolve scan profile
259
+ let profileOpts;
260
+ try {
261
+ profileOpts = resolveScanOptions({
262
+ profile: opts.profile,
263
+ recon: opts.recon,
264
+ deep: opts.deep,
265
+ fuzz: opts.fuzz,
266
+ all: opts.all,
267
+ });
268
+ } catch (e) {
269
+ printError(e.message);
270
+ process.exit(2);
271
+ }
272
+
273
+ const recon = profileOpts.recon;
274
+ const deep = profileOpts.deep;
275
+ const fuzz = profileOpts.fuzz;
276
+
277
+ // Warning for destructive modes
278
+ if (deep && !opts.quiet && !opts.json) {
279
+ process.stderr.write(
280
+ "\n \x1b[33mWARN\x1b[0m --deep sends attack payloads (SQLi, SSTI, SSRF, XSS, CMDi).\n" +
281
+ " Only scan targets you have permission to test.\n\n",
282
+ );
283
+ }
284
+
285
+ const scanOpts = {
286
+ timeout: parseInt(opts.timeout, 10),
287
+ concurrency: parseInt(opts.concurrency, 10),
288
+ fuzz,
289
+ recon,
290
+ deep,
291
+ profile: profileOpts.profile,
292
+ skipRetireJs: profileOpts.skipRetireJs,
293
+ skipParamDiscovery: profileOpts.skipParamDiscovery,
294
+ nucleiPath: opts.nuclei ? resolve(opts.nuclei) : null,
295
+ templatePaths: parseTemplatePaths(opts.templates).map((p) => resolve(p)),
296
+ adaptive: opts.adaptive,
297
+ verbose: opts.verbose,
298
+ authCookie: opts["auth-cookie"] || null,
299
+ authBearer: opts["auth-bearer"] || null,
300
+ onPhase: createProgress(opts.quiet || opts.json),
301
+ };
302
+
303
+ const results = [];
304
+
305
+ try {
306
+ if (url) {
307
+ const urlResult = await scanUrl(url, scanOpts);
308
+ results.push(urlResult);
309
+
310
+ if (!urlResult.reachable && !repo) {
311
+ printError(`Target unreachable: ${url}`);
312
+ process.exit(2);
313
+ }
314
+ }
315
+
316
+ if (repo) {
317
+ const repoResult = await scanRepo(resolve(repo), {
318
+ ...scanOpts,
319
+ apiRoot: opts["api-root"] ? resolve(opts["api-root"]) : null,
320
+ });
321
+ results.push(repoResult);
322
+ }
323
+
324
+ if (opts.machine) {
325
+ const { auditMachine } = await import("../lib/machine.js");
326
+ const machineResult = await auditMachine({ onPhase: scanOpts.onPhase });
327
+ results.push({
328
+ target: "this machine",
329
+ mode: "machine",
330
+ timestamp: new Date().toISOString(),
331
+ duration: 0,
332
+ machine: machineResult,
333
+ findings: machineResult.findings,
334
+ });
335
+ }
336
+ } catch (e) {
337
+ printError(`Scan failed: ${e.message}`);
338
+ if (opts.verbose) console.error(e.stack);
339
+ process.exit(2);
340
+ }
341
+
342
+ const merged = mergeResults(results);
343
+ merged.profile = profileOpts.profile;
344
+
345
+ // Compliance framework mapping (e.g. --framework nis2)
346
+ if (opts.framework) {
347
+ const coverage = buildComplianceCoverage(merged, opts.framework);
348
+ if (!coverage) {
349
+ printError(`Unknown --framework "${opts.framework}". Supported: ${listFrameworks().join(", ")}.`);
350
+ process.exit(2);
351
+ }
352
+ merged.compliance = coverage;
353
+ }
354
+
355
+ // Prioritised, owner-actionable remediation plan (also consumed by the Agent
356
+ // Skill to draft questionnaire answers and remediation documents).
357
+ merged.actionPlan = buildActionPlan(merged.findings);
358
+
359
+ // NIS2 readiness report (scan evidence + local self-assessment, provenance-linked).
360
+ // Also built when --questionnaire is set, since the answerer maps to it.
361
+ if (opts.readiness || opts.questionnaire) {
362
+ let answers = {};
363
+ if (opts.assessment) {
364
+ try {
365
+ answers = JSON.parse(readFileSync(resolve(opts.assessment), "utf-8"));
366
+ } catch (e) {
367
+ printError(`Could not read --assessment file: ${e.message}`);
368
+ process.exit(2);
369
+ }
370
+ }
371
+ const jurisdiction = loadJurisdiction();
372
+ merged.readiness = buildReadiness(merged, {
373
+ answers,
374
+ jurisdiction,
375
+ generatedAt: new Date().toISOString(),
376
+ assessedAt: answers?._assessedAt || null,
377
+ });
378
+ }
379
+
380
+ // Remediation / audit-loop tracking over time (measure f)
381
+ if (opts.history) {
382
+ const histPath = resolve(opts.history);
383
+ let history;
384
+ try {
385
+ history = JSON.parse(readFileSync(histPath, "utf-8"));
386
+ } catch {
387
+ history = emptyHistory();
388
+ }
389
+ const now = new Date().toISOString();
390
+ history = updateHistory(history, merged.findings, now);
391
+ mkdirSync(dirname(histPath), { recursive: true });
392
+ writeFileSync(histPath, JSON.stringify(history, null, 2) + "\n");
393
+ merged.auditLoop = buildAuditLoop(history, now);
394
+ }
395
+
396
+ // Baseline comparison
397
+ let baselineStats = null;
398
+ if (opts.baseline) {
399
+ const comparison = compareWithBaseline(merged.findings, resolve(opts.baseline));
400
+ baselineStats = comparison.stats;
401
+ if (opts.quiet) {
402
+ merged.findings = comparison.newFindings;
403
+ } else if (!opts.json) {
404
+ process.stderr.write(
405
+ `\n Baseline: ${comparison.stats.newCount} new, ` +
406
+ `${comparison.stats.resolvedCount} resolved, ${comparison.stats.unchangedCount} unchanged\n\n`,
407
+ );
408
+ }
409
+ }
410
+
411
+ const mdOpts = { version: pkg.version, baseline: baselineStats };
412
+
413
+ // Output
414
+ if (opts.json) {
415
+ console.log(JSON.stringify(merged, null, 2));
416
+ } else if (opts.quiet) {
417
+ printFindings(merged.findings);
418
+ } else {
419
+ printReport(merged, { verbose: opts.verbose });
420
+ }
421
+
422
+ if (opts.output) {
423
+ writeJsonReport(merged, opts.output);
424
+ const companionMd = markdownPathFromJson(opts.output);
425
+ writeMarkdownReport(merged, companionMd, mdOpts);
426
+ if (!opts.quiet && !opts.json) {
427
+ process.stderr.write(` JSON report ${opts.output}\n`);
428
+ process.stderr.write(` Markdown report ${companionMd}\n`);
429
+ }
430
+ }
431
+
432
+ if (opts.markdown) {
433
+ writeMarkdownReport(merged, opts.markdown, mdOpts);
434
+ if (!opts.quiet && !opts.json) {
435
+ process.stderr.write(` Markdown report ${opts.markdown}\n`);
436
+ }
437
+ }
438
+
439
+ if (opts.sarif) {
440
+ writeSarifReport(merged, opts.sarif);
441
+ if (!opts.quiet && !opts.json) {
442
+ process.stderr.write(` SARIF report ${opts.sarif}\n`);
443
+ }
444
+ }
445
+
446
+ if (merged.readiness) {
447
+ const rdPath = opts.output
448
+ ? opts.output.replace(/\.json$/i, "") + "-readiness.md"
449
+ : opts.markdown
450
+ ? opts.markdown.replace(/\.md$/i, "") + "-readiness.md"
451
+ : "nis2-readiness.md";
452
+ writeFileSync(resolve(rdPath), formatReadinessMarkdown(merged.readiness));
453
+ if (!opts.quiet && !opts.json) {
454
+ const s = merged.readiness.summary;
455
+ process.stderr.write(
456
+ ` NIS2 readiness ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["n/a"]} n/a\n`,
457
+ );
458
+ process.stderr.write(` Readiness report ${rdPath}\n`);
459
+ }
460
+ }
461
+
462
+ if (opts.sbom) {
463
+ const sbomRepo = resolve(opts.repo || ".");
464
+ const sbom = buildSbom(sbomRepo, {
465
+ serialNumber: `urn:uuid:${randomUUID()}`,
466
+ timestamp: new Date().toISOString(),
467
+ });
468
+ if (!sbom) {
469
+ printError(`No dependency manifest found in ${sbomRepo} (looked for package-lock.json, package.json, requirements.txt).`);
470
+ } else {
471
+ writeFileSync(resolve(opts.sbom), JSON.stringify(sbom, null, 2) + "\n");
472
+ if (!opts.quiet && !opts.json) {
473
+ process.stderr.write(` SBOM (CycloneDX) ${sbom.components.length} components → ${opts.sbom}\n`);
474
+ }
475
+ }
476
+ }
477
+
478
+ if (opts["policy-pack"]) {
479
+ const dir = resolve(opts["policy-pack"]);
480
+ mkdirSync(dir, { recursive: true });
481
+ const jurisdiction = loadJurisdiction();
482
+ const ctx = {
483
+ org: opts.org || (url ? new URL(url).hostname : "Your organisation"),
484
+ date: new Date().toISOString().slice(0, 10),
485
+ jurisdiction,
486
+ readiness: merged.readiness || null,
487
+ };
488
+ const pack = buildPolicyPack(ctx);
489
+ for (const p of pack) writeFileSync(resolve(dir, p.filename), p.content);
490
+ writeFileSync(resolve(dir, "README.md"), policyPackIndex(pack, ctx));
491
+ if (!opts.quiet && !opts.json) {
492
+ process.stderr.write(` Policy pack ${pack.length} templates → ${dir}/\n`);
493
+ }
494
+ }
495
+
496
+ if (opts.questionnaire) {
497
+ let qtext;
498
+ try {
499
+ qtext = readFileSync(resolve(opts.questionnaire), "utf-8");
500
+ } catch (e) {
501
+ printError(`Could not read --questionnaire file: ${e.message}`);
502
+ process.exit(2);
503
+ }
504
+ const answered = answerQuestionnaire(parseQuestionnaire(qtext), merged.readiness || { measures: [] });
505
+ const qPath = opts.output
506
+ ? opts.output.replace(/\.json$/i, "") + "-questionnaire.md"
507
+ : "questionnaire-response.md";
508
+ writeFileSync(resolve(qPath), formatQuestionnaireMarkdown(answered));
509
+ if (!opts.quiet && !opts.json) {
510
+ process.stderr.write(
511
+ ` Questionnaire ${answered.summary.answered}/${answered.total} drafted` +
512
+ ` (${answered.summary.needsHuman} need human input) → ${qPath}\n`,
513
+ );
514
+ }
515
+ }
516
+
517
+ if (merged.auditLoop && !opts.quiet && !opts.json) {
518
+ const a = merged.auditLoop;
519
+ process.stderr.write(
520
+ ` Audit loop ${a.openCount} open · ${a.resolvedCount} resolved` +
521
+ `${a.medianTimeToFixDays != null ? ` · median fix ${a.medianTimeToFixDays}d` : ""}\n`,
522
+ );
523
+ }
524
+
525
+ if ((opts.output || opts.markdown || opts.sarif || opts.readiness || opts["policy-pack"] || opts.sbom || opts.history || opts.questionnaire) && !opts.quiet && !opts.json) {
526
+ process.stderr.write("\n");
527
+ }
528
+
529
+ // Exit code: 1 if critical or high findings
530
+ const hasSevere = merged.findings.some(
531
+ (f) => f.severity === "critical" || f.severity === "high",
532
+ );
533
+ process.exit(hasSevere ? 1 : 0);
534
+ }
535
+
536
+ export { mergeResults };