@rafter-security/cli 0.5.9 → 0.6.1
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/dist/commands/agent/audit.js +13 -9
- package/dist/commands/agent/scan.js +4 -1
- package/dist/commands/backend/run.js +9 -3
- package/dist/commands/mcp/server.js +4 -1
- package/dist/commands/scan/index.js +1 -0
- package/dist/core/audit-logger.js +106 -7
- package/dist/core/config-manager.js +97 -1
- package/dist/core/custom-patterns.js +20 -17
- package/dist/core/policy-loader.js +25 -2
- package/dist/index.js +4 -1
- package/dist/scanners/gitleaks.js +3 -2
- package/dist/scanners/regex-scanner.js +4 -0
- package/dist/scanners/secret-patterns.js +6 -6
- package/dist/utils/api.js +18 -0
- package/dist/utils/binary-manager.js +67 -1
- package/package.json +4 -3
|
@@ -51,13 +51,17 @@ export function createAuditCommand() {
|
|
|
51
51
|
if (entry.action?.riskLevel) {
|
|
52
52
|
console.log(` Risk: ${entry.action.riskLevel}`);
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
if (entry.securityCheck) {
|
|
55
|
+
console.log(` Check: ${entry.securityCheck.passed ? "PASSED" : "FAILED"}`);
|
|
56
|
+
if (entry.securityCheck.reason) {
|
|
57
|
+
console.log(` Reason: ${entry.securityCheck.reason}`);
|
|
58
|
+
}
|
|
57
59
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
if (entry.resolution) {
|
|
61
|
+
console.log(` Action: ${entry.resolution.actionTaken}`);
|
|
62
|
+
if (entry.resolution.overrideReason) {
|
|
63
|
+
console.log(` Override: ${entry.resolution.overrideReason}`);
|
|
64
|
+
}
|
|
61
65
|
}
|
|
62
66
|
console.log("");
|
|
63
67
|
}
|
|
@@ -130,16 +134,16 @@ export function getRiskLevel(config) {
|
|
|
130
134
|
return config?.agent?.riskLevel ?? "moderate";
|
|
131
135
|
}
|
|
132
136
|
export function formatShareDetail(entry) {
|
|
133
|
-
const action = entry.resolution
|
|
137
|
+
const action = entry.resolution?.actionTaken ?? "unknown";
|
|
134
138
|
const suffix = `[${action}]`;
|
|
135
139
|
if (entry.eventType === "secret_detected") {
|
|
136
|
-
const reason = entry.securityCheck
|
|
140
|
+
const reason = entry.securityCheck?.reason ?? "";
|
|
137
141
|
return `${reason} ${suffix}`;
|
|
138
142
|
}
|
|
139
143
|
if (entry.action?.command) {
|
|
140
144
|
return `${truncateCommand(entry.action.command, 60)} ${suffix}`;
|
|
141
145
|
}
|
|
142
|
-
if (entry.securityCheck
|
|
146
|
+
if (entry.securityCheck?.reason) {
|
|
143
147
|
return `${entry.securityCheck.reason} ${suffix}`;
|
|
144
148
|
}
|
|
145
149
|
return suffix;
|
|
@@ -8,6 +8,9 @@ import fs from "fs";
|
|
|
8
8
|
import os from "os";
|
|
9
9
|
import path from "path";
|
|
10
10
|
import { fmt } from "../../utils/formatter.js";
|
|
11
|
+
import { createRequire } from "module";
|
|
12
|
+
const _require = createRequire(import.meta.url);
|
|
13
|
+
const { version: CLI_VERSION } = _require("../../../package.json");
|
|
11
14
|
function loadBaselineEntries() {
|
|
12
15
|
const baselinePath = path.join(os.homedir(), ".rafter", "baseline.json");
|
|
13
16
|
if (!fs.existsSync(baselinePath))
|
|
@@ -150,7 +153,7 @@ function outputSarif(results) {
|
|
|
150
153
|
tool: {
|
|
151
154
|
driver: {
|
|
152
155
|
name: "rafter",
|
|
153
|
-
version:
|
|
156
|
+
version: CLI_VERSION,
|
|
154
157
|
informationUri: "https://rafter.so",
|
|
155
158
|
rules: Array.from(rules.values()),
|
|
156
159
|
},
|
|
@@ -2,7 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import axios from "axios";
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { detectRepo } from "../../utils/git.js";
|
|
5
|
-
import { API, resolveKey, EXIT_GENERAL_ERROR, EXIT_QUOTA_EXHAUSTED } from "../../utils/api.js";
|
|
5
|
+
import { API, resolveKey, EXIT_GENERAL_ERROR, EXIT_QUOTA_EXHAUSTED, EXIT_INSUFFICIENT_SCOPE, handleScopeError } from "../../utils/api.js";
|
|
6
6
|
import { handleScanStatus } from "./scan-status.js";
|
|
7
7
|
/**
|
|
8
8
|
* Shared handler for the remote backend scan (used by both `rafter run` and `rafter scan` / `rafter scan remote`).
|
|
@@ -34,7 +34,10 @@ export async function runRemoteScan(opts) {
|
|
|
34
34
|
}
|
|
35
35
|
catch (e) {
|
|
36
36
|
spinner.fail("Request failed");
|
|
37
|
-
if (e
|
|
37
|
+
if (handleScopeError(e)) {
|
|
38
|
+
process.exit(EXIT_INSUFFICIENT_SCOPE);
|
|
39
|
+
}
|
|
40
|
+
else if (e.response?.status === 429) {
|
|
38
41
|
console.error("Quota exhausted");
|
|
39
42
|
process.exit(EXIT_QUOTA_EXHAUSTED);
|
|
40
43
|
}
|
|
@@ -59,7 +62,10 @@ export async function runRemoteScan(opts) {
|
|
|
59
62
|
process.exit(exitCode);
|
|
60
63
|
}
|
|
61
64
|
catch (e) {
|
|
62
|
-
if (e
|
|
65
|
+
if (handleScopeError(e)) {
|
|
66
|
+
process.exit(EXIT_INSUFFICIENT_SCOPE);
|
|
67
|
+
}
|
|
68
|
+
else if (e.response?.status === 429) {
|
|
63
69
|
process.exit(EXIT_QUOTA_EXHAUSTED);
|
|
64
70
|
}
|
|
65
71
|
else if (e.response?.data) {
|
|
@@ -7,6 +7,9 @@ import { GitleaksScanner } from "../../scanners/gitleaks.js";
|
|
|
7
7
|
import { CommandInterceptor } from "../../core/command-interceptor.js";
|
|
8
8
|
import { AuditLogger } from "../../core/audit-logger.js";
|
|
9
9
|
import { ConfigManager } from "../../core/config-manager.js";
|
|
10
|
+
import { createRequire } from "module";
|
|
11
|
+
const _require = createRequire(import.meta.url);
|
|
12
|
+
const { version: CLI_VERSION } = _require("../../../package.json");
|
|
10
13
|
function formatScanResults(results) {
|
|
11
14
|
return results.map(r => ({
|
|
12
15
|
file: r.file,
|
|
@@ -25,7 +28,7 @@ function errorResult(message) {
|
|
|
25
28
|
return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
|
|
26
29
|
}
|
|
27
30
|
function createServer() {
|
|
28
|
-
const server = new Server({ name: "rafter", version:
|
|
31
|
+
const server = new Server({ name: "rafter", version: CLI_VERSION }, { capabilities: { tools: {}, resources: {} } });
|
|
29
32
|
// ── Tools ───────────────────────────────────────────────────────────
|
|
30
33
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
31
34
|
tools: [
|
|
@@ -28,6 +28,7 @@ export function createScanGroupCommand() {
|
|
|
28
28
|
// Root scan group — default action is remote backend scan
|
|
29
29
|
const scanGroup = new Command("scan")
|
|
30
30
|
.description("Scan for security issues. Default: remote backend scan. Use 'scan local' for local secret scanning.")
|
|
31
|
+
.enablePositionalOptions()
|
|
31
32
|
.option("-r, --repo <repo>", "org/repo (default: current)")
|
|
32
33
|
.option("-b, --branch <branch>", "branch (default: current else main)")
|
|
33
34
|
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
@@ -1,8 +1,104 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import dns from "dns/promises";
|
|
1
3
|
import fs from "fs";
|
|
4
|
+
import net from "net";
|
|
2
5
|
import path from "path";
|
|
3
6
|
import { getAuditLogPath } from "./config-defaults.js";
|
|
4
7
|
import { ConfigManager } from "./config-manager.js";
|
|
5
8
|
import { assessCommandRisk } from "./risk-rules.js";
|
|
9
|
+
/**
|
|
10
|
+
* Validate a webhook URL to prevent SSRF attacks.
|
|
11
|
+
* Rejects non-HTTP(S) schemes and URLs that resolve to private/internal IPs.
|
|
12
|
+
*/
|
|
13
|
+
export async function validateWebhookUrl(rawUrl) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(rawUrl);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error(`Invalid webhook URL: ${rawUrl}`);
|
|
20
|
+
}
|
|
21
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
22
|
+
throw new Error(`Webhook URL must use http or https, got ${parsed.protocol}`);
|
|
23
|
+
}
|
|
24
|
+
// URL.hostname keeps brackets for IPv6 (e.g. "[::1]") — strip them
|
|
25
|
+
let hostname = parsed.hostname;
|
|
26
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
27
|
+
hostname = hostname.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
// If the hostname is already an IP, check it directly
|
|
30
|
+
if (net.isIP(hostname)) {
|
|
31
|
+
if (isPrivateIp(hostname)) {
|
|
32
|
+
throw new Error(`Webhook URL must not point to a private/internal address: ${hostname}`);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Resolve hostname and check all resulting IPs
|
|
37
|
+
let addresses;
|
|
38
|
+
try {
|
|
39
|
+
const results = await dns.resolve(hostname);
|
|
40
|
+
addresses = results;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Could not resolve webhook hostname: ${hostname}`);
|
|
44
|
+
}
|
|
45
|
+
for (const addr of addresses) {
|
|
46
|
+
if (isPrivateIp(addr)) {
|
|
47
|
+
throw new Error(`Webhook URL must not point to a private/internal address: ${hostname} resolved to ${addr}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Check if an IP address belongs to a private, loopback, link-local, or
|
|
53
|
+
* cloud-metadata range.
|
|
54
|
+
*/
|
|
55
|
+
function isPrivateIp(ip) {
|
|
56
|
+
// IPv4 checks
|
|
57
|
+
if (net.isIPv4(ip)) {
|
|
58
|
+
const parts = ip.split(".").map(Number);
|
|
59
|
+
const [a, b] = parts;
|
|
60
|
+
// 127.0.0.0/8 — loopback
|
|
61
|
+
if (a === 127)
|
|
62
|
+
return true;
|
|
63
|
+
// 10.0.0.0/8 — private
|
|
64
|
+
if (a === 10)
|
|
65
|
+
return true;
|
|
66
|
+
// 172.16.0.0/12 — private
|
|
67
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
68
|
+
return true;
|
|
69
|
+
// 192.168.0.0/16 — private
|
|
70
|
+
if (a === 192 && b === 168)
|
|
71
|
+
return true;
|
|
72
|
+
// 169.254.0.0/16 — link-local / cloud metadata
|
|
73
|
+
if (a === 169 && b === 254)
|
|
74
|
+
return true;
|
|
75
|
+
// 0.0.0.0
|
|
76
|
+
if (a === 0)
|
|
77
|
+
return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// IPv6 checks
|
|
81
|
+
const lower = ip.toLowerCase();
|
|
82
|
+
// ::1 — loopback
|
|
83
|
+
if (lower === "::1")
|
|
84
|
+
return true;
|
|
85
|
+
// :: — unspecified
|
|
86
|
+
if (lower === "::")
|
|
87
|
+
return true;
|
|
88
|
+
// fe80::/10 — link-local
|
|
89
|
+
if (lower.startsWith("fe80:"))
|
|
90
|
+
return true;
|
|
91
|
+
// fc00::/7 — unique local (ULA)
|
|
92
|
+
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
|
93
|
+
return true;
|
|
94
|
+
// ::ffff:127.0.0.1 etc — IPv4-mapped IPv6
|
|
95
|
+
if (lower.startsWith("::ffff:")) {
|
|
96
|
+
const mapped = lower.slice(7);
|
|
97
|
+
if (net.isIPv4(mapped))
|
|
98
|
+
return isPrivateIp(mapped);
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
6
102
|
export const RISK_SEVERITY = {
|
|
7
103
|
low: 0,
|
|
8
104
|
medium: 1,
|
|
@@ -17,7 +113,7 @@ export class AuditLogger {
|
|
|
17
113
|
// Ensure log directory exists
|
|
18
114
|
const dir = path.dirname(this.logPath);
|
|
19
115
|
if (!fs.existsSync(dir)) {
|
|
20
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
117
|
}
|
|
22
118
|
}
|
|
23
119
|
/**
|
|
@@ -36,7 +132,7 @@ export class AuditLogger {
|
|
|
36
132
|
};
|
|
37
133
|
// Append to log file
|
|
38
134
|
const line = JSON.stringify(fullEntry) + "\n";
|
|
39
|
-
fs.appendFileSync(this.logPath, line, "utf-8");
|
|
135
|
+
fs.appendFileSync(this.logPath, line, { encoding: "utf-8", mode: 0o600 });
|
|
40
136
|
// Send webhook notification if configured and risk meets threshold
|
|
41
137
|
this.sendNotification(fullEntry, config);
|
|
42
138
|
}
|
|
@@ -64,13 +160,16 @@ export class AuditLogger {
|
|
|
64
160
|
content: `[rafter] ${eventRisk}-risk event: ${entry.eventType}${entry.action?.command ? ` — ${entry.action.command}` : ""}`,
|
|
65
161
|
};
|
|
66
162
|
// Fire-and-forget POST — never block audit logging
|
|
67
|
-
|
|
163
|
+
// Validate URL to prevent SSRF before making the request
|
|
164
|
+
validateWebhookUrl(webhookUrl)
|
|
165
|
+
.then(() => fetch(webhookUrl, {
|
|
68
166
|
method: "POST",
|
|
69
167
|
headers: { "Content-Type": "application/json" },
|
|
70
168
|
body: JSON.stringify(payload),
|
|
71
169
|
signal: AbortSignal.timeout(5000),
|
|
72
|
-
})
|
|
73
|
-
|
|
170
|
+
}))
|
|
171
|
+
.catch(() => {
|
|
172
|
+
// Silently ignore webhook failures (including validation rejections)
|
|
74
173
|
});
|
|
75
174
|
}
|
|
76
175
|
/**
|
|
@@ -196,13 +295,13 @@ export class AuditLogger {
|
|
|
196
295
|
const filtered = entries.filter(e => new Date(e.timestamp) >= cutoffDate);
|
|
197
296
|
// Rewrite log file with only retained entries
|
|
198
297
|
const content = filtered.map(e => JSON.stringify(e)).join("\n") + "\n";
|
|
199
|
-
fs.writeFileSync(this.logPath, content, "utf-8");
|
|
298
|
+
fs.writeFileSync(this.logPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
200
299
|
}
|
|
201
300
|
/**
|
|
202
301
|
* Generate a unique session ID
|
|
203
302
|
*/
|
|
204
303
|
generateSessionId() {
|
|
205
|
-
return `${Date.now()}-${
|
|
304
|
+
return `${Date.now()}-${randomBytes(8).toString("hex")}`;
|
|
206
305
|
}
|
|
207
306
|
/**
|
|
208
307
|
* Assess risk level of a command
|
|
@@ -2,6 +2,101 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { getDefaultConfig, getConfigPath, getRafterDir, CONFIG_VERSION } from "./config-defaults.js";
|
|
4
4
|
import { loadPolicy } from "./policy-loader.js";
|
|
5
|
+
const VALID_RISK_LEVELS = new Set(["minimal", "moderate", "aggressive"]);
|
|
6
|
+
const VALID_COMMAND_MODES = new Set(["allow-all", "approve-dangerous", "deny-list"]);
|
|
7
|
+
const VALID_LOG_LEVELS = new Set(["debug", "info", "warn", "error"]);
|
|
8
|
+
/**
|
|
9
|
+
* Validate a parsed config JSON object, warning and falling back to defaults for invalid fields.
|
|
10
|
+
*/
|
|
11
|
+
function validateConfig(raw) {
|
|
12
|
+
if (!raw || typeof raw !== "object") {
|
|
13
|
+
console.error("Warning: config file is not a JSON object — using defaults.");
|
|
14
|
+
return getDefaultConfig();
|
|
15
|
+
}
|
|
16
|
+
const defaults = getDefaultConfig();
|
|
17
|
+
// Top-level scalars
|
|
18
|
+
if (raw.version !== undefined && typeof raw.version !== "string") {
|
|
19
|
+
console.error('Warning: config "version" must be a string — using default.');
|
|
20
|
+
raw.version = defaults.version;
|
|
21
|
+
}
|
|
22
|
+
if (raw.initialized !== undefined && typeof raw.initialized !== "string") {
|
|
23
|
+
console.error('Warning: config "initialized" must be a string — using default.');
|
|
24
|
+
raw.initialized = defaults.initialized;
|
|
25
|
+
}
|
|
26
|
+
const agent = raw.agent;
|
|
27
|
+
if (agent && typeof agent === "object") {
|
|
28
|
+
// riskLevel
|
|
29
|
+
if (agent.riskLevel !== undefined && !VALID_RISK_LEVELS.has(agent.riskLevel)) {
|
|
30
|
+
console.error(`Warning: config "agent.riskLevel" must be one of: minimal, moderate, aggressive — using default.`);
|
|
31
|
+
agent.riskLevel = defaults.agent.riskLevel;
|
|
32
|
+
}
|
|
33
|
+
// commandPolicy
|
|
34
|
+
const cp = agent.commandPolicy;
|
|
35
|
+
if (cp && typeof cp === "object") {
|
|
36
|
+
if (cp.mode !== undefined && !VALID_COMMAND_MODES.has(cp.mode)) {
|
|
37
|
+
console.error(`Warning: config "agent.commandPolicy.mode" must be one of: allow-all, approve-dangerous, deny-list — using default.`);
|
|
38
|
+
cp.mode = defaults.agent.commandPolicy.mode;
|
|
39
|
+
}
|
|
40
|
+
if (cp.blockedPatterns !== undefined && (!Array.isArray(cp.blockedPatterns) || !cp.blockedPatterns.every((v) => typeof v === "string"))) {
|
|
41
|
+
console.error('Warning: config "agent.commandPolicy.blockedPatterns" must be an array of strings — using default.');
|
|
42
|
+
cp.blockedPatterns = [...defaults.agent.commandPolicy.blockedPatterns];
|
|
43
|
+
}
|
|
44
|
+
if (cp.requireApproval !== undefined && (!Array.isArray(cp.requireApproval) || !cp.requireApproval.every((v) => typeof v === "string"))) {
|
|
45
|
+
console.error('Warning: config "agent.commandPolicy.requireApproval" must be an array of strings — using default.');
|
|
46
|
+
cp.requireApproval = [...defaults.agent.commandPolicy.requireApproval];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// audit
|
|
50
|
+
const audit = agent.audit;
|
|
51
|
+
if (audit && typeof audit === "object") {
|
|
52
|
+
if (audit.retentionDays !== undefined && (typeof audit.retentionDays !== "number" || isNaN(audit.retentionDays))) {
|
|
53
|
+
console.error('Warning: config "agent.audit.retentionDays" must be a number — using default.');
|
|
54
|
+
audit.retentionDays = defaults.agent.audit.retentionDays;
|
|
55
|
+
}
|
|
56
|
+
if (audit.logLevel !== undefined && !VALID_LOG_LEVELS.has(audit.logLevel)) {
|
|
57
|
+
console.error(`Warning: config "agent.audit.logLevel" must be one of: debug, info, warn, error — using default.`);
|
|
58
|
+
audit.logLevel = defaults.agent.audit.logLevel;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// outputFiltering
|
|
62
|
+
const of = agent.outputFiltering;
|
|
63
|
+
if (of && typeof of === "object") {
|
|
64
|
+
if (of.redactSecrets !== undefined && typeof of.redactSecrets !== "boolean") {
|
|
65
|
+
console.error('Warning: config "agent.outputFiltering.redactSecrets" must be a boolean — using default.');
|
|
66
|
+
of.redactSecrets = defaults.agent.outputFiltering.redactSecrets;
|
|
67
|
+
}
|
|
68
|
+
if (of.blockPatterns !== undefined && typeof of.blockPatterns !== "boolean") {
|
|
69
|
+
console.error('Warning: config "agent.outputFiltering.blockPatterns" must be a boolean — using default.');
|
|
70
|
+
of.blockPatterns = defaults.agent.outputFiltering.blockPatterns;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// scan.customPatterns — validate regex compilation
|
|
74
|
+
const scan = agent.scan;
|
|
75
|
+
if (scan && typeof scan === "object") {
|
|
76
|
+
if (scan.excludePaths !== undefined && (!Array.isArray(scan.excludePaths) || !scan.excludePaths.every((v) => typeof v === "string"))) {
|
|
77
|
+
console.error('Warning: config "agent.scan.excludePaths" must be an array of strings — using default.');
|
|
78
|
+
delete scan.excludePaths;
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(scan.customPatterns)) {
|
|
81
|
+
scan.customPatterns = scan.customPatterns.filter((p) => {
|
|
82
|
+
if (!p || typeof p !== "object" || typeof p.name !== "string" || !p.name || typeof p.regex !== "string" || !p.regex) {
|
|
83
|
+
console.error(`Warning: skipping malformed scan.customPatterns entry — must have name and regex.`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
new RegExp(p.regex);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
console.error(`Warning: skipping custom pattern "${p.name}" — invalid regex.`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
5
100
|
export class ConfigManager {
|
|
6
101
|
constructor(configPath) {
|
|
7
102
|
this.configPath = configPath || getConfigPath();
|
|
@@ -15,7 +110,8 @@ export class ConfigManager {
|
|
|
15
110
|
}
|
|
16
111
|
try {
|
|
17
112
|
const content = fs.readFileSync(this.configPath, "utf-8");
|
|
18
|
-
const
|
|
113
|
+
const parsed = JSON.parse(content);
|
|
114
|
+
const config = validateConfig(parsed);
|
|
19
115
|
// Migrate config if needed
|
|
20
116
|
return this.migrate(config);
|
|
21
117
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import path from "path";
|
|
7
|
+
import { minimatch } from "minimatch";
|
|
7
8
|
import { getRafterDir } from "./config-defaults.js";
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Custom pattern loading
|
|
@@ -69,12 +70,24 @@ function loadJsonPatterns(file) {
|
|
|
69
70
|
return [];
|
|
70
71
|
const patterns = [];
|
|
71
72
|
for (const entry of data) {
|
|
72
|
-
if (typeof entry.pattern !== "string")
|
|
73
|
+
if (typeof entry.pattern !== "string" || !entry.pattern)
|
|
73
74
|
continue;
|
|
75
|
+
try {
|
|
76
|
+
new RegExp(entry.pattern);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
console.error(`Warning: skipping custom pattern in ${path.basename(file)} — invalid regex: ${entry.pattern}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const severity = entry.severity ?? "high";
|
|
83
|
+
if (!["low", "medium", "high", "critical"].includes(severity)) {
|
|
84
|
+
console.error(`Warning: skipping custom pattern in ${path.basename(file)} — invalid severity: ${severity}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
74
87
|
patterns.push({
|
|
75
88
|
name: entry.name ?? `Custom (${path.basename(file, ".json")})`,
|
|
76
89
|
regex: entry.pattern,
|
|
77
|
-
severity:
|
|
90
|
+
severity: severity,
|
|
78
91
|
description: entry.description,
|
|
79
92
|
});
|
|
80
93
|
}
|
|
@@ -135,23 +148,13 @@ export function isSuppressed(filePath, patternName, suppressions) {
|
|
|
135
148
|
return false;
|
|
136
149
|
}
|
|
137
150
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
151
|
+
* Match a file path against a glob pattern using minimatch.
|
|
152
|
+
*
|
|
153
|
+
* Uses `matchBase` so bare patterns like "*.env" match against the basename
|
|
154
|
+
* (e.g. "config/.env"), and `dot` so dotfiles are included.
|
|
140
155
|
*/
|
|
141
156
|
function matchGlob(glob, filePath) {
|
|
142
|
-
// Normalise separators
|
|
143
157
|
const g = glob.replace(/\\/g, "/");
|
|
144
158
|
const f = filePath.replace(/\\/g, "/");
|
|
145
|
-
|
|
146
|
-
const escaped = g
|
|
147
|
-
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
148
|
-
.replace(/\*\*/g, "\x00") // placeholder for **
|
|
149
|
-
.replace(/\*/g, "[^/]*") // * = anything within one segment
|
|
150
|
-
.replace(/\x00/g, ".*"); // ** = anything including /
|
|
151
|
-
try {
|
|
152
|
-
return new RegExp(`(^|/)${escaped}(/|$)`).test(f);
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
159
|
+
return minimatch(f, g, { dot: true, matchBase: true });
|
|
157
160
|
}
|
|
@@ -136,10 +136,33 @@ function validatePolicy(policy, raw) {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
if (policy.scan.customPatterns !== undefined) {
|
|
139
|
-
if (!Array.isArray(policy.scan.customPatterns)
|
|
140
|
-
console.error(`Warning: "scan.custom_patterns" must be an array
|
|
139
|
+
if (!Array.isArray(policy.scan.customPatterns)) {
|
|
140
|
+
console.error(`Warning: "scan.custom_patterns" must be an array — ignoring.`);
|
|
141
141
|
delete policy.scan.customPatterns;
|
|
142
142
|
}
|
|
143
|
+
else {
|
|
144
|
+
const valid = [];
|
|
145
|
+
for (const v of policy.scan.customPatterns) {
|
|
146
|
+
if (!v || typeof v !== "object" || typeof v.name !== "string" || !v.name || typeof v.regex !== "string" || !v.regex || typeof v.severity !== "string") {
|
|
147
|
+
console.error(`Warning: skipping malformed custom_patterns entry — must have name, regex, severity.`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
new RegExp(v.regex);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
console.error(`Warning: skipping custom pattern "${v.name}" — invalid regex.`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
valid.push(v);
|
|
158
|
+
}
|
|
159
|
+
if (valid.length > 0) {
|
|
160
|
+
policy.scan.customPatterns = valid;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
delete policy.scan.customPatterns;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
143
166
|
}
|
|
144
167
|
}
|
|
145
168
|
if (policy.audit) {
|
package/dist/index.js
CHANGED
|
@@ -14,12 +14,15 @@ import { createCompletionCommand } from "./commands/completion.js";
|
|
|
14
14
|
import { createIssuesCommand } from "./commands/issues/index.js";
|
|
15
15
|
import { checkForUpdate } from "./utils/update-checker.js";
|
|
16
16
|
import { setAgentMode } from "./utils/formatter.js";
|
|
17
|
+
import { createRequire } from "module";
|
|
17
18
|
dotenv.config();
|
|
18
|
-
const
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { version: VERSION } = require("../package.json");
|
|
19
21
|
const program = new Command()
|
|
20
22
|
.name("rafter")
|
|
21
23
|
.description("Rafter CLI")
|
|
22
24
|
.version(VERSION)
|
|
25
|
+
.enablePositionalOptions()
|
|
23
26
|
.option("-a, --agent", "Plain output for AI agents (no colors/emoji)");
|
|
24
27
|
// Set agent mode before any subcommand runs
|
|
25
28
|
program.hook("preAction", (thisCommand) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "child_process";
|
|
2
2
|
import { promisify } from "util";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
3
4
|
import { BinaryManager } from "../utils/binary-manager.js";
|
|
4
5
|
import fs from "fs";
|
|
5
6
|
import os from "os";
|
|
@@ -26,7 +27,7 @@ export class GitleaksScanner {
|
|
|
26
27
|
throw new Error("Gitleaks not available");
|
|
27
28
|
}
|
|
28
29
|
const gitleaksPath = this.binaryManager.getGitleaksPath();
|
|
29
|
-
const tmpReport = path.join(os.tmpdir(), `gitleaks-${Date.now()}-${
|
|
30
|
+
const tmpReport = path.join(os.tmpdir(), `gitleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`);
|
|
30
31
|
try {
|
|
31
32
|
// Run gitleaks detect on file
|
|
32
33
|
await execFileAsync(gitleaksPath, ["detect", "--no-git", "-f", "json", "-r", tmpReport, "-s", filePath], { timeout: 30000 });
|
|
@@ -86,7 +87,7 @@ export class GitleaksScanner {
|
|
|
86
87
|
throw new Error("Gitleaks not available");
|
|
87
88
|
}
|
|
88
89
|
const gitleaksPath = this.binaryManager.getGitleaksPath();
|
|
89
|
-
const tmpReport = path.join(os.tmpdir(), `gitleaks-${Date.now()}-${
|
|
90
|
+
const tmpReport = path.join(os.tmpdir(), `gitleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`);
|
|
90
91
|
try {
|
|
91
92
|
// Run gitleaks detect on directory
|
|
92
93
|
await execFileAsync(gitleaksPath, ["detect", "--no-git", "-f", "json", "-r", tmpReport, "-s", dirPath], { timeout: 60000 });
|
|
@@ -112,6 +112,10 @@ export class RegexScanner {
|
|
|
112
112
|
try {
|
|
113
113
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
114
114
|
for (const entry of entries) {
|
|
115
|
+
// Skip symlinks to prevent traversal outside intended scope
|
|
116
|
+
if (entry.isSymbolicLink()) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
115
119
|
const fullPath = path.join(dir, entry.name);
|
|
116
120
|
// Skip excluded directories
|
|
117
121
|
if (exclude.includes(entry.name)) {
|
|
@@ -90,13 +90,13 @@ export const DEFAULT_SECRET_PATTERNS = [
|
|
|
90
90
|
// Generic patterns
|
|
91
91
|
{
|
|
92
92
|
name: "Generic API Key",
|
|
93
|
-
regex: "(?i)(?<![a-zA-Z0-9_])(api[_-]?key|apikey)[\\s]*[:=][\\s]*['\"](?=[0-9a-zA-Z\\-_]*[0-9])[0-9a-zA-Z\\-_]{16,}['\"]",
|
|
93
|
+
regex: "(?i)(?<![a-zA-Z0-9_])(api[_-]?key|apikey)[\\s]*[:=][\\s]*['\"](?=[0-9a-zA-Z\\-_]*[0-9])[0-9a-zA-Z\\-_]{16,256}['\"]",
|
|
94
94
|
severity: "high",
|
|
95
95
|
description: "Generic API key pattern detected"
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
name: "Generic Secret",
|
|
99
|
-
regex: "(?i)(?<![a-zA-Z0-9_])(secret|password|passwd|pwd)[\\s]*[:=][\\s]*['\"](?=[^\\s'\"]*[0-9])(?=[^\\s'\"]*[a-zA-Z])[0-9a-zA-Z\\-_!@#$%^&*()]{12,}['\"]",
|
|
99
|
+
regex: "(?i)(?<![a-zA-Z0-9_])(secret|password|passwd|pwd)[\\s]*[:=][\\s]*['\"](?=[^\\s'\"]*[0-9])(?=[^\\s'\"]*[a-zA-Z])[0-9a-zA-Z\\-_!@#$%^&*()]{12,256}['\"]",
|
|
100
100
|
severity: "high",
|
|
101
101
|
description: "Generic secret pattern detected"
|
|
102
102
|
},
|
|
@@ -108,21 +108,21 @@ export const DEFAULT_SECRET_PATTERNS = [
|
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
name: "Bearer Token",
|
|
111
|
-
regex: "(?i)bearer[\\s]+(?=[a-zA-Z0-9\\-_\\.=]*[0-9])(?=[a-zA-Z0-9\\-_\\.=]*[a-zA-Z])[a-zA-Z0-9\\-_\\.=]{20,}",
|
|
111
|
+
regex: "(?i)bearer[\\s]+(?=[a-zA-Z0-9\\-_\\.=]*[0-9])(?=[a-zA-Z0-9\\-_\\.=]*[a-zA-Z])[a-zA-Z0-9\\-_\\.=]{20,512}",
|
|
112
112
|
severity: "high",
|
|
113
113
|
description: "Bearer token detected"
|
|
114
114
|
},
|
|
115
115
|
// Database connection strings
|
|
116
116
|
{
|
|
117
117
|
name: "Database Connection String",
|
|
118
|
-
regex: "(?i)(postgres|mysql|mongodb)://[^\\s]+:[^\\s]+@[^\\s]+",
|
|
118
|
+
regex: "(?i)(postgres|mysql|mongodb)://[^\\s:@]+:[^\\s@]+@[^\\s]+",
|
|
119
119
|
severity: "critical",
|
|
120
120
|
description: "Database connection string with credentials detected"
|
|
121
121
|
},
|
|
122
122
|
// JWT
|
|
123
123
|
{
|
|
124
124
|
name: "JSON Web Token",
|
|
125
|
-
regex: "eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}",
|
|
125
|
+
regex: "eyJ[A-Za-z0-9_-]{10,2048}\\.[A-Za-z0-9_-]{10,2048}\\.[A-Za-z0-9_-]{10,2048}",
|
|
126
126
|
severity: "high",
|
|
127
127
|
description: "JWT token detected"
|
|
128
128
|
},
|
|
@@ -136,7 +136,7 @@ export const DEFAULT_SECRET_PATTERNS = [
|
|
|
136
136
|
// PyPI token
|
|
137
137
|
{
|
|
138
138
|
name: "PyPI Token",
|
|
139
|
-
regex: "pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\\-_]{50,}",
|
|
139
|
+
regex: "pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\\-_]{50,1024}",
|
|
140
140
|
severity: "critical",
|
|
141
141
|
description: "PyPI API token detected"
|
|
142
142
|
}
|
package/dist/utils/api.js
CHANGED
|
@@ -4,6 +4,24 @@ export const EXIT_SUCCESS = 0;
|
|
|
4
4
|
export const EXIT_GENERAL_ERROR = 1;
|
|
5
5
|
export const EXIT_SCAN_NOT_FOUND = 2;
|
|
6
6
|
export const EXIT_QUOTA_EXHAUSTED = 3;
|
|
7
|
+
export const EXIT_INSUFFICIENT_SCOPE = 4;
|
|
8
|
+
/**
|
|
9
|
+
* Detect a 403 scope-enforcement error from the API and print a helpful message.
|
|
10
|
+
* Returns true if the error was a scope error (caller should exit), false otherwise.
|
|
11
|
+
*/
|
|
12
|
+
export function handleScopeError(e) {
|
|
13
|
+
if (!e || e.response?.status !== 403)
|
|
14
|
+
return false;
|
|
15
|
+
const body = e.response?.data;
|
|
16
|
+
const msg = typeof body === "string" ? body : body?.error ?? "";
|
|
17
|
+
if (msg.includes("scope")) {
|
|
18
|
+
console.error('Error: This API key only has read access.\nTo trigger scans, create a key with "Read & Scan" scope at https://rfrr.co/account');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.error(`Error: Forbidden (403) — ${msg || "access denied"}`);
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
7
25
|
export function resolveKey(cliKey) {
|
|
8
26
|
if (cliKey)
|
|
9
27
|
return cliKey;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
|
+
import crypto from "crypto";
|
|
4
5
|
import https from "https";
|
|
5
6
|
import { exec, execSync } from "child_process";
|
|
6
7
|
import { promisify } from "util";
|
|
@@ -173,6 +174,10 @@ export class BinaryManager {
|
|
|
173
174
|
// Log downloaded file size as basic integrity signal
|
|
174
175
|
const stats = fs.statSync(archivePath);
|
|
175
176
|
log(` Downloaded: ${(stats.size / 1024).toFixed(1)} KB`);
|
|
177
|
+
// Verify SHA256 checksum against official checksums file
|
|
178
|
+
log("Verifying checksum...");
|
|
179
|
+
await this.verifyChecksum(archivePath, platform, arch, version, log);
|
|
180
|
+
log(" ✓ Checksum verified");
|
|
176
181
|
// Extract binary
|
|
177
182
|
log("Extracting binary...");
|
|
178
183
|
if (platform === "windows") {
|
|
@@ -322,6 +327,64 @@ export class BinaryManager {
|
|
|
322
327
|
});
|
|
323
328
|
});
|
|
324
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Verify downloaded archive checksum against official gitleaks checksums file.
|
|
332
|
+
*/
|
|
333
|
+
async verifyChecksum(archivePath, platform, arch, version, onProgress) {
|
|
334
|
+
const checksumsUrl = `https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_checksums.txt`;
|
|
335
|
+
const checksumsPath = path.join(this.binDir, "checksums.txt");
|
|
336
|
+
try {
|
|
337
|
+
await this.downloadFile(checksumsUrl, checksumsPath, () => { });
|
|
338
|
+
const checksumsContent = fs.readFileSync(checksumsPath, "utf-8");
|
|
339
|
+
const archiveFilename = platform === "windows"
|
|
340
|
+
? `gitleaks_${version}_windows_${arch}.zip`
|
|
341
|
+
: `gitleaks_${version}_${platform}_${arch}.tar.gz`;
|
|
342
|
+
const expectedHash = this.parseChecksumFile(checksumsContent, archiveFilename);
|
|
343
|
+
if (!expectedHash) {
|
|
344
|
+
throw new Error(`Checksum not found for ${archiveFilename} in checksums file`);
|
|
345
|
+
}
|
|
346
|
+
const actualHash = await this.computeSHA256(archivePath);
|
|
347
|
+
if (actualHash !== expectedHash) {
|
|
348
|
+
throw new Error(`Checksum mismatch for ${archiveFilename}:\n` +
|
|
349
|
+
` Expected: ${expectedHash}\n` +
|
|
350
|
+
` Actual: ${actualHash}\n` +
|
|
351
|
+
`The downloaded file may be corrupted or tampered with.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
if (fs.existsSync(checksumsPath)) {
|
|
356
|
+
fs.unlinkSync(checksumsPath);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Parse a checksums.txt file and return the SHA256 hash for the given filename.
|
|
362
|
+
*/
|
|
363
|
+
parseChecksumFile(content, filename) {
|
|
364
|
+
for (const line of content.split("\n")) {
|
|
365
|
+
const trimmed = line.trim();
|
|
366
|
+
if (!trimmed)
|
|
367
|
+
continue;
|
|
368
|
+
// Format: "<sha256> <filename>" (two spaces between hash and filename)
|
|
369
|
+
const parts = trimmed.split(/\s+/);
|
|
370
|
+
if (parts.length >= 2 && parts[1] === filename) {
|
|
371
|
+
return parts[0].toLowerCase();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Compute SHA256 hash of a file.
|
|
378
|
+
*/
|
|
379
|
+
computeSHA256(filePath) {
|
|
380
|
+
return new Promise((resolve, reject) => {
|
|
381
|
+
const hash = crypto.createHash("sha256");
|
|
382
|
+
const stream = fs.createReadStream(filePath);
|
|
383
|
+
stream.on("data", (data) => hash.update(data));
|
|
384
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
385
|
+
stream.on("error", reject);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
325
388
|
/**
|
|
326
389
|
* Extract zip (Windows) — uses PowerShell's Expand-Archive, then copies
|
|
327
390
|
* only the gitleaks.exe binary to binDir. Cleans up the temp extract dir.
|
|
@@ -330,7 +393,10 @@ export class BinaryManager {
|
|
|
330
393
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-gitleaks-"));
|
|
331
394
|
try {
|
|
332
395
|
// PowerShell 5+ ships on all supported Windows versions
|
|
333
|
-
|
|
396
|
+
// Escape single quotes to prevent shell injection ('' is the PS escape for ')
|
|
397
|
+
const safeZipPath = zipPath.replace(/'/g, "''");
|
|
398
|
+
const safeTempDir = tempDir.replace(/'/g, "''");
|
|
399
|
+
await execAsync(`powershell -NoProfile -Command "Expand-Archive -Force -LiteralPath '${safeZipPath}' -DestinationPath '${safeTempDir}'"`, { timeout: 30000 });
|
|
334
400
|
// Find gitleaks.exe — may be at root or inside a subdirectory
|
|
335
401
|
const findBinary = (dir) => {
|
|
336
402
|
for (const entry of fs.readdirSync(dir)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rafter-security/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"rafter": "./dist/index.js"
|
|
@@ -20,14 +20,15 @@
|
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
23
|
-
"axios": "^1.
|
|
23
|
+
"axios": "^1.13.5",
|
|
24
24
|
"chalk": "^5.3.0",
|
|
25
25
|
"chokidar": "^5.0.0",
|
|
26
26
|
"commander": "^11.1.0",
|
|
27
27
|
"dotenv": "^16.4.5",
|
|
28
28
|
"js-yaml": "^4.1.0",
|
|
29
|
+
"minimatch": "^10.2.4",
|
|
29
30
|
"ora": "^7.0.1",
|
|
30
|
-
"tar": "^7.5.
|
|
31
|
+
"tar": "^7.5.10"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@types/js-yaml": "^4.0.9",
|