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
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ANSI helpers + brand styling, respects NO_COLOR and terminal width.
3
+ */
4
+ const off = !!process.env.NO_COLOR;
5
+
6
+ export const bold = (s) => off ? s : `\x1b[1m${s}\x1b[0m`;
7
+ export const dim = (s) => off ? s : `\x1b[2m${s}\x1b[0m`;
8
+ export const cyan = (s) => off ? s : `\x1b[36m${s}\x1b[0m`;
9
+ export const green = (s) => off ? s : `\x1b[32m${s}\x1b[0m`;
10
+ export const yellow = (s) => off ? s : `\x1b[33m${s}\x1b[0m`;
11
+ export const red = (s) => off ? s : `\x1b[31m${s}\x1b[0m`;
12
+
13
+ // ── Brand ──────────────────────────────────────────────────────────────────
14
+ // Penthera blue: #0000ed (rgb 0,0,237). Truecolor + bold, with graceful
15
+ // degradation under NO_COLOR.
16
+ const BRAND = "\x1b[1m\x1b[38;2;0;0;237m";
17
+ const RESET = "\x1b[0m";
18
+ export const brand = (s) => off ? s : `${BRAND}${s}${RESET}`;
19
+
20
+ // "PENTHERA", ANSI Shadow block letters (~67 cols wide).
21
+ const LOGO_BIG = [
22
+ "██████╗ ███████╗███╗ ██╗████████╗██╗ ██╗███████╗██████╗ █████╗ ",
23
+ "██╔══██╗██╔════╝████╗ ██║╚══██╔══╝██║ ██║██╔════╝██╔══██╗██╔══██╗",
24
+ "██████╔╝█████╗ ██╔██╗ ██║ ██║ ███████║█████╗ ██████╔╝███████║",
25
+ "██╔═══╝ ██╔══╝ ██║╚██╗██║ ██║ ██╔══██║██╔══╝ ██╔══██╗██╔══██║",
26
+ "██║ ███████╗██║ ╚████║ ██║ ██║ ██║███████╗██║ ██║██║ ██║",
27
+ "╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
28
+ ];
29
+
30
+ // Compact wordmark for narrow terminals / nested output.
31
+ const LOGO_SMALL = "▌▌ P E N T H E R A";
32
+
33
+ /**
34
+ * Render the Penthera logo. Falls back to a compact wordmark when the
35
+ * terminal is narrower than the block art.
36
+ */
37
+ export function logo(tagline = "security check for your vibecoded app") {
38
+ const cols = process.stdout.columns || 80;
39
+ const lines = [""];
40
+
41
+ if (cols >= 72) {
42
+ for (const row of LOGO_BIG) lines.push(" " + brand(row));
43
+ } else {
44
+ lines.push(" " + brand(LOGO_SMALL));
45
+ }
46
+
47
+ lines.push("");
48
+ if (tagline) lines.push(" " + dim(tagline));
49
+ lines.push("");
50
+ return lines.join("\n");
51
+ }
52
+
53
+ // Back-compat alias, older callers import `banner`.
54
+ export function banner() {
55
+ return logo();
56
+ }
57
+
58
+ export function box(title, lines) {
59
+ const out = [` ${bold(title)}`, ""];
60
+ for (const line of lines) out.push(` ${line}`);
61
+ out.push("");
62
+ return out.join("\n");
63
+ }
64
+
65
+ /** Dim horizontal rule sized to the terminal (capped). */
66
+ export function hr(width) {
67
+ const cols = process.stdout.columns || 60;
68
+ const len = Math.min(width || 58, Math.max(20, cols - 4));
69
+ return dim("─".repeat(len));
70
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Compare scan findings against a previous JSON report.
3
+ */
4
+ import { readFileSync } from "node:fs";
5
+
6
+ function findingKey(f) {
7
+ return `${f.severity}::${f.title}::${f.url || ""}::${f.source || ""}`;
8
+ }
9
+
10
+ export function compareWithBaseline(currentFindings, baselinePath) {
11
+ let baseline;
12
+ try {
13
+ baseline = JSON.parse(readFileSync(baselinePath, "utf-8"));
14
+ } catch (e) {
15
+ throw new Error(`Cannot read baseline report: ${baselinePath} (${e.message})`);
16
+ }
17
+
18
+ const prev = baseline.findings || [];
19
+ const prevKeys = new Set(prev.map(findingKey));
20
+ const currKeys = new Set((currentFindings || []).map(findingKey));
21
+
22
+ const newFindings = (currentFindings || []).filter((f) => !prevKeys.has(findingKey(f)));
23
+ const resolvedFindings = prev.filter((f) => !currKeys.has(findingKey(f)));
24
+ const unchangedFindings = (currentFindings || []).filter((f) => prevKeys.has(findingKey(f)));
25
+
26
+ return {
27
+ newFindings,
28
+ resolvedFindings,
29
+ unchangedFindings,
30
+ stats: {
31
+ path: baselinePath,
32
+ newCount: newFindings.length,
33
+ resolvedCount: resolvedFindings.length,
34
+ unchangedCount: unchangedFindings.length,
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Merge multi-target scan results into one report.
3
+ */
4
+ import { enrichFindingsWithWstg } from "../../lib/owasp-wstg.js";
5
+ import { stampProvenance } from "../../lib/provenance.js";
6
+
7
+ export function mergeResults(results) {
8
+ let merged;
9
+
10
+ if (results.length === 1) {
11
+ merged = { ...results[0] };
12
+ } else {
13
+ merged = {
14
+ target: results.map((r) => r.target).join(" + "),
15
+ modes: results.map((r) => r.mode),
16
+ local: results.some((r) => r.local),
17
+ timestamp: new Date().toISOString(),
18
+ duration: results.reduce((sum, r) => sum + (r.duration || 0), 0),
19
+ reachable: results.some((r) => r.reachable),
20
+ tls: results.find((r) => r.tls)?.tls || null,
21
+ fingerprint: results.find((r) => r.fingerprint)?.fingerprint || null,
22
+ recon: results.find((r) => r.recon)?.recon || null,
23
+ openapi: results.find((r) => r.openapi)?.openapi || null,
24
+ endpoints: mergeEndpoints(results),
25
+ cookies: results.find((r) => r.cookies)?.cookies || null,
26
+ jsLibraries: results.find((r) => r.jsLibraries)?.jsLibraries || null,
27
+ paramDiscovery: results.find((r) => r.paramDiscovery)?.paramDiscovery || null,
28
+ attackSurface: results.find((r) => r.attackSurface?.length)?.attackSurface || null,
29
+ boundaries: results.find((r) => r.boundaries && Object.keys(r.boundaries).length)?.boundaries || null,
30
+ apiRoot: results.find((r) => r.apiRoot)?.apiRoot || null,
31
+ machine: results.find((r) => r.machine)?.machine || null,
32
+ executedProbes: results.flatMap((r) => r.executedProbes || []),
33
+ findings: dedupeFindings(results.flatMap((r) => r.findings || [])),
34
+ };
35
+ }
36
+
37
+ merged.findings = enrichFindingsWithWstg(merged.findings);
38
+ // Provenance + honest-confidence on every finding (source, method,
39
+ // collected_at, confidence). Additive: consumers that don't use it ignore it.
40
+ merged.findings = stampProvenance(merged.findings, merged.timestamp);
41
+ return merged;
42
+ }
43
+
44
+ function mergeEndpoints(results) {
45
+ const all = results.flatMap((r) => {
46
+ if (r.endpoints?.list) return r.endpoints.list;
47
+ return [];
48
+ });
49
+
50
+ if (all.length === 0) {
51
+ const first = results.find((r) => r.endpoints);
52
+ return first?.endpoints || { total: 0, discovered: 0, byStatus: {} };
53
+ }
54
+
55
+ const byStatus = {};
56
+ for (const ep of all) {
57
+ byStatus[ep.status] = (byStatus[ep.status] || 0) + 1;
58
+ }
59
+
60
+ return { total: all.length, discovered: all.length, byStatus, list: all };
61
+ }
62
+
63
+ export function dedupeFindings(findings) {
64
+ const seen = new Set();
65
+ return findings.filter((f) => {
66
+ const key = `${f.severity}::${f.title}::${f.url || ""}::${f.source || ""}`;
67
+ if (seen.has(key)) return false;
68
+ seen.add(key);
69
+ return true;
70
+ });
71
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * PostHog-style interactive onboarding, simple defaults, advanced hidden.
3
+ */
4
+ import { existsSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+ import { initPrompt, closePrompt, ask, choose, confirm, normalizeUrlInput } from "./prompt.js";
8
+ import { banner, box, bold, dim, cyan, green, yellow, hr, brand } from "./ansi.js";
9
+ import { executeScan, writeScanReports } from "./run-scan.js";
10
+ import { isPrivateHost } from "../utils/url.js";
11
+ import { printError } from "../reporter.js";
12
+
13
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
14
+
15
+ function countFindings(findings) {
16
+ const c = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
17
+ for (const f of findings || []) c[f.severity] = (c[f.severity] || 0) + 1;
18
+ return c;
19
+ }
20
+
21
+ function summarize(findings) {
22
+ const c = countFindings(findings);
23
+ const parts = [];
24
+ if (c.critical) parts.push(`${c.critical} critical`);
25
+ if (c.high) parts.push(`${c.high} high`);
26
+ if (c.medium) parts.push(`${c.medium} medium`);
27
+ if (c.low) parts.push(`${c.low} low`);
28
+ if (parts.length === 0) return green("No issues found, looking good!");
29
+ return yellow(parts.join(" · "));
30
+ }
31
+
32
+ const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
33
+
34
+ function topFindings(findings, limit = 3) {
35
+ return [...(findings || [])]
36
+ .filter((f) => f.severity !== "info")
37
+ .sort((a, b) => (SEV_RANK[a.severity] ?? 5) - (SEV_RANK[b.severity] ?? 5))
38
+ .slice(0, limit);
39
+ }
40
+
41
+ function printTopFixes(findings) {
42
+ const top = topFindings(findings);
43
+ if (top.length === 0) return;
44
+ process.stdout.write(`\n ${bold("Start here")}\n`);
45
+ top.forEach((f, i) => {
46
+ process.stdout.write(` ${i + 1}. ${f.title} ${dim(`(${f.severity})`)}\n`);
47
+ });
48
+ }
49
+
50
+ async function runSimpleScan(config) {
51
+ process.stdout.write(`\n ${dim("Scanning")}${dim("...")}\n\n`);
52
+ const result = await executeScan({ ...config, version: pkg.version });
53
+ const paths = writeScanReports(
54
+ { merged: result.merged },
55
+ result.paths,
56
+ result.mdOpts,
57
+ { verbose: false, quiet: true, writeSarif: true },
58
+ );
59
+ return { ...result, paths };
60
+ }
61
+
62
+ function printScanFailure(url, err) {
63
+ if (/unreachable/i.test(err.message)) {
64
+ printError(`Couldn't reach ${url}. Is your app running?`);
65
+ process.stdout.write(
66
+ `\n ${dim("Tip:")} start your dev server, then run ${bold("penthera")} again.\n\n`,
67
+ );
68
+ return;
69
+ }
70
+ printError(err.message);
71
+ }
72
+
73
+ export async function runOnboarding() {
74
+ await initPrompt();
75
+
76
+ process.stdout.write(banner());
77
+ process.stdout.write(box("Welcome", [
78
+ "Shipped something you vibecoded? Pressure-test it before attackers do.",
79
+ "Penthera checks your live app + repo for the gaps that get apps owned, ",
80
+ "exposed APIs, missing headers, weak auth, leaked secrets.",
81
+ "",
82
+ dim("Only scan apps you own or have permission to test."),
83
+ ]));
84
+ process.stdout.write(
85
+ ` ${dim("Expert?")} ${dim("skip the wizard with")} ${brand("penthera <url> --profile deep")} ${dim("· see")} ${brand("penthera --help")}\n\n`,
86
+ );
87
+
88
+ // ── Step 1: URL (required) ─────────────────────────────────────────────
89
+ process.stdout.write(` ${bold("Step 1")} ${dim("Where is your app running?")}\n\n`);
90
+ const urlRaw = await ask(` ${cyan(">")} URL ${dim("(e.g. https://myapp.com or localhost:3000)")}: `);
91
+ const url = normalizeUrlInput(urlRaw);
92
+
93
+ if (!url) {
94
+ printError("A URL is required. Example: localhost:3000 or https://staging.myapp.com");
95
+ closePrompt();
96
+ process.exit(2);
97
+ }
98
+
99
+ // ── Step 2: Source code (optional, default yes if package.json) ────────
100
+ const hasProject = existsSync(resolve("package.json"));
101
+ let repo = null;
102
+
103
+ if (hasProject) {
104
+ process.stdout.write(`\n ${bold("Step 2")} ${dim("Scan your source code too?")}\n`);
105
+ process.stdout.write(` ${dim("Finds hardcoded secrets and risky API routes in this folder.")}\n\n`);
106
+ const scanRepo = await confirm(` ${cyan(">")} Scan ./`, true);
107
+ if (scanRepo) repo = ".";
108
+ } else {
109
+ process.stdout.write(`\n ${dim("Step 2 skipped, no package.json in current folder.")}\n`);
110
+ }
111
+
112
+ // ── Permission check for remote targets ────────────────────────────────
113
+ if (!isPrivateHost(url)) {
114
+ process.stdout.write(`\n ${yellow("Note")} ${dim("We'll send safe checks to")} ${cyan(url)}\n`);
115
+ process.stdout.write(` ${dim("No attack payloads in the default scan.")}\n\n`);
116
+ // Default to "no": for a security tool the safe default is to not scan a
117
+ // remote host unless the user explicitly affirms authorization.
118
+ const ok = await confirm(` ${cyan(">")} You have permission to scan this URL`, false);
119
+ if (!ok) {
120
+ process.stdout.write(`\n ${dim("Aborted.")}\n\n`);
121
+ closePrompt();
122
+ process.exit(0);
123
+ }
124
+ }
125
+
126
+ closePrompt();
127
+
128
+ // ── Run default scan (standard profile) ────────────────────────────────
129
+ let scanResult;
130
+ try {
131
+ scanResult = await runSimpleScan({ url, repo, profile: "standard" });
132
+ } catch (e) {
133
+ printScanFailure(url, e);
134
+ process.exit(2);
135
+ }
136
+
137
+ const { merged, paths, hasSevere } = scanResult;
138
+
139
+ process.stdout.write(`\n ${hr()}\n`);
140
+ process.stdout.write(` ${green("Done!")} ${summarize(merged.findings)}\n`);
141
+ printTopFixes(merged.findings);
142
+ process.stdout.write(`\n ${bold("Read your report")}\n`);
143
+ process.stdout.write(` ${cyan(paths.markdown)} ${dim("← start here (plain English)")}\n`);
144
+ process.stdout.write(` ${dim(paths.json)} ${dim("(machine-readable)")}\n`);
145
+ process.stdout.write(`\n ${hr()}\n`);
146
+
147
+ // ── Post-scan menu (re-open prompt) ────────────────────────────────────
148
+ await initPrompt();
149
+
150
+ const next = await choose("What next?", [
151
+ { id: "done", label: "Looks good, I'm done" },
152
+ { id: "deeper", label: "Run a deeper scan (sends test payloads, needs permission)" },
153
+ { id: "custom", label: "Custom setup (profiles, auth tokens, advanced flags)" },
154
+ ]);
155
+
156
+ if (next === "done") {
157
+ closePrompt();
158
+ process.stdout.write(`\n ${dim("Tip:")} re-run anytime with ${bold("penthera")} or ${bold("penthera <url>")}\n\n`);
159
+ process.exit(hasSevere ? 1 : 0);
160
+ }
161
+
162
+ if (next === "deeper") {
163
+ process.stdout.write(`\n ${yellow("Deep scan")} sends SQLi, XSS, and fuzz payloads.\n`);
164
+ const ok = await confirm(` ${cyan(">")} I have permission to run destructive tests`, false);
165
+ closePrompt();
166
+ if (!ok) {
167
+ process.stdout.write(`\n ${dim("Skipped deep scan.")}\n\n`);
168
+ process.exit(hasSevere ? 1 : 0);
169
+ }
170
+
171
+ try {
172
+ const deep = await runSimpleScan({ url, repo, profile: "deep" });
173
+ process.stdout.write(`\n ${green("Deep scan complete.")} ${summarize(deep.merged.findings)}\n`);
174
+ process.stdout.write(` ${cyan(deep.paths.markdown)}\n\n`);
175
+ process.exit(deep.hasSevere ? 1 : 0);
176
+ } catch (e) {
177
+ printScanFailure(url, e);
178
+ process.exit(2);
179
+ }
180
+ }
181
+
182
+ // ── Custom setup (hidden power-user path) ──────────────────────────────
183
+ process.stdout.write(box("Custom setup", [
184
+ "Power-user options. Most people never need this.",
185
+ dim("Press enter to keep defaults shown in [brackets]."),
186
+ ]));
187
+
188
+ const profileAns = await ask(` Profile ${dim("[standard]")} quick | standard | deep: `);
189
+ const profile = ["quick", "standard", "deep"].includes(profileAns) ? profileAns : "standard";
190
+
191
+ const repoAns = await ask(` Repo path ${dim("[.]")}: `);
192
+ const customRepo = repoAns || repo || null;
193
+
194
+ const bearer = await ask(` Bearer token ${dim("(optional, for logged-in routes)")}: `);
195
+ const cookie = await ask(` Session cookie ${dim("(optional)")}: `);
196
+
197
+ const customUrl = await ask(` URL ${dim(`[${url}]`)}: `);
198
+ closePrompt();
199
+
200
+ try {
201
+ const custom = await runSimpleScan({
202
+ url: customUrl || url,
203
+ repo: customRepo,
204
+ profile,
205
+ authBearer: bearer || null,
206
+ authCookie: cookie || null,
207
+ });
208
+ process.stdout.write(`\n ${green("Custom scan complete.")} ${summarize(custom.merged.findings)}\n`);
209
+ process.stdout.write(` ${cyan(custom.paths.markdown)}\n\n`);
210
+ process.stdout.write(` ${dim("Expert mode:")} penthera --help\n\n`);
211
+ process.exit(custom.hasSevere ? 1 : 0);
212
+ } catch (e) {
213
+ printScanFailure(customUrl || url, e);
214
+ process.exit(2);
215
+ }
216
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Scan profile presets.
3
+ */
4
+
5
+ export const PROFILES = {
6
+ quick: {
7
+ label: "Quick",
8
+ description: "Headers, OpenAPI, auth smoke tests, skips Retire.js and param discovery",
9
+ recon: false,
10
+ deep: false,
11
+ fuzz: false,
12
+ skipRetireJs: true,
13
+ skipParamDiscovery: true,
14
+ },
15
+ standard: {
16
+ label: "Standard",
17
+ description: "Full non-destructive scan (default)",
18
+ recon: false,
19
+ deep: false,
20
+ fuzz: false,
21
+ skipRetireJs: false,
22
+ skipParamDiscovery: false,
23
+ },
24
+ deep: {
25
+ label: "Deep",
26
+ description: "Maximum coverage: recon + injection probes + API fuzzing",
27
+ recon: true,
28
+ deep: true,
29
+ fuzz: true,
30
+ skipRetireJs: false,
31
+ skipParamDiscovery: false,
32
+ },
33
+ };
34
+
35
+ export function resolveScanOptions(opts) {
36
+ const profileName = opts.profile || "standard";
37
+ const profile = PROFILES[profileName];
38
+
39
+ if (!profile) {
40
+ throw new Error(`Unknown profile "${profileName}". Use: quick, standard, or deep.`);
41
+ }
42
+
43
+ const all = opts.all === true;
44
+
45
+ return {
46
+ profile: profileName,
47
+ recon: all || opts.recon === true || profile.recon,
48
+ deep: all || opts.deep === true || profile.deep,
49
+ fuzz: all || opts.fuzz === true || profile.fuzz,
50
+ skipRetireJs: profile.skipRetireJs,
51
+ skipParamDiscovery: profile.skipParamDiscovery,
52
+ };
53
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared terminal prompt helpers (TTY + piped stdin).
3
+ */
4
+ import { createInterface } from "node:readline";
5
+
6
+ let _pipedLines = null;
7
+ let _rl = null;
8
+
9
+ export async function initPrompt() {
10
+ if (process.stdin.isTTY) {
11
+ _rl = createInterface({ input: process.stdin, output: process.stdout });
12
+ } else {
13
+ const chunks = [];
14
+ for await (const chunk of process.stdin) chunks.push(chunk);
15
+ _pipedLines = Buffer.concat(chunks).toString().split("\n").map((l) => l.trim());
16
+ }
17
+ }
18
+
19
+ export function closePrompt() {
20
+ if (_rl) _rl.close();
21
+ }
22
+
23
+ export function ask(question) {
24
+ if (_pipedLines) {
25
+ process.stdout.write(question);
26
+ const answer = _pipedLines.shift() || "";
27
+ process.stdout.write(answer + "\n");
28
+ return Promise.resolve(answer);
29
+ }
30
+ return new Promise((resolve) => {
31
+ _rl.question(question, (answer) => resolve(answer.trim()));
32
+ });
33
+ }
34
+
35
+ /** Normalize user URL input (adds http/https). */
36
+ export function normalizeUrlInput(raw, forceLocal = false) {
37
+ if (!raw) return null;
38
+ const trimmed = raw.trim();
39
+ if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
40
+ const looksLocal = forceLocal ||
41
+ /^(localhost|127\.|0\.0\.0|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01]))/.test(trimmed);
42
+ return looksLocal ? `http://${trimmed}` : `https://${trimmed}`;
43
+ }
44
+ return trimmed;
45
+ }
46
+
47
+ /** Single-choice menu. Returns selected option id. */
48
+ export async function choose(question, options) {
49
+ process.stdout.write(`\n${question}\n`);
50
+ options.forEach((opt, i) => {
51
+ process.stdout.write(` ${i + 1}. ${opt.label}\n`);
52
+ });
53
+ const answer = await ask(`\n Choice [1-${options.length}]: `);
54
+ const idx = parseInt(answer, 10) - 1;
55
+ if (idx >= 0 && idx < options.length) return options[idx].id;
56
+ return options[0].id;
57
+ }
58
+
59
+ export async function confirm(question, defaultYes = true) {
60
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
61
+ const answer = await ask(`${question} ${hint}: `);
62
+ if (!answer) return defaultYes;
63
+ return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
64
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared scan execution, used by CLI and interactive onboarding.
3
+ */
4
+ import { resolve, join } from "node:path";
5
+ import { existsSync, mkdirSync } from "node:fs";
6
+ import { scanUrl } from "../scan-url.js";
7
+ import { scanRepo } from "../scan-repo.js";
8
+ import { mergeResults } from "./merge-results.js";
9
+ import { resolveScanOptions } from "./profiles.js";
10
+ import { compareWithBaseline } from "./baseline.js";
11
+ import { normalizeBaseUrl, urlToFilename } from "../utils/url.js";
12
+ import {
13
+ printReport,
14
+ printFindings,
15
+ writeJsonReport,
16
+ writeSarifReport,
17
+ createProgress,
18
+ } from "../reporter.js";
19
+ import { writeMarkdownReport, markdownPathFromJson } from "../report/markdown.js";
20
+ import { buildActionPlan } from "../../lib/remediation/index.js";
21
+
22
+ export async function executeScan(config) {
23
+ const {
24
+ url = null,
25
+ repo = null,
26
+ machine = false,
27
+ profile = "standard",
28
+ recon = false,
29
+ deep = false,
30
+ fuzz = false,
31
+ output = null,
32
+ markdown = null,
33
+ sarif = null,
34
+ baseline = null,
35
+ authCookie = null,
36
+ authBearer = null,
37
+ apiRoot = null,
38
+ quiet = false,
39
+ verbose = false,
40
+ json = false,
41
+ version = "0.2.0",
42
+ onPhase = null,
43
+ } = config;
44
+
45
+ const profileOpts = resolveScanOptions({ profile, recon, deep, fuzz, all: false });
46
+
47
+ const scanOpts = {
48
+ timeout: config.timeout || 10_000,
49
+ concurrency: config.concurrency || 15,
50
+ fuzz: profileOpts.fuzz,
51
+ recon: profileOpts.recon,
52
+ deep: profileOpts.deep,
53
+ profile: profileOpts.profile,
54
+ skipRetireJs: profileOpts.skipRetireJs,
55
+ skipParamDiscovery: profileOpts.skipParamDiscovery,
56
+ nucleiPath: config.nucleiPath || null,
57
+ templatePaths: config.templatePaths || [],
58
+ adaptive: config.adaptive || false,
59
+ verbose,
60
+ authCookie,
61
+ authBearer,
62
+ onPhase: onPhase || createProgress(quiet || json),
63
+ };
64
+
65
+ const results = [];
66
+
67
+ if (url) {
68
+ const urlResult = await scanUrl(normalizeBaseUrl(url), scanOpts);
69
+ results.push(urlResult);
70
+ if (!urlResult.reachable && !repo) {
71
+ throw new Error(`Target unreachable: ${url}`);
72
+ }
73
+ }
74
+
75
+ if (repo) {
76
+ const repoResult = await scanRepo(resolve(repo), {
77
+ ...scanOpts,
78
+ apiRoot: apiRoot ? resolve(apiRoot) : null,
79
+ });
80
+ results.push(repoResult);
81
+ }
82
+
83
+ if (machine) {
84
+ const { auditMachine } = await import("../../lib/machine.js");
85
+ const machineResult = await auditMachine({ onPhase: scanOpts.onPhase });
86
+ results.push({
87
+ target: "this machine",
88
+ mode: "machine",
89
+ timestamp: new Date().toISOString(),
90
+ duration: 0,
91
+ machine: machineResult,
92
+ findings: machineResult.findings,
93
+ });
94
+ }
95
+
96
+ const merged = mergeResults(results);
97
+ merged.profile = profileOpts.profile;
98
+ // Parity with the main CLI path so onboarding reports carry the action plan.
99
+ merged.actionPlan = buildActionPlan(merged.findings);
100
+
101
+ let baselineStats = null;
102
+ if (baseline) {
103
+ const comparison = compareWithBaseline(merged.findings, resolve(baseline));
104
+ baselineStats = comparison.stats;
105
+ if (quiet) merged.findings = comparison.newFindings;
106
+ }
107
+
108
+ const mdOpts = { version, baseline: baselineStats };
109
+
110
+ // Default report paths
111
+ let jsonPath = output;
112
+ let mdPath = markdown;
113
+ let sarifPath = sarif;
114
+
115
+ if (!jsonPath && !json) {
116
+ const reportsDir = resolve("reports");
117
+ if (!existsSync(reportsDir)) mkdirSync(reportsDir, { recursive: true });
118
+ const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
119
+ const base = url ? urlToFilename(normalizeBaseUrl(url)) : repo ? "repo" : "scan";
120
+ jsonPath = join(reportsDir, `${base}_${ts}.json`);
121
+ mdPath = mdPath || markdownPathFromJson(jsonPath);
122
+ sarifPath = sarifPath || join(reportsDir, `${base}_${ts}.sarif`);
123
+ } else if (jsonPath && !mdPath) {
124
+ mdPath = markdownPathFromJson(jsonPath);
125
+ }
126
+
127
+ return {
128
+ merged,
129
+ profileOpts,
130
+ paths: { json: jsonPath, markdown: mdPath, sarif: sarifPath },
131
+ mdOpts,
132
+ hasSevere: merged.findings.some((f) => f.severity === "critical" || f.severity === "high"),
133
+ };
134
+ }
135
+
136
+ export function writeScanReports(result, paths, mdOpts, opts = {}) {
137
+ const { quiet = false, json = false, writeSarif = true } = opts;
138
+
139
+ if (json) {
140
+ console.log(JSON.stringify(result.merged, null, 2));
141
+ } else if (!quiet) {
142
+ printReport(result.merged, { verbose: opts.verbose });
143
+ } else {
144
+ printFindings(result.merged.findings);
145
+ }
146
+
147
+ if (paths.json) writeJsonReport(result.merged, paths.json);
148
+ if (paths.markdown) writeMarkdownReport(result.merged, paths.markdown, mdOpts);
149
+ if (writeSarif && paths.sarif) writeSarifReport(result.merged, paths.sarif);
150
+
151
+ return paths;
152
+ }