@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.
- package/README.md +25 -25
- package/dist/commands/agent/audit-skill.js +20 -19
- package/dist/commands/agent/config.js +2 -1
- package/dist/commands/agent/exec.js +2 -0
- package/dist/commands/agent/index.js +2 -0
- package/dist/commands/agent/init-project.js +164 -0
- package/dist/commands/agent/init.js +276 -20
- package/dist/commands/agent/install-hook.js +15 -14
- package/dist/commands/agent/instruction-block.js +63 -0
- package/dist/commands/agent/scan.js +4 -3
- package/dist/commands/agent/verify.js +1 -1
- package/dist/commands/backend/run.js +12 -3
- package/dist/commands/backend/scan-status.js +3 -2
- package/dist/commands/brief.js +39 -2
- package/dist/commands/ci/init.js +26 -22
- package/dist/commands/completion.js +4 -3
- package/dist/commands/hook/posttool.js +95 -10
- package/dist/commands/hook/pretool.js +105 -10
- package/dist/commands/mcp/server.js +5 -5
- package/dist/commands/notify.js +278 -0
- package/dist/commands/report.js +274 -0
- package/dist/commands/scan/index.js +7 -5
- package/dist/core/risk-rules.js +18 -3
- package/dist/index.js +20 -10
- package/dist/scanners/gitleaks.js +14 -4
- package/dist/scanners/secret-patterns.js +1 -1
- package/package.json +2 -1
- package/resources/pre-commit-hook.sh +0 -5
- package/resources/rafter-security-skill.md +1 -1
- package/resources/skills/rafter/SKILL.md +25 -6
- package/resources/skills/rafter-agent-security/SKILL.md +25 -35
|
@@ -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">✅</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)} — ${escapeHtml(formatDate(now))}
|
|
247
|
+
</footer>
|
|
248
|
+
</body>
|
|
249
|
+
</html>
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
function escapeHtml(text) {
|
|
253
|
+
return text
|
|
254
|
+
.replace(/&/g, "&")
|
|
255
|
+
.replace(/</g, "<")
|
|
256
|
+
.replace(/>/g, ">")
|
|
257
|
+
.replace(/"/g, """)
|
|
258
|
+
.replace(/'/g, "'");
|
|
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
|
|
5
|
-
* rafter scan remote: explicit alias for remote
|
|
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
|
|
30
|
+
// Root scan group — default action is remote scan
|
|
30
31
|
const scanGroup = new Command("scan")
|
|
31
|
-
.description("Scan for security issues. Default: remote
|
|
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
|
|
44
|
+
// When invoked with no subcommand, run remote scan
|
|
43
45
|
scanGroup.action(async (opts) => {
|
|
44
46
|
await runRemoteScan(opts);
|
|
45
47
|
});
|
package/dist/core/risk-rules.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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";
|