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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable Markdown security report.
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { buildWstgCoverage, formatWstgMarkdown } from "../../lib/owasp-wstg.js";
|
|
7
|
+
import { formatComplianceMarkdown } from "../../lib/compliance/index.js";
|
|
8
|
+
import { buildActionPlan, formatActionPlanMarkdown } from "../../lib/remediation/index.js";
|
|
9
|
+
import { formatAuditLoopMarkdown } from "../../lib/audit-loop.js";
|
|
10
|
+
|
|
11
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
12
|
+
const SEV_TAG = {
|
|
13
|
+
critical: "[CRIT]",
|
|
14
|
+
high: "[HIGH]",
|
|
15
|
+
medium: "[MED]",
|
|
16
|
+
low: "[LOW]",
|
|
17
|
+
info: "[INFO]",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function sortFindings(findings) {
|
|
21
|
+
return [...(findings || [])].sort(
|
|
22
|
+
(a, b) => (SEV_ORDER[a.severity] ?? 5) - (SEV_ORDER[b.severity] ?? 5),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function countBySeverity(findings) {
|
|
27
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
28
|
+
for (const f of findings || []) {
|
|
29
|
+
if (counts[f.severity] != null) counts[f.severity]++;
|
|
30
|
+
}
|
|
31
|
+
return counts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mdEscape(s) {
|
|
35
|
+
return String(s || "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate Markdown report content from scan result.
|
|
40
|
+
*/
|
|
41
|
+
export function formatMarkdownReport(result, opts = {}) {
|
|
42
|
+
const findings = sortFindings(result.findings);
|
|
43
|
+
const counts = countBySeverity(findings);
|
|
44
|
+
const version = opts.version || "0.2.0";
|
|
45
|
+
const lines = [];
|
|
46
|
+
|
|
47
|
+
lines.push(`# Penthera Security Report`);
|
|
48
|
+
lines.push("");
|
|
49
|
+
lines.push(`> Generated by [Penthera](https://github.com/danoszz/penthera) v${version}`);
|
|
50
|
+
lines.push("");
|
|
51
|
+
|
|
52
|
+
lines.push("## Executive summary");
|
|
53
|
+
lines.push("");
|
|
54
|
+
lines.push(`| | |`);
|
|
55
|
+
lines.push(`|---|---|`);
|
|
56
|
+
lines.push(`| **Target** | \`${result.target || "unknown"}\` |`);
|
|
57
|
+
lines.push(`| **Scan time** | ${result.timestamp?.replace("T", " ").slice(0, 19) || "-"} UTC |`);
|
|
58
|
+
lines.push(`| **Duration** | ${result.duration ? `${(result.duration / 1000).toFixed(1)}s` : ", "} |`);
|
|
59
|
+
lines.push(`| **Mode** | ${result.modes?.join(", ") || result.mode || "-"} |`);
|
|
60
|
+
lines.push(`| **Reachable** | ${result.reachable ? "Yes" : "No"} |`);
|
|
61
|
+
if (result.profile) lines.push(`| **Profile** | ${result.profile} |`);
|
|
62
|
+
lines.push("");
|
|
63
|
+
|
|
64
|
+
const total = findings.length;
|
|
65
|
+
if (total === 0) {
|
|
66
|
+
lines.push("**No security findings detected.**");
|
|
67
|
+
lines.push("");
|
|
68
|
+
} else {
|
|
69
|
+
lines.push(
|
|
70
|
+
`Found **${total}** issue(s): ` +
|
|
71
|
+
`${counts.critical} critical · ${counts.high} high · ${counts.medium} medium · ` +
|
|
72
|
+
`${counts.low} low · ${counts.info} info`,
|
|
73
|
+
);
|
|
74
|
+
lines.push("");
|
|
75
|
+
|
|
76
|
+
if (counts.critical > 0 || counts.high > 0) {
|
|
77
|
+
lines.push("> **Action required**, critical or high severity issues should be fixed before production deployment.");
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (opts.baseline) {
|
|
83
|
+
lines.push("## Baseline comparison");
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push(`Compared against: \`${opts.baseline.path}\``);
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(`| Status | Count |`);
|
|
88
|
+
lines.push(`|--------|-------|`);
|
|
89
|
+
lines.push(`| New findings | ${opts.baseline.newCount} |`);
|
|
90
|
+
lines.push(`| Resolved since baseline | ${opts.baseline.resolvedCount} |`);
|
|
91
|
+
lines.push(`| Unchanged | ${opts.baseline.unchangedCount} |`);
|
|
92
|
+
lines.push("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lines.push(formatWstgMarkdown(buildWstgCoverage(result, result.profile || opts.profile || "standard")));
|
|
96
|
+
|
|
97
|
+
if (result.compliance) {
|
|
98
|
+
lines.push(formatComplianceMarkdown(result.compliance));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (result.fingerprint) {
|
|
102
|
+
lines.push("## Technology fingerprint");
|
|
103
|
+
lines.push("");
|
|
104
|
+
const fp = result.fingerprint;
|
|
105
|
+
if (fp.framework) lines.push(`- **Framework:** ${fp.framework}`);
|
|
106
|
+
if (fp.server) lines.push(`- **Server:** ${fp.server}`);
|
|
107
|
+
if (fp.cdn) lines.push(`- **CDN:** ${fp.cdn}`);
|
|
108
|
+
lines.push(`- **Security headers:** ${fp.security?.length ? fp.security.join(", ") : "none detected"}`);
|
|
109
|
+
lines.push("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (result.openapi?.specUrl) {
|
|
113
|
+
lines.push("## API surface");
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(`- **OpenAPI spec:** ${result.openapi.specUrl}`);
|
|
116
|
+
lines.push(`- **Paths documented:** ${result.openapi.pathCount || "-"}`);
|
|
117
|
+
lines.push(`- **Unprotected routes (spec):** ${result.openapi.unprotectedRoutes || "-"}`);
|
|
118
|
+
lines.push("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (total > 0) {
|
|
122
|
+
lines.push("## Findings");
|
|
123
|
+
lines.push("");
|
|
124
|
+
|
|
125
|
+
const grouped = {};
|
|
126
|
+
for (const f of findings) {
|
|
127
|
+
const sev = f.severity || "info";
|
|
128
|
+
if (!grouped[sev]) grouped[sev] = [];
|
|
129
|
+
grouped[sev].push(f);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const sev of ["critical", "high", "medium", "low", "info"]) {
|
|
133
|
+
const group = grouped[sev];
|
|
134
|
+
if (!group?.length) continue;
|
|
135
|
+
|
|
136
|
+
lines.push(`### ${SEV_TAG[sev]} ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${group.length})`);
|
|
137
|
+
lines.push("");
|
|
138
|
+
lines.push("| Finding | URL | Confidence | Source |");
|
|
139
|
+
lines.push("|---------|-----|-----------|--------|");
|
|
140
|
+
|
|
141
|
+
for (const f of group) {
|
|
142
|
+
lines.push(`| **${mdEscape(f.title)}** | ${f.url ? `[link](${f.url})` : "-"} | ${f.confidence || "-"} | ${mdEscape(f.source || "-")} |`);
|
|
143
|
+
}
|
|
144
|
+
lines.push("");
|
|
145
|
+
|
|
146
|
+
for (const f of group) {
|
|
147
|
+
if (!f.description) continue;
|
|
148
|
+
lines.push(`**${f.title}**`);
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push(f.description);
|
|
151
|
+
if (f.url) lines.push(`\n- URL: ${f.url}`);
|
|
152
|
+
if (f.status) lines.push(`- HTTP status: ${f.status}`);
|
|
153
|
+
lines.push("");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const actionPlan = result.actionPlan || buildActionPlan(findings);
|
|
159
|
+
if (actionPlan.length > 0) {
|
|
160
|
+
lines.push(formatActionPlanMarkdown(actionPlan));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (result.auditLoop) {
|
|
164
|
+
lines.push(formatAuditLoopMarkdown(result.auditLoop));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
lines.push("---");
|
|
168
|
+
lines.push("");
|
|
169
|
+
lines.push("*Authorized security testing only. See Penthera README for ethical use guidelines.*");
|
|
170
|
+
lines.push("");
|
|
171
|
+
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function writeMarkdownReport(result, filePath, opts = {}) {
|
|
176
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
177
|
+
writeFileSync(filePath, formatMarkdownReport(result, opts));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function markdownPathFromJson(jsonPath) {
|
|
181
|
+
return jsonPath.replace(/\.json$/i, ".md");
|
|
182
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Penthera, Report Formatter
|
|
3
|
+
*
|
|
4
|
+
* Terminal output with ANSI colors, JSON export, SARIF export.
|
|
5
|
+
*/
|
|
6
|
+
import { writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { resolveWstgTags } from "../lib/owasp-wstg.js";
|
|
9
|
+
|
|
10
|
+
const PKG_VERSION = JSON.parse(
|
|
11
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
|
|
12
|
+
).version;
|
|
13
|
+
|
|
14
|
+
// ── ANSI helpers ─────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const NO_COLOR = !!process.env.NO_COLOR;
|
|
17
|
+
|
|
18
|
+
const ansi = (code) => (s) => NO_COLOR ? s : `\x1b[${code}m${s}\x1b[0m`;
|
|
19
|
+
|
|
20
|
+
const c = {
|
|
21
|
+
bold: ansi("1"),
|
|
22
|
+
dim: ansi("2"),
|
|
23
|
+
red: ansi("31"),
|
|
24
|
+
green: ansi("32"),
|
|
25
|
+
yellow: ansi("33"),
|
|
26
|
+
cyan: ansi("36"),
|
|
27
|
+
magenta: ansi("35"),
|
|
28
|
+
bgRed: (s) => NO_COLOR ? s : `\x1b[41;37;1m${s}\x1b[0m`,
|
|
29
|
+
bgYellow:(s) => NO_COLOR ? s : `\x1b[43;30;1m${s}\x1b[0m`,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const SEV_COLOR = {
|
|
33
|
+
critical: c.bgRed,
|
|
34
|
+
high: c.red,
|
|
35
|
+
medium: c.yellow,
|
|
36
|
+
low: c.cyan,
|
|
37
|
+
info: c.dim,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SEV_LABEL = {
|
|
41
|
+
critical: "CRIT",
|
|
42
|
+
high: "HIGH",
|
|
43
|
+
medium: "MED ",
|
|
44
|
+
low: "LOW ",
|
|
45
|
+
info: "INFO",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function pad(s, n) { return (s + " ".repeat(n)).slice(0, n); }
|
|
49
|
+
function line(char = "\u2500", len = 58) { return char.repeat(len); }
|
|
50
|
+
|
|
51
|
+
// ── Terminal report ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export function printReport(result, opts = {}) {
|
|
54
|
+
const out = process.stdout;
|
|
55
|
+
const w = (s) => out.write(s + "\n");
|
|
56
|
+
|
|
57
|
+
w("");
|
|
58
|
+
w(` ${c.bold("penthera")} ${c.dim("security scanner")}`);
|
|
59
|
+
w("");
|
|
60
|
+
|
|
61
|
+
// Target info
|
|
62
|
+
if (result.target) w(` Target ${c.cyan(result.target)}`);
|
|
63
|
+
if (result.timestamp) w(` Started ${result.timestamp.replace("T", " ").slice(0, 19)}`);
|
|
64
|
+
if (result.duration) w(` Duration ${(result.duration / 1000).toFixed(1)}s`);
|
|
65
|
+
if (result.modes) w(` Modes ${result.modes.join(", ")}`);
|
|
66
|
+
else if (result.mode) w(` Mode ${result.mode}`);
|
|
67
|
+
w("");
|
|
68
|
+
|
|
69
|
+
// TLS
|
|
70
|
+
if (result.tls && result.tls.protocol) {
|
|
71
|
+
w(` ${c.dim(line())}`);
|
|
72
|
+
w(` ${c.bold("TLS/SSL")}`);
|
|
73
|
+
w("");
|
|
74
|
+
const t = result.tls;
|
|
75
|
+
w(` Protocol ${t.protocol === "TLSv1.3" ? c.green(t.protocol) : t.protocol}`);
|
|
76
|
+
w(` Cipher ${t.cipher}`);
|
|
77
|
+
w(` Issuer ${t.issuer}`);
|
|
78
|
+
w(` Subject ${t.subject}`);
|
|
79
|
+
w(` Expires ${t.validTo?.slice(0, 10) || "unknown"} ${t.daysUntilExpiry != null ? c.dim(`(${t.daysUntilExpiry}d)`) : ""}`);
|
|
80
|
+
w(` Valid ${t.valid ? c.green("yes") : c.red("no" + (t.error ? `, ${t.error}` : ""))}`);
|
|
81
|
+
w("");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fingerprint
|
|
85
|
+
if (result.fingerprint) {
|
|
86
|
+
w(` ${c.dim(line())}`);
|
|
87
|
+
w(` ${c.bold("Fingerprint")}`);
|
|
88
|
+
w("");
|
|
89
|
+
const fp = result.fingerprint;
|
|
90
|
+
if (fp.framework) w(` Framework ${fp.framework}`);
|
|
91
|
+
if (fp.server) w(` Server ${fp.server}`);
|
|
92
|
+
if (fp.cdn) w(` CDN ${fp.cdn}`);
|
|
93
|
+
if (fp.security?.length > 0) {
|
|
94
|
+
w(` Headers ${c.green(fp.security.join(", "))}`);
|
|
95
|
+
} else {
|
|
96
|
+
w(` Headers ${c.yellow("no security headers detected")}`);
|
|
97
|
+
}
|
|
98
|
+
w("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// OSINT Recon
|
|
102
|
+
if (result.recon) {
|
|
103
|
+
w(` ${c.dim(line())}`);
|
|
104
|
+
w(` ${c.bold("OSINT Recon")}`);
|
|
105
|
+
w("");
|
|
106
|
+
const r = result.recon;
|
|
107
|
+
if (r.subdomains?.length > 0) {
|
|
108
|
+
w(` Subdomains ${c.bold(String(r.subdomains.length))} discovered ${c.dim("(crt.sh)")}`);
|
|
109
|
+
if (opts.verbose) {
|
|
110
|
+
for (const sub of r.subdomains.slice(0, 20)) {
|
|
111
|
+
w(` ${c.dim(sub)}`);
|
|
112
|
+
}
|
|
113
|
+
if (r.subdomains.length > 20) w(` ${c.dim(`... and ${r.subdomains.length - 20} more`)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (r.endpoints?.length > 0) {
|
|
117
|
+
w(` Endpoints ${c.bold(String(r.endpoints.length))} historical paths ${c.dim("(Wayback + OTX)")}`);
|
|
118
|
+
}
|
|
119
|
+
if (r.parameters?.length > 0) {
|
|
120
|
+
w(` Parameters ${r.parameters.length} unique names ${c.dim(r.parameters.slice(0, 8).join(", "))}`);
|
|
121
|
+
}
|
|
122
|
+
const src = r.sources || {};
|
|
123
|
+
w(` Sources crt.sh:${src.crtsh || 0} wayback:${src.wayback || 0} otx:${src.otx || 0}`);
|
|
124
|
+
w("");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Endpoints
|
|
128
|
+
if (result.endpoints && result.endpoints.discovered > 0) {
|
|
129
|
+
w(` ${c.dim(line())}`);
|
|
130
|
+
w(` ${c.bold("Discovery")}`);
|
|
131
|
+
w("");
|
|
132
|
+
w(` Probed ${result.endpoints.total} paths`);
|
|
133
|
+
w(` Found ${c.bold(String(result.endpoints.discovered))} live endpoints`);
|
|
134
|
+
const byStatus = result.endpoints.byStatus || {};
|
|
135
|
+
const statusLine = Object.entries(byStatus)
|
|
136
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
137
|
+
.map(([s, n]) => `${s}:${n}`)
|
|
138
|
+
.join(" ");
|
|
139
|
+
if (statusLine) w(` Status ${c.dim(statusLine)}`);
|
|
140
|
+
w("");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Attack surface (repo scan)
|
|
144
|
+
if (result.attackSurface && result.attackSurface.length > 0) {
|
|
145
|
+
w(` ${c.dim(line())}`);
|
|
146
|
+
w(` ${c.bold("Attack Surface")}`);
|
|
147
|
+
w("");
|
|
148
|
+
const b = result.boundaries || {};
|
|
149
|
+
w(` Routes ${c.bold(String(result.attackSurface.length))} discovered`);
|
|
150
|
+
if (b.public) w(` Public ${b.public.length}`);
|
|
151
|
+
if (b.userAuth) w(` User auth ${b.userAuth.length}`);
|
|
152
|
+
if (b.adminAuth) w(` Admin auth ${b.adminAuth.length}`);
|
|
153
|
+
if (b.cronAuth) w(` Cron auth ${b.cronAuth.length}`);
|
|
154
|
+
|
|
155
|
+
if (opts.verbose && result.attackSurface.length > 0) {
|
|
156
|
+
w("");
|
|
157
|
+
for (const r of result.attackSurface) {
|
|
158
|
+
const methods = r.methods.join(",");
|
|
159
|
+
const auth = r.auth.join(",");
|
|
160
|
+
w(` ${c.dim(pad(methods, 12))} ${pad(r.url, 30)} ${c.dim(auth)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
w("");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Machine Audit
|
|
167
|
+
if (result.machine) {
|
|
168
|
+
const m = result.machine;
|
|
169
|
+
w(` ${c.dim(line())}`);
|
|
170
|
+
w(` ${c.bold("Machine Audit")}`);
|
|
171
|
+
w("");
|
|
172
|
+
|
|
173
|
+
// System posture
|
|
174
|
+
for (const check of m.checks || []) {
|
|
175
|
+
if (check.status) {
|
|
176
|
+
const ok = check.status === "enabled" || check.status === "on" || check.status.startsWith("Version");
|
|
177
|
+
const display = check.status.length > 30 ? check.status.slice(0, 30) + "..." : check.status;
|
|
178
|
+
w(` ${pad(check.name, 36)} ${ok ? c.green(display) : c.red(display)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
w("");
|
|
182
|
+
|
|
183
|
+
// Persistence
|
|
184
|
+
if (m.persistence?.length > 0) {
|
|
185
|
+
const unknown = m.persistence.filter((p) => !p.known);
|
|
186
|
+
const known = m.persistence.filter((p) => p.known);
|
|
187
|
+
w(` Persistence ${c.bold(String(m.persistence.length))} items ${c.dim(`(${known.length} known, ${unknown.length} unknown)`)}`);
|
|
188
|
+
if (opts.verbose) {
|
|
189
|
+
for (const p of unknown) {
|
|
190
|
+
const sig = p.signed === false ? c.red(" UNSIGNED") : p.signed ? c.green(" signed") : "";
|
|
191
|
+
w(` ${c.yellow("?")} ${p.label}${sig}`);
|
|
192
|
+
w(` ${c.dim(p.program)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Network
|
|
198
|
+
if (m.network?.length > 0) {
|
|
199
|
+
w(` Connections ${c.bold(String(m.network.length))} established outbound`);
|
|
200
|
+
if (opts.verbose) {
|
|
201
|
+
const byProcess = {};
|
|
202
|
+
for (const conn of m.network) {
|
|
203
|
+
byProcess[conn.process] = (byProcess[conn.process] || 0) + 1;
|
|
204
|
+
}
|
|
205
|
+
const top = Object.entries(byProcess).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
206
|
+
for (const [proc, count] of top) {
|
|
207
|
+
w(` ${c.dim(pad(proc, 20))} ${count} connection${count > 1 ? "s" : ""}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Browser extensions
|
|
213
|
+
if (m.browserExtensions?.length > 0) {
|
|
214
|
+
w(` Extensions ${c.bold(String(m.browserExtensions.length))} Chrome extensions`);
|
|
215
|
+
if (opts.verbose) {
|
|
216
|
+
for (const ext of m.browserExtensions) {
|
|
217
|
+
w(` ${c.dim(pad(ext.name, 30))} v${ext.version}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Login items
|
|
223
|
+
if (m.loginItems?.length > 0) {
|
|
224
|
+
w(` Login items ${m.loginItems.join(", ")}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// rkhunter stats
|
|
228
|
+
const rkCheck = (m.checks || []).find((c) => c.name === "rkhunter");
|
|
229
|
+
if (rkCheck) {
|
|
230
|
+
const fp = rkCheck.filteredFalsePositives || 0;
|
|
231
|
+
const real = rkCheck.realWarnings || 0;
|
|
232
|
+
w(` rkhunter ${real === 0 ? c.green("clean") : c.yellow(real + " warning" + (real > 1 ? "s" : ""))}${fp > 0 ? c.dim(` (${fp} macOS false positives filtered)`) : ""}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ClamAV stats
|
|
236
|
+
const clamCheck = (m.checks || []).find((c) => c.name === "ClamAV");
|
|
237
|
+
if (clamCheck) {
|
|
238
|
+
w(` ClamAV ${clamCheck.infected === 0 ? c.green("clean") : c.red(clamCheck.infected + " infected")} ${c.dim(`(scanned ${clamCheck.scanned})`)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Optional tools status
|
|
242
|
+
if (m.tools) {
|
|
243
|
+
const toolStatus = Object.entries(m.tools)
|
|
244
|
+
.map(([name, ok]) => ok ? c.green(name) : c.dim(name))
|
|
245
|
+
.join(c.dim(" · "));
|
|
246
|
+
w(` Tools ${toolStatus}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Missing tools advisory
|
|
250
|
+
if (m.missing?.length > 0) {
|
|
251
|
+
w("");
|
|
252
|
+
w(` ${c.dim("Install for deeper scans:")}`);
|
|
253
|
+
for (const t of m.missing) {
|
|
254
|
+
w(` ${c.dim("brew install " + t.split("(")[0].trim().toLowerCase())}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
w("");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Cookies
|
|
262
|
+
if (result.cookies && result.cookies.length > 0) {
|
|
263
|
+
w(` ${c.dim(line())}`);
|
|
264
|
+
w(` ${c.bold("Cookies")}`);
|
|
265
|
+
w("");
|
|
266
|
+
for (const ck of result.cookies) {
|
|
267
|
+
const flags = [];
|
|
268
|
+
flags.push(ck.httpOnly ? c.green("HttpOnly") : c.red("HttpOnly"));
|
|
269
|
+
flags.push(ck.secure ? c.green("Secure") : (result.local ? c.dim("Secure") : c.red("Secure")));
|
|
270
|
+
flags.push(ck.sameSite ? c.green(`SameSite=${ck.sameSite}`) : c.yellow("SameSite"));
|
|
271
|
+
w(` ${pad(ck.name, 24)} ${flags.join(c.dim(" · "))}`);
|
|
272
|
+
}
|
|
273
|
+
w("");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// JS Libraries (Retire.js)
|
|
277
|
+
if (result.jsLibraries && result.jsLibraries.length > 0) {
|
|
278
|
+
w(` ${c.dim(line())}`);
|
|
279
|
+
w(` ${c.bold("JS Libraries")}`);
|
|
280
|
+
w("");
|
|
281
|
+
for (const lib of result.jsLibraries) {
|
|
282
|
+
const vulnCount = lib.vulnerabilities?.length || 0;
|
|
283
|
+
const status = vulnCount > 0
|
|
284
|
+
? c.red(`${vulnCount} known vuln${vulnCount > 1 ? "s" : ""}`)
|
|
285
|
+
: c.green("no known vulns");
|
|
286
|
+
w(` ${pad(lib.name, 20)} ${c.dim("v")}${lib.version} ${status}`);
|
|
287
|
+
if (opts.verbose && lib.vulnerabilities) {
|
|
288
|
+
for (const v of lib.vulnerabilities) {
|
|
289
|
+
const cves = v.cves?.join(", ") || "no CVE";
|
|
290
|
+
w(` ${c.dim(`↳ ${v.severity}, ${cves} (< ${v.below || "latest"})`)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
w("");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Parameter Discovery
|
|
298
|
+
if (result.paramDiscovery && result.paramDiscovery.length > 0) {
|
|
299
|
+
w(` ${c.dim(line())}`);
|
|
300
|
+
w(` ${c.bold("Parameters")}`);
|
|
301
|
+
w("");
|
|
302
|
+
for (const pd of result.paramDiscovery) {
|
|
303
|
+
w(` ${pad(pd.path, 30)} ${c.bold(String(pd.params.length))} active ${c.dim(pd.params.slice(0, 6).join(", "))}`);
|
|
304
|
+
}
|
|
305
|
+
w("");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Findings
|
|
309
|
+
if (result.findings && result.findings.length > 0) {
|
|
310
|
+
w(` ${c.dim(line())}`);
|
|
311
|
+
w(` ${c.bold("Findings")}`);
|
|
312
|
+
w("");
|
|
313
|
+
|
|
314
|
+
const sorted = [...result.findings].sort((a, b) => {
|
|
315
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
316
|
+
return (order[a.severity] ?? 5) - (order[b.severity] ?? 5);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
for (const f of sorted) {
|
|
320
|
+
const colorFn = SEV_COLOR[f.severity] || c.dim;
|
|
321
|
+
const label = SEV_LABEL[f.severity] || " ";
|
|
322
|
+
w(` ${colorFn(label)} ${f.title}`);
|
|
323
|
+
if (f.url) w(` ${" ".repeat(6)} ${c.dim(f.url)}${f.status ? c.dim(` \u2192 ${f.status}`) : ""}`);
|
|
324
|
+
if (f.description && opts.verbose) w(` ${" ".repeat(6)} ${c.dim(f.description)}`);
|
|
325
|
+
}
|
|
326
|
+
w("");
|
|
327
|
+
} else if (result.findings) {
|
|
328
|
+
w(` ${c.dim(line())}`);
|
|
329
|
+
w(` ${c.green("No findings")} \u2014 looking good.`);
|
|
330
|
+
w("");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Compliance summary
|
|
334
|
+
if (result.compliance) {
|
|
335
|
+
const s = result.compliance.summary;
|
|
336
|
+
w(` ${c.dim(line())}`);
|
|
337
|
+
w(` ${c.bold("Compliance")} ${c.dim(result.compliance.name)}`);
|
|
338
|
+
w("");
|
|
339
|
+
w(` Coverage ${c.bold(`${s.coveredThisScan}/${s.testable}`)} testable Article 21 measures exercised this scan`);
|
|
340
|
+
w(` ${c.dim("Technical evidence toward the duty of care, not a certification. Full mapping in the Markdown report.")}`);
|
|
341
|
+
w("");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Summary
|
|
345
|
+
w(` ${c.dim(line())}`);
|
|
346
|
+
printSummaryLine(result, w);
|
|
347
|
+
w("");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function printSummaryLine(result, w) {
|
|
351
|
+
const findings = result.findings || [];
|
|
352
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
353
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
354
|
+
|
|
355
|
+
const parts = [];
|
|
356
|
+
if (counts.critical) parts.push(c.bgRed(` ${counts.critical} critical `));
|
|
357
|
+
if (counts.high) parts.push(c.red(`${counts.high} high`));
|
|
358
|
+
if (counts.medium) parts.push(c.yellow(`${counts.medium} medium`));
|
|
359
|
+
if (counts.low) parts.push(c.cyan(`${counts.low} low`));
|
|
360
|
+
if (counts.info) parts.push(c.dim(`${counts.info} info`));
|
|
361
|
+
|
|
362
|
+
if (parts.length === 0) {
|
|
363
|
+
w(` ${c.green("\u2713")} Clean scan \u2014 no issues found`);
|
|
364
|
+
} else {
|
|
365
|
+
w(` ${parts.join(c.dim(" \u00b7 "))}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Findings-only output (--quiet) ───────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
export function printFindings(findings) {
|
|
372
|
+
if (!findings || findings.length === 0) return;
|
|
373
|
+
const sorted = [...findings].sort((a, b) => {
|
|
374
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
375
|
+
return (order[a.severity] ?? 5) - (order[b.severity] ?? 5);
|
|
376
|
+
});
|
|
377
|
+
for (const f of sorted) {
|
|
378
|
+
const label = (SEV_LABEL[f.severity] || "INFO").trim();
|
|
379
|
+
console.log(`[${label}] ${f.title}${f.url ? ` (${f.url})` : ""}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── JSON export ──────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
export function writeJsonReport(result, filePath) {
|
|
386
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
387
|
+
writeFileSync(filePath, JSON.stringify(result, null, 2) + "\n");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── SARIF export (GitHub Security tab integration) ───────────────────────
|
|
391
|
+
|
|
392
|
+
const SARIF_SEV_MAP = {
|
|
393
|
+
critical: "error",
|
|
394
|
+
high: "error",
|
|
395
|
+
medium: "warning",
|
|
396
|
+
low: "note",
|
|
397
|
+
info: "note",
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Convert a finding location (target URL or path) into a SARIF-safe relative
|
|
402
|
+
* URI. GitHub Code Scanning rejects absolute http(s) URIs ("scheme http did
|
|
403
|
+
* not match checkout scheme file"), so strip the scheme and port and keep a
|
|
404
|
+
* host/path string. The full original URL is preserved in properties.url.
|
|
405
|
+
*/
|
|
406
|
+
function toSarifUri(location) {
|
|
407
|
+
if (!location) return undefined;
|
|
408
|
+
try {
|
|
409
|
+
const u = new URL(location);
|
|
410
|
+
const path = `${u.hostname}${u.pathname}`.replace(/\/+$/, "").replace(/^\/+/, "");
|
|
411
|
+
return path || u.hostname;
|
|
412
|
+
} catch {
|
|
413
|
+
return String(location).replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^\/+/, "");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Generate SARIF 2.1.0 report from scan results.
|
|
419
|
+
* SARIF = Static Analysis Results Interchange Format
|
|
420
|
+
* Uploads to GitHub via: gh api repos/{owner}/{repo}/code-scanning/sarifs
|
|
421
|
+
*/
|
|
422
|
+
export function writeSarifReport(result, filePath) {
|
|
423
|
+
const rules = new Map();
|
|
424
|
+
const results = [];
|
|
425
|
+
|
|
426
|
+
for (const f of result.findings || []) {
|
|
427
|
+
const ruleId = f.templateId || f.category || "unknown";
|
|
428
|
+
|
|
429
|
+
if (!rules.has(ruleId)) {
|
|
430
|
+
rules.set(ruleId, {
|
|
431
|
+
id: ruleId,
|
|
432
|
+
name: f.title,
|
|
433
|
+
shortDescription: { text: f.title },
|
|
434
|
+
fullDescription: { text: f.description || f.title },
|
|
435
|
+
defaultConfiguration: {
|
|
436
|
+
level: SARIF_SEV_MAP[f.severity] || "note",
|
|
437
|
+
},
|
|
438
|
+
properties: {
|
|
439
|
+
"security-severity": sevToScore(f.severity),
|
|
440
|
+
tags: ["security", f.category || "general", ...(f.wstg || resolveWstgTags(f))].filter(Boolean),
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
results.push({
|
|
446
|
+
ruleId,
|
|
447
|
+
level: SARIF_SEV_MAP[f.severity] || "note",
|
|
448
|
+
message: {
|
|
449
|
+
text: `${f.title}${f.description ? `: ${f.description}` : ""}`,
|
|
450
|
+
},
|
|
451
|
+
locations: f.url ? [{
|
|
452
|
+
physicalLocation: {
|
|
453
|
+
artifactLocation: {
|
|
454
|
+
uri: toSarifUri(f.url),
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
}] : [],
|
|
458
|
+
properties: {
|
|
459
|
+
severity: f.severity,
|
|
460
|
+
source: f.source,
|
|
461
|
+
url: f.url,
|
|
462
|
+
httpStatus: f.status,
|
|
463
|
+
wstg: f.wstg || resolveWstgTags(f),
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const sarif = {
|
|
469
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
470
|
+
version: "2.1.0",
|
|
471
|
+
runs: [{
|
|
472
|
+
tool: {
|
|
473
|
+
driver: {
|
|
474
|
+
name: "penthera",
|
|
475
|
+
version: PKG_VERSION,
|
|
476
|
+
informationUri: "https://github.com/danoszz/penthera",
|
|
477
|
+
rules: [...rules.values()],
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
results,
|
|
481
|
+
invocations: [{
|
|
482
|
+
executionSuccessful: true,
|
|
483
|
+
startTimeUtc: result.timestamp,
|
|
484
|
+
endTimeUtc: new Date().toISOString(),
|
|
485
|
+
}],
|
|
486
|
+
}],
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
490
|
+
writeFileSync(filePath, JSON.stringify(sarif, null, 2) + "\n");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function sevToScore(severity) {
|
|
494
|
+
switch (severity) {
|
|
495
|
+
case "critical": return "9.5";
|
|
496
|
+
case "high": return "7.5";
|
|
497
|
+
case "medium": return "5.0";
|
|
498
|
+
case "low": return "2.5";
|
|
499
|
+
default: return "1.0";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Progress logger ──────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
export function createProgress(quiet) {
|
|
506
|
+
if (quiet) return () => {};
|
|
507
|
+
return (msg) => process.stderr.write(` ${c.dim("\u25b8")} ${msg}\n`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function printError(msg) {
|
|
511
|
+
process.stderr.write(`\n ${c.red("error")} ${msg}\n\n`);
|
|
512
|
+
}
|