@rafter-security/cli 0.6.5 → 0.7.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.
@@ -0,0 +1,278 @@
1
+ import { Command } from "commander";
2
+ import axios from "axios";
3
+ import { API, resolveKey, EXIT_GENERAL_ERROR, EXIT_SCAN_NOT_FOUND } from "../utils/api.js";
4
+ import { validateWebhookUrl } from "../core/audit-logger.js";
5
+ import { ConfigManager } from "../core/config-manager.js";
6
+ import { fmt } from "../utils/formatter.js";
7
+ function resolveWebhook(cliOpt) {
8
+ if (cliOpt)
9
+ return cliOpt;
10
+ if (process.env.RAFTER_NOTIFY_WEBHOOK)
11
+ return process.env.RAFTER_NOTIFY_WEBHOOK;
12
+ // Try config file
13
+ try {
14
+ const configManager = new ConfigManager();
15
+ const config = configManager.load();
16
+ if (config.agent?.notifications?.webhook) {
17
+ return config.agent.notifications.webhook;
18
+ }
19
+ }
20
+ catch {
21
+ // ignore
22
+ }
23
+ console.error("No webhook URL provided. Use --webhook or set RAFTER_NOTIFY_WEBHOOK");
24
+ process.exit(EXIT_GENERAL_ERROR);
25
+ }
26
+ function detectPlatform(url) {
27
+ if (url.includes("hooks.slack.com") || url.includes("slack.com/api"))
28
+ return "slack";
29
+ if (url.includes("discord.com/api/webhooks") || url.includes("discordapp.com/api/webhooks"))
30
+ return "discord";
31
+ return "generic";
32
+ }
33
+ function formatSlackPayload(scan) {
34
+ const status = scan.status ?? "unknown";
35
+ const repo = scan.repository_name ?? "unknown";
36
+ const scanId = scan.scan_id ?? "";
37
+ const findings = scan.findings ?? [];
38
+ const summary = scan.summary ?? {};
39
+ const critical = summary.critical ?? 0;
40
+ const high = summary.high ?? 0;
41
+ const medium = summary.medium ?? 0;
42
+ const low = summary.low ?? 0;
43
+ const total = critical + high + medium + low;
44
+ let statusIcon;
45
+ let statusText;
46
+ if (status === "completed" && total === 0) {
47
+ statusIcon = ":white_check_mark:";
48
+ statusText = "Clean — no issues found";
49
+ }
50
+ else if (status === "completed" && (critical > 0 || high > 0)) {
51
+ statusIcon = ":rotating_light:";
52
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
53
+ }
54
+ else if (status === "completed") {
55
+ statusIcon = ":warning:";
56
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
57
+ }
58
+ else if (status === "failed") {
59
+ statusIcon = ":x:";
60
+ statusText = "Scan failed";
61
+ }
62
+ else {
63
+ statusIcon = ":hourglass_flowing_sand:";
64
+ statusText = `Scan ${status}`;
65
+ }
66
+ const sectionFields = [
67
+ { type: "mrkdwn", text: `*Repository:*\n${repo}` },
68
+ { type: "mrkdwn", text: `*Status:*\n${statusText}` },
69
+ ];
70
+ if (scanId)
71
+ sectionFields.push({ type: "mrkdwn", text: `*Scan ID:*\n\`${scanId}\`` });
72
+ if (scan.branch_name)
73
+ sectionFields.push({ type: "mrkdwn", text: `*Branch:*\n\`${scan.branch_name}\`` });
74
+ const blocks = [
75
+ { type: "header", text: { type: "plain_text", text: `${statusIcon} Rafter Security Scan` } },
76
+ { type: "section", fields: sectionFields },
77
+ ];
78
+ if (total > 0) {
79
+ const parts = [];
80
+ if (critical)
81
+ parts.push(`:red_circle: Critical: *${critical}*`);
82
+ if (high)
83
+ parts.push(`:orange_circle: High: *${high}*`);
84
+ if (medium)
85
+ parts.push(`:large_yellow_circle: Medium: *${medium}*`);
86
+ if (low)
87
+ parts.push(`:white_circle: Low: *${low}*`);
88
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: parts.join("\n") } });
89
+ }
90
+ if (findings.length > 0) {
91
+ const lines = findings.slice(0, 5).map((f) => {
92
+ const sev = (f.severity ?? "unknown").toUpperCase();
93
+ const title = f.title ?? f.rule_id ?? "Unknown";
94
+ const loc = f.location ?? f.file ?? "";
95
+ let line = `• \`[${sev}]\` ${title}`;
96
+ if (loc)
97
+ line += ` — ${loc}`;
98
+ return line;
99
+ });
100
+ if (findings.length > 5)
101
+ lines.push(`_... and ${findings.length - 5} more_`);
102
+ blocks.push({ type: "divider" });
103
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Top Findings:*\n${lines.join("\n")}` } });
104
+ }
105
+ blocks.push({
106
+ type: "context",
107
+ elements: [{ type: "mrkdwn", text: "Posted by *rafter-bot* | <https://rafter.so|rafter.so>" }],
108
+ });
109
+ return { text: `[rafter] ${repo}: ${statusText}`, blocks };
110
+ }
111
+ function formatDiscordPayload(scan) {
112
+ const status = scan.status ?? "unknown";
113
+ const repo = scan.repository_name ?? "unknown";
114
+ const scanId = scan.scan_id ?? "";
115
+ const findings = scan.findings ?? [];
116
+ const summary = scan.summary ?? {};
117
+ const critical = summary.critical ?? 0;
118
+ const high = summary.high ?? 0;
119
+ const medium = summary.medium ?? 0;
120
+ const low = summary.low ?? 0;
121
+ const total = critical + high + medium + low;
122
+ let color;
123
+ let statusText;
124
+ if (status === "completed" && total === 0) {
125
+ color = 0x2ecc71;
126
+ statusText = "Clean — no issues found";
127
+ }
128
+ else if (status === "completed" && (critical > 0 || high > 0)) {
129
+ color = 0xe74c3c;
130
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
131
+ }
132
+ else if (status === "completed") {
133
+ color = 0xf39c12;
134
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
135
+ }
136
+ else if (status === "failed") {
137
+ color = 0x95a5a6;
138
+ statusText = "Scan failed";
139
+ }
140
+ else {
141
+ color = 0x3498db;
142
+ statusText = `Scan ${status}`;
143
+ }
144
+ const fields = [
145
+ { name: "Repository", value: repo, inline: true },
146
+ { name: "Status", value: statusText, inline: true },
147
+ ];
148
+ if (scanId)
149
+ fields.push({ name: "Scan ID", value: `\`${scanId}\``, inline: true });
150
+ if (scan.branch_name)
151
+ fields.push({ name: "Branch", value: `\`${scan.branch_name}\``, inline: true });
152
+ if (total > 0) {
153
+ const parts = [];
154
+ if (critical)
155
+ parts.push(`\u{1f534} Critical: **${critical}**`);
156
+ if (high)
157
+ parts.push(`\u{1f7e0} High: **${high}**`);
158
+ if (medium)
159
+ parts.push(`\u{1f7e1} Medium: **${medium}**`);
160
+ if (low)
161
+ parts.push(`\u26aa Low: **${low}**`);
162
+ fields.push({ name: "Severity Breakdown", value: parts.join("\n"), inline: false });
163
+ }
164
+ if (findings.length > 0) {
165
+ const lines = findings.slice(0, 5).map((f) => {
166
+ const sev = (f.severity ?? "unknown").toUpperCase();
167
+ const title = f.title ?? f.rule_id ?? "Unknown";
168
+ const loc = f.location ?? f.file ?? "";
169
+ let line = `• \`[${sev}]\` ${title}`;
170
+ if (loc)
171
+ line += ` — ${loc}`;
172
+ return line;
173
+ });
174
+ if (findings.length > 5)
175
+ lines.push(`*... and ${findings.length - 5} more*`);
176
+ fields.push({ name: "Top Findings", value: lines.join("\n"), inline: false });
177
+ }
178
+ return {
179
+ content: `[rafter] ${repo}: ${statusText}`,
180
+ embeds: [{ title: "\u{1f6e1}\ufe0f Rafter Security Scan", color, fields, footer: { text: "rafter-bot | rafter.so" } }],
181
+ };
182
+ }
183
+ function formatGenericPayload(scan) {
184
+ const status = scan.status ?? "unknown";
185
+ const repo = scan.repository_name ?? "unknown";
186
+ const summary = scan.summary ?? {};
187
+ const total = (summary.critical ?? 0) + (summary.high ?? 0) + (summary.medium ?? 0) + (summary.low ?? 0);
188
+ let statusText;
189
+ if (status === "completed" && total === 0)
190
+ statusText = "Clean — no issues found";
191
+ else if (status === "completed")
192
+ statusText = `${total} issue${total !== 1 ? "s" : ""} found`;
193
+ else
194
+ statusText = `Scan ${status}`;
195
+ const msg = `[rafter] ${repo}: ${statusText}`;
196
+ return { text: msg, content: msg, ...scan };
197
+ }
198
+ export function createNotifyCommand() {
199
+ return new Command("notify")
200
+ .description("Post scan results to Slack or Discord channels via webhooks")
201
+ .argument("[scan_id]", "Scan ID to fetch and post results for")
202
+ .option("-w, --webhook <url>", "Webhook URL (Slack or Discord)")
203
+ .option("-k, --api-key <key>", "API key for fetching scan results")
204
+ .option("-p, --platform <platform>", "Force platform: slack, discord, or generic")
205
+ .option("--quiet", "Suppress status messages")
206
+ .option("--dry-run", "Print payload without posting")
207
+ .action(async (scanId, opts) => {
208
+ const webhookUrl = resolveWebhook(opts?.webhook);
209
+ let scanData;
210
+ if (scanId) {
211
+ const key = resolveKey(opts?.apiKey);
212
+ try {
213
+ const { data } = await axios.get(`${API}/static/scan`, {
214
+ params: { scan_id: scanId, format: "json" },
215
+ headers: { "x-api-key": key },
216
+ });
217
+ scanData = data;
218
+ }
219
+ catch (e) {
220
+ if (e.response?.status === 404) {
221
+ console.error(`Scan '${scanId}' not found`);
222
+ process.exit(EXIT_SCAN_NOT_FOUND);
223
+ }
224
+ console.error(`Error: ${e.response?.data ?? e.message}`);
225
+ process.exit(EXIT_GENERAL_ERROR);
226
+ }
227
+ }
228
+ else if (!process.stdin.isTTY) {
229
+ // Read from stdin
230
+ const chunks = [];
231
+ for await (const chunk of process.stdin) {
232
+ chunks.push(chunk);
233
+ }
234
+ const raw = Buffer.concat(chunks).toString("utf-8");
235
+ try {
236
+ scanData = JSON.parse(raw);
237
+ }
238
+ catch {
239
+ console.error("Error: stdin is not valid JSON");
240
+ process.exit(EXIT_GENERAL_ERROR);
241
+ }
242
+ }
243
+ else {
244
+ console.error("Error: provide a scan ID or pipe JSON scan data via stdin");
245
+ process.exit(EXIT_GENERAL_ERROR);
246
+ }
247
+ const detected = opts?.platform || detectPlatform(webhookUrl);
248
+ let payload;
249
+ if (detected === "slack")
250
+ payload = formatSlackPayload(scanData);
251
+ else if (detected === "discord")
252
+ payload = formatDiscordPayload(scanData);
253
+ else
254
+ payload = formatGenericPayload(scanData);
255
+ if (opts?.dryRun) {
256
+ console.log(JSON.stringify(payload, null, 2));
257
+ return;
258
+ }
259
+ if (!opts?.quiet) {
260
+ process.stderr.write(fmt.info(`Posting to ${detected} webhook...`) + "\n");
261
+ }
262
+ try {
263
+ await validateWebhookUrl(webhookUrl);
264
+ await fetch(webhookUrl, {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify(payload),
268
+ });
269
+ }
270
+ catch (e) {
271
+ console.error(`Error posting to webhook: ${e.message}`);
272
+ process.exit(EXIT_GENERAL_ERROR);
273
+ }
274
+ if (!opts?.quiet) {
275
+ process.stderr.write(fmt.success(`Scan results posted to ${detected} channel`) + "\n");
276
+ }
277
+ });
278
+ }
@@ -0,0 +1,274 @@
1
+ import { Command } from "commander";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { createRequire } from "module";
5
+ const _require = createRequire(import.meta.url);
6
+ const { version: CLI_VERSION } = _require("../../package.json");
7
+ export function createReportCommand() {
8
+ return new Command("report")
9
+ .description("Generate a standalone HTML security report from scan results")
10
+ .argument("[input]", "Path to JSON scan results (default: read from stdin)")
11
+ .option("-o, --output <path>", "Output file path (default: stdout)")
12
+ .option("--title <title>", "Report title", "Rafter Security Report")
13
+ .action(async (input, opts) => {
14
+ let jsonData;
15
+ if (input) {
16
+ const resolved = path.resolve(input);
17
+ if (!fs.existsSync(resolved)) {
18
+ console.error(`Error: File not found: ${resolved}`);
19
+ process.exit(2);
20
+ }
21
+ jsonData = fs.readFileSync(resolved, "utf-8");
22
+ }
23
+ else if (!process.stdin.isTTY) {
24
+ jsonData = await readStdin();
25
+ }
26
+ else {
27
+ console.error("Error: No input provided. Pipe scan results or provide a file path.\n" +
28
+ " Example: rafter scan local --json . | rafter report -o report.html\n" +
29
+ " Example: rafter report scan-results.json -o report.html");
30
+ process.exit(2);
31
+ return;
32
+ }
33
+ let results;
34
+ try {
35
+ results = JSON.parse(jsonData);
36
+ if (!Array.isArray(results)) {
37
+ throw new Error("Expected a JSON array of scan results");
38
+ }
39
+ }
40
+ catch (e) {
41
+ console.error(`Error: Invalid JSON input — ${e.message}`);
42
+ process.exit(2);
43
+ return;
44
+ }
45
+ const html = generateHtmlReport(results, opts.title || "Rafter Security Report");
46
+ if (opts.output) {
47
+ const outPath = path.resolve(opts.output);
48
+ fs.writeFileSync(outPath, html, "utf-8");
49
+ console.error(`Report written to ${outPath}`);
50
+ }
51
+ else {
52
+ process.stdout.write(html);
53
+ }
54
+ });
55
+ }
56
+ function readStdin() {
57
+ return new Promise((resolve, reject) => {
58
+ const chunks = [];
59
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
60
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
61
+ process.stdin.on("error", reject);
62
+ });
63
+ }
64
+ function generateHtmlReport(results, title) {
65
+ const now = new Date().toISOString();
66
+ const totalFindings = results.reduce((sum, r) => sum + r.matches.length, 0);
67
+ const filesAffected = results.length;
68
+ const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
69
+ const patternCounts = {};
70
+ for (const r of results) {
71
+ for (const m of r.matches) {
72
+ const sev = m.pattern.severity.toLowerCase();
73
+ if (sev in severityCounts)
74
+ severityCounts[sev]++;
75
+ const name = m.pattern.name;
76
+ patternCounts[name] = (patternCounts[name] || 0) + 1;
77
+ }
78
+ }
79
+ const riskLevel = severityCounts.critical > 0
80
+ ? "Critical"
81
+ : severityCounts.high > 0
82
+ ? "High"
83
+ : severityCounts.medium > 0
84
+ ? "Medium"
85
+ : totalFindings > 0
86
+ ? "Low"
87
+ : "None";
88
+ const riskColor = {
89
+ Critical: "hsl(0 40% 55%)",
90
+ High: "hsl(25 35% 55%)",
91
+ Medium: "hsl(0 0% 64%)",
92
+ Low: "hsl(0 0% 50%)",
93
+ None: "hsl(0 0% 50%)",
94
+ }[riskLevel];
95
+ const topPatterns = Object.entries(patternCounts)
96
+ .sort((a, b) => b[1] - a[1])
97
+ .slice(0, 10);
98
+ const findingsRows = results
99
+ .flatMap((r) => r.matches.map((m) => ({
100
+ file: escapeHtml(r.file),
101
+ line: m.line ?? "—",
102
+ severity: m.pattern.severity,
103
+ pattern: escapeHtml(m.pattern.name),
104
+ description: escapeHtml(m.pattern.description || ""),
105
+ redacted: escapeHtml(m.redacted || ""),
106
+ })))
107
+ .sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
108
+ return `<!DOCTYPE html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="UTF-8">
112
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
113
+ <title>${escapeHtml(title)}</title>
114
+ <style>
115
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
116
+ body { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; line-height: 1.6; color: hsl(0 0% 98%); background: hsl(0 0% 3.9%); }
117
+ .container { max-width: 1100px; margin: 0 auto; padding: 2rem 1.5rem; }
118
+ header { background: hsl(0 0% 7%); color: hsl(0 0% 98%); padding: 2rem 0; margin-bottom: 2rem; border-bottom: 1px solid hsl(0 0% 14.9%); }
119
+ header .container { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; }
120
+ header h1 { font-size: 1.5rem; font-weight: 700; }
121
+ header .meta { font-size: 0.85rem; opacity: 0.6; text-align: right; }
122
+ .card { background: hsl(0 0% 7%); border-radius: 8px; border: 1px solid hsl(0 0% 14.9%); padding: 1.5rem; margin-bottom: 1.5rem; }
123
+ .card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; color: hsl(0 0% 98%); }
124
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
125
+ .stat { text-align: center; padding: 1rem; border-radius: 6px; background: hsl(0 0% 10%); border: 1px solid hsl(0 0% 14.9%); }
126
+ .stat .value { font-size: 2rem; font-weight: 700; color: hsl(0 0% 98%); }
127
+ .stat .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(0 0% 50%); margin-top: 0.25rem; }
128
+ .risk-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 4px; font-weight: 600; font-size: 0.85rem; }
129
+ .sev-critical { background: hsl(0 30% 20%); color: hsl(0 40% 75%); border: 1px solid hsl(0 30% 30%); }
130
+ .sev-high { background: hsl(25 25% 18%); color: hsl(25 35% 70%); border: 1px solid hsl(25 25% 28%); }
131
+ .sev-medium { background: hsl(0 0% 18%); color: hsl(0 0% 70%); border: 1px solid hsl(0 0% 25%); }
132
+ .sev-low { background: hsl(0 0% 14%); color: hsl(0 0% 55%); border: 1px solid hsl(0 0% 22%); }
133
+ .bar-chart { margin-top: 0.5rem; }
134
+ .bar-row { display: flex; align-items: center; margin-bottom: 0.4rem; }
135
+ .bar-label { width: 180px; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: hsl(0 0% 70%); }
136
+ .bar-track { flex: 1; height: 20px; background: hsl(0 0% 14.9%); border-radius: 3px; overflow: hidden; }
137
+ .bar-fill { height: 100%; border-radius: 3px; min-width: 2px; background: hsl(0 0% 98%); opacity: 0.6; }
138
+ .bar-count { width: 40px; text-align: right; font-size: 0.85rem; font-weight: 600; color: hsl(0 0% 64%); margin-left: 0.5rem; }
139
+ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
140
+ th { text-align: left; padding: 0.6rem 0.75rem; background: hsl(0 0% 10%); border-bottom: 2px solid hsl(0 0% 14.9%); font-weight: 600; color: hsl(0 0% 64%); white-space: nowrap; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.75rem; }
141
+ td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(0 0% 14.9%); vertical-align: top; }
142
+ tr:hover td { background: hsl(0 0% 10%); }
143
+ .sev-pill { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
144
+ .file-path { font-size: 0.8rem; word-break: break-all; }
145
+ .redacted { font-size: 0.8rem; color: hsl(0 0% 40%); }
146
+ .description { color: hsl(0 0% 50%); }
147
+ footer { text-align: center; padding: 2rem 0; font-size: 0.8rem; color: hsl(0 0% 35%); border-top: 1px solid hsl(0 0% 14.9%); }
148
+ .no-findings { text-align: center; padding: 3rem; color: hsl(0 0% 64%); }
149
+ .no-findings .icon { font-size: 3rem; margin-bottom: 0.5rem; }
150
+ @media (max-width: 768px) {
151
+ .summary-grid { grid-template-columns: repeat(2, 1fr); }
152
+ .bar-label { width: 120px; }
153
+ table { display: block; overflow-x: auto; }
154
+ }
155
+ @media print {
156
+ body { background: hsl(0 0% 3.9%); color: hsl(0 0% 98%); }
157
+ .card { break-inside: avoid; }
158
+ header { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
159
+ }
160
+ </style>
161
+ </head>
162
+ <body>
163
+ <header>
164
+ <div class="container">
165
+ <h1>${escapeHtml(title)}</h1>
166
+ <div class="meta">
167
+ Generated: ${escapeHtml(formatDate(now))}<br>
168
+ Rafter CLI v${escapeHtml(CLI_VERSION)}
169
+ </div>
170
+ </div>
171
+ </header>
172
+ <main class="container">
173
+ <div class="card">
174
+ <h2>Executive Summary</h2>
175
+ <div class="summary-grid">
176
+ <div class="stat">
177
+ <div class="value">${totalFindings}</div>
178
+ <div class="label">Total Findings</div>
179
+ </div>
180
+ <div class="stat">
181
+ <div class="value">${filesAffected}</div>
182
+ <div class="label">Files Affected</div>
183
+ </div>
184
+ <div class="stat">
185
+ <div class="value"><span class="risk-badge" style="background:${riskColor};color:hsl(0 0% 98%)">${riskLevel}</span></div>
186
+ <div class="label">Overall Risk</div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="card">
192
+ <h2>Severity Breakdown</h2>
193
+ <div class="summary-grid">
194
+ <div class="stat"><div class="value" style="color:hsl(0 40% 70%)">${severityCounts.critical}</div><div class="label">Critical</div></div>
195
+ <div class="stat"><div class="value" style="color:hsl(25 30% 65%)">${severityCounts.high}</div><div class="label">High</div></div>
196
+ <div class="stat"><div class="value">${severityCounts.medium}</div><div class="label">Medium</div></div>
197
+ <div class="stat"><div class="value" style="color:hsl(0 0% 50%)">${severityCounts.low}</div><div class="label">Low</div></div>
198
+ </div>
199
+ </div>
200
+
201
+ ${topPatterns.length > 0 ? ` <div class="card">
202
+ <h2>Top Finding Types</h2>
203
+ <div class="bar-chart">
204
+ ${topPatterns.map(([name, count]) => {
205
+ const pct = Math.round((count / totalFindings) * 100);
206
+ return ` <div class="bar-row">
207
+ <div class="bar-label" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
208
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
209
+ <div class="bar-count">${count}</div>
210
+ </div>`;
211
+ }).join("\n")}
212
+ </div>
213
+ </div>
214
+ ` : ""}
215
+ ${totalFindings > 0 ? ` <div class="card">
216
+ <h2>Detailed Findings</h2>
217
+ <table>
218
+ <thead>
219
+ <tr>
220
+ <th>Severity</th>
221
+ <th>Pattern</th>
222
+ <th>File</th>
223
+ <th>Line</th>
224
+ <th>Redacted</th>
225
+ </tr>
226
+ </thead>
227
+ <tbody>
228
+ ${findingsRows.map((f) => ` <tr>
229
+ <td><span class="sev-pill sev-${f.severity}">${f.severity}</span></td>
230
+ <td>${f.pattern}${f.description ? `<br><small class="description">${f.description}</small>` : ""}</td>
231
+ <td class="file-path">${f.file}</td>
232
+ <td>${f.line}</td>
233
+ <td class="redacted">${f.redacted}</td>
234
+ </tr>`).join("\n")}
235
+ </tbody>
236
+ </table>
237
+ </div>
238
+ ` : ` <div class="card no-findings">
239
+ <div class="icon">&#x2705;</div>
240
+ <h2>No Security Findings</h2>
241
+ <p>No secrets or vulnerabilities were detected in the scanned files.</p>
242
+ </div>
243
+ `}
244
+ </main>
245
+ <footer>
246
+ Generated by Rafter CLI v${escapeHtml(CLI_VERSION)} &mdash; ${escapeHtml(formatDate(now))}
247
+ </footer>
248
+ </body>
249
+ </html>
250
+ `;
251
+ }
252
+ function escapeHtml(text) {
253
+ return text
254
+ .replace(/&/g, "&amp;")
255
+ .replace(/</g, "&lt;")
256
+ .replace(/>/g, "&gt;")
257
+ .replace(/"/g, "&quot;")
258
+ .replace(/'/g, "&#39;");
259
+ }
260
+ function severityRank(severity) {
261
+ const ranks = { critical: 0, high: 1, medium: 2, low: 3 };
262
+ return ranks[severity.toLowerCase()] ?? 4;
263
+ }
264
+ function formatDate(iso) {
265
+ const d = new Date(iso);
266
+ return d.toLocaleDateString("en-US", {
267
+ year: "numeric",
268
+ month: "long",
269
+ day: "numeric",
270
+ hour: "2-digit",
271
+ minute: "2-digit",
272
+ timeZoneName: "short",
273
+ });
274
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * rafter scan — top-level scan command group.
3
3
  *
4
- * Default (no subcommand): remote backend scan (same as `rafter run`)
5
- * rafter scan remote: explicit alias for remote backend scan
4
+ * Default (no subcommand): remote scan (same as `rafter run`)
5
+ * rafter scan remote: explicit alias for remote scan
6
6
  * rafter scan local [path]: local secret scanner (was `rafter agent scan`)
7
7
  */
8
8
  import { Command } from "commander";
@@ -21,25 +21,27 @@ export function createScanGroupCommand() {
21
21
  .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
22
22
  .option("-f, --format <format>", "json | md", "md")
23
23
  .option("-m, --mode <mode>", "scan mode: fast | plus", "fast")
24
+ .option("--github-token <token>", "GitHub PAT for private repos (or RAFTER_GITHUB_TOKEN env var)")
24
25
  .option("--skip-interactive", "do not wait for scan to complete")
25
26
  .option("--quiet", "suppress status messages")
26
27
  .action(async (opts) => {
27
28
  await runRemoteScan(opts);
28
29
  });
29
- // Root scan group — default action is remote backend scan
30
+ // Root scan group — default action is remote scan
30
31
  const scanGroup = new Command("scan")
31
- .description("Scan for security issues. Default: remote backend scan. Use 'scan local' for local secret scanning.")
32
+ .description("Scan for security issues. Default: remote scan. Use 'scan local' for local secret scanning.")
32
33
  .enablePositionalOptions()
33
34
  .option("-r, --repo <repo>", "org/repo (default: current)")
34
35
  .option("-b, --branch <branch>", "branch (default: current else main)")
35
36
  .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
36
37
  .option("-f, --format <format>", "json | md", "md")
37
38
  .option("-m, --mode <mode>", "scan mode: fast | plus", "fast")
39
+ .option("--github-token <token>", "GitHub PAT for private repos (or RAFTER_GITHUB_TOKEN env var)")
38
40
  .option("--skip-interactive", "do not wait for scan to complete")
39
41
  .option("--quiet", "suppress status messages");
40
42
  scanGroup.addCommand(localCmd);
41
43
  scanGroup.addCommand(remoteCmd);
42
- // When invoked with no subcommand, run remote backend scan
44
+ // When invoked with no subcommand, run remote scan
43
45
  scanGroup.action(async (opts) => {
44
46
  await runRemoteScan(opts);
45
47
  });
@@ -2,8 +2,15 @@
2
2
  * Centralized risk assessment rules.
3
3
  * Single source of truth — imported by command-interceptor, audit-logger, and config-defaults.
4
4
  */
5
+ /** Directories where `rm -rf /<dir>` is catastrophic (data loss / unbootable). */
6
+ const CRITICAL_DIRS = "home|etc|usr|boot|root|sys|proc|lib|lib64|bin|sbin|opt";
5
7
  export const CRITICAL_PATTERNS = [
6
- /rm\s+-rf\s+\//,
8
+ // rm -rf / (root only, any flag order)
9
+ new RegExp(`rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(\\s|$)`),
10
+ new RegExp(`rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(\\s|$)`),
11
+ // rm -rf on critical top-level directories
12
+ new RegExp(`rm\\s+(-[a-z]*r[a-z]*\\s+)*-[a-z]*f[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`),
13
+ new RegExp(`rm\\s+(-[a-z]*f[a-z]*\\s+)*-[a-z]*r[a-z]*\\s+/(${CRITICAL_DIRS})(/|\\s|$)`),
7
14
  /:\(\)\{\s*:\|:&\s*\};:/, // fork bomb
8
15
  /dd\s+if=.*of=\/dev\/sd/,
9
16
  />\s*\/dev\/sd/,
@@ -12,7 +19,8 @@ export const CRITICAL_PATTERNS = [
12
19
  /parted/,
13
20
  ];
14
21
  export const HIGH_PATTERNS = [
15
- /rm\s+-rf/,
22
+ /rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*/, // rm -rf, -fr, -r -f, -f -r (any path)
23
+ /rm\s+(-[a-z]*f[a-z]*\s+)*-[a-z]*r[a-z]*/, // rm -fr, reversed
16
24
  /sudo\s+rm/,
17
25
  /chmod\s+777/,
18
26
  /curl.*\|\s*(bash|sh|zsh|dash)\b/,
@@ -53,11 +61,18 @@ export const DEFAULT_REQUIRE_APPROVAL = [
53
61
  "git push --force-if-includes",
54
62
  "git push .* \\+",
55
63
  ];
64
+ /** Read-only commands whose arguments should not trigger risk patterns. */
65
+ const SAFE_PREFIX = /^(grep|egrep|fgrep|rg|ag|ack|echo|printf)\s/;
66
+ /** Shell operators that chain independent commands. */
67
+ const CHAIN_OPERATORS = /[;|&]|&&|\|\|/;
56
68
  /**
57
69
  * Assess risk level of a command string.
58
70
  */
59
71
  export function assessCommandRisk(command) {
60
- const cmd = command.toLowerCase();
72
+ const cmd = command.toLowerCase().trim();
73
+ // Safe prefix only applies to simple (non-chained) commands
74
+ if (SAFE_PREFIX.test(cmd) && !CHAIN_OPERATORS.test(cmd))
75
+ return "low";
61
76
  for (const pattern of CRITICAL_PATTERNS) {
62
77
  if (pattern.test(cmd))
63
78
  return "critical";