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.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +561 -0
- package/SECURITY.md +31 -0
- package/bin/penthera.js +3 -0
- package/bin/scan.js +11 -0
- package/docs/index.md +60 -0
- package/docs/owasp-wstg-coverage.md +79 -0
- package/lib/adaptive-probes.js +334 -0
- package/lib/attack-surface.js +149 -0
- package/lib/audit-loop.js +170 -0
- package/lib/blackbox/adaptive-scan.js +137 -0
- package/lib/blackbox/client-auth.js +60 -0
- package/lib/blackbox/email-dns.js +135 -0
- package/lib/blackbox/headers.js +91 -0
- package/lib/blackbox/idor.js +113 -0
- package/lib/blackbox/jwt.js +73 -0
- package/lib/blackbox/oauth.js +50 -0
- package/lib/blackbox/openapi.js +227 -0
- package/lib/compliance/incident.js +169 -0
- package/lib/compliance/index.js +163 -0
- package/lib/compliance/policy-pack.js +293 -0
- package/lib/compliance/questionnaire.js +113 -0
- package/lib/compliance/readiness.js +229 -0
- package/lib/compliance/self-assessment.js +66 -0
- package/lib/crawler.js +257 -0
- package/lib/fuzzer.js +233 -0
- package/lib/injections.js +676 -0
- package/lib/jurisdictions/nl.json +48 -0
- package/lib/knowledge-graph.js +232 -0
- package/lib/machine.js +814 -0
- package/lib/nuclei-loader.js +283 -0
- package/lib/owasp-wstg.js +204 -0
- package/lib/params.js +235 -0
- package/lib/plugins.js +70 -0
- package/lib/provenance.js +82 -0
- package/lib/recon.js +193 -0
- package/lib/remediation/index.js +219 -0
- package/lib/retirejs.js +239 -0
- package/lib/supply-chain.js +131 -0
- package/lib/templates.js +364 -0
- package/lib/tls.js +145 -0
- package/lib/whitebox/frameworks.js +139 -0
- package/lib/whitebox/sbom.js +144 -0
- package/lib/whitebox/secrets.js +111 -0
- package/package.json +76 -0
- package/pentest.config.example.js +82 -0
- package/scripts/package-skill.sh +16 -0
- package/skills/penthera/SKILL.md +272 -0
- package/skills/penthera/references/authorization.md +36 -0
- package/skills/penthera/references/output-and-ci.md +109 -0
- package/skills/penthera/references/profiles-and-flags.md +74 -0
- package/skills/penthera/references/remediation.md +267 -0
- package/skills/penthera/references/reporting.md +113 -0
- package/skills/penthera/references/secure-defaults.md +41 -0
- package/skills/penthera/references/troubleshooting.md +100 -0
- package/skills/penthera/scripts/preflight.sh +66 -0
- package/skills/penthera/scripts/validate-report.mjs +53 -0
- package/skills/penthera/scripts/validate-report.sh +8 -0
- package/src/cli/ansi.js +70 -0
- package/src/cli/baseline.js +37 -0
- package/src/cli/merge-results.js +71 -0
- package/src/cli/onboarding.js +216 -0
- package/src/cli/profiles.js +53 -0
- package/src/cli/prompt.js +64 -0
- package/src/cli/run-scan.js +152 -0
- package/src/cli.js +536 -0
- package/src/report/markdown.js +182 -0
- package/src/reporter.js +512 -0
- package/src/scan-repo.js +205 -0
- package/src/scan-url.js +563 -0
- package/src/utils/auth.js +13 -0
- package/src/utils/http.js +38 -0
- 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 };
|