@rafter-security/cli 0.5.9 → 0.6.3
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/init.js +4 -4
- package/dist/commands/agent/scan.js +18 -8
- package/dist/commands/backend/run.js +12 -5
- package/dist/commands/mcp/server.js +4 -1
- package/dist/commands/scan/index.js +3 -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
- package/resources/skills/rafter/SKILL.md +119 -0
- package/resources/skills/rafter-agent-security/SKILL.md +334 -0
|
@@ -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;
|
|
@@ -206,7 +206,7 @@ async function installClaudeCodeSkills() {
|
|
|
206
206
|
// Install Backend Skill
|
|
207
207
|
const backendSkillDir = path.join(claudeSkillsDir, "rafter");
|
|
208
208
|
const backendSkillPath = path.join(backendSkillDir, "SKILL.md");
|
|
209
|
-
const backendTemplatePath = path.join(__dirname, "..", "..", "..", "
|
|
209
|
+
const backendTemplatePath = path.join(__dirname, "..", "..", "..", "resources", "skills", "rafter", "SKILL.md");
|
|
210
210
|
if (!fs.existsSync(backendSkillDir)) {
|
|
211
211
|
fs.mkdirSync(backendSkillDir, { recursive: true });
|
|
212
212
|
}
|
|
@@ -220,7 +220,7 @@ async function installClaudeCodeSkills() {
|
|
|
220
220
|
// Install Agent Security Skill
|
|
221
221
|
const agentSkillDir = path.join(claudeSkillsDir, "rafter-agent-security");
|
|
222
222
|
const agentSkillPath = path.join(agentSkillDir, "SKILL.md");
|
|
223
|
-
const agentTemplatePath = path.join(__dirname, "..", "..", "..", "
|
|
223
|
+
const agentTemplatePath = path.join(__dirname, "..", "..", "..", "resources", "skills", "rafter-agent-security", "SKILL.md");
|
|
224
224
|
if (!fs.existsSync(agentSkillDir)) {
|
|
225
225
|
fs.mkdirSync(agentSkillDir, { recursive: true });
|
|
226
226
|
}
|
|
@@ -238,7 +238,7 @@ function installCodexSkills() {
|
|
|
238
238
|
// Install Backend Skill
|
|
239
239
|
const backendDir = path.join(agentsSkillsDir, "rafter");
|
|
240
240
|
const backendSkillPath = path.join(backendDir, "SKILL.md");
|
|
241
|
-
const backendTemplatePath = path.join(__dirname, "..", "..", "..", "
|
|
241
|
+
const backendTemplatePath = path.join(__dirname, "..", "..", "..", "resources", "skills", "rafter", "SKILL.md");
|
|
242
242
|
if (!fs.existsSync(backendDir)) {
|
|
243
243
|
fs.mkdirSync(backendDir, { recursive: true });
|
|
244
244
|
}
|
|
@@ -252,7 +252,7 @@ function installCodexSkills() {
|
|
|
252
252
|
// Install Agent Security Skill
|
|
253
253
|
const agentDir = path.join(agentsSkillsDir, "rafter-agent-security");
|
|
254
254
|
const agentSkillPath = path.join(agentDir, "SKILL.md");
|
|
255
|
-
const agentTemplatePath = path.join(__dirname, "..", "..", "..", "
|
|
255
|
+
const agentTemplatePath = path.join(__dirname, "..", "..", "..", "resources", "skills", "rafter-agent-security", "SKILL.md");
|
|
256
256
|
if (!fs.existsSync(agentDir)) {
|
|
257
257
|
fs.mkdirSync(agentDir, { recursive: true });
|
|
258
258
|
}
|
|
@@ -3,11 +3,14 @@ import { RegexScanner } from "../../scanners/regex-scanner.js";
|
|
|
3
3
|
import { GitleaksScanner } from "../../scanners/gitleaks.js";
|
|
4
4
|
import { ConfigManager } from "../../core/config-manager.js";
|
|
5
5
|
import { AuditLogger } from "../../core/audit-logger.js";
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync } from "child_process";
|
|
7
7
|
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))
|
|
@@ -72,12 +75,12 @@ export function createScanCommand() {
|
|
|
72
75
|
const baselineEntries = opts.baseline ? loadBaselineEntries() : [];
|
|
73
76
|
// Handle --diff flag
|
|
74
77
|
if (opts.diff) {
|
|
75
|
-
await scanDiffFiles(opts.diff, opts, scanCfg, baselineEntries);
|
|
78
|
+
await scanDiffFiles(opts.diff, opts, scanCfg, baselineEntries, path.resolve(scanPath));
|
|
76
79
|
return;
|
|
77
80
|
}
|
|
78
81
|
// Handle --staged flag
|
|
79
82
|
if (opts.staged) {
|
|
80
|
-
await scanStagedFiles(opts, scanCfg, baselineEntries);
|
|
83
|
+
await scanStagedFiles(opts, scanCfg, baselineEntries, path.resolve(scanPath));
|
|
81
84
|
return;
|
|
82
85
|
}
|
|
83
86
|
const resolvedPath = path.resolve(scanPath);
|
|
@@ -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
|
},
|
|
@@ -227,10 +230,12 @@ function outputScanResults(results, opts, context, exitOnFindings = true) {
|
|
|
227
230
|
/**
|
|
228
231
|
* Scan files changed since a git ref
|
|
229
232
|
*/
|
|
230
|
-
async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = []) {
|
|
233
|
+
async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = [], scanPath) {
|
|
234
|
+
const cwd = scanPath && fs.existsSync(scanPath) && fs.statSync(scanPath).isDirectory() ? scanPath : undefined;
|
|
231
235
|
try {
|
|
232
236
|
const diffOutput = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACM", ref], {
|
|
233
237
|
encoding: "utf-8",
|
|
238
|
+
cwd,
|
|
234
239
|
stdio: ["pipe", "pipe", "ignore"],
|
|
235
240
|
}).trim();
|
|
236
241
|
if (!diffOutput) {
|
|
@@ -243,6 +248,7 @@ async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = []) {
|
|
|
243
248
|
}
|
|
244
249
|
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
245
250
|
encoding: "utf-8",
|
|
251
|
+
cwd,
|
|
246
252
|
stdio: ["pipe", "pipe", "ignore"],
|
|
247
253
|
}).trim();
|
|
248
254
|
const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
|
|
@@ -270,10 +276,12 @@ async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = []) {
|
|
|
270
276
|
/**
|
|
271
277
|
* Scan git staged files for secrets
|
|
272
278
|
*/
|
|
273
|
-
async function scanStagedFiles(opts, scanCfg, baselineEntries = []) {
|
|
279
|
+
async function scanStagedFiles(opts, scanCfg, baselineEntries = [], scanPath) {
|
|
280
|
+
const cwd = scanPath && fs.existsSync(scanPath) && fs.statSync(scanPath).isDirectory() ? scanPath : undefined;
|
|
274
281
|
try {
|
|
275
|
-
const stagedFilesOutput =
|
|
282
|
+
const stagedFilesOutput = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM"], {
|
|
276
283
|
encoding: "utf-8",
|
|
284
|
+
cwd,
|
|
277
285
|
stdio: ["pipe", "pipe", "ignore"]
|
|
278
286
|
}).trim();
|
|
279
287
|
if (!stagedFilesOutput) {
|
|
@@ -286,6 +294,7 @@ async function scanStagedFiles(opts, scanCfg, baselineEntries = []) {
|
|
|
286
294
|
}
|
|
287
295
|
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
288
296
|
encoding: "utf-8",
|
|
297
|
+
cwd,
|
|
289
298
|
stdio: ["pipe", "pipe", "ignore"],
|
|
290
299
|
}).trim();
|
|
291
300
|
const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
|
|
@@ -406,7 +415,8 @@ async function watchAndScan(watchPath, opts, scanCfg) {
|
|
|
406
415
|
const watcher = watch(watchPath, {
|
|
407
416
|
ignoreInitial: true,
|
|
408
417
|
persistent: true,
|
|
409
|
-
ignored: /(^|[/\\])
|
|
418
|
+
ignored: [/(^|[/\\])\./, /node_modules/, /\.git/],
|
|
419
|
+
depth: 10,
|
|
410
420
|
});
|
|
411
421
|
watcher.on("change", async (filePath) => {
|
|
412
422
|
const timestamp = new Date().toLocaleTimeString();
|
|
@@ -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`).
|
|
@@ -25,7 +25,7 @@ export async function runRemoteScan(opts) {
|
|
|
25
25
|
if (!opts.quiet) {
|
|
26
26
|
const spinner = ora("Submitting scan").start();
|
|
27
27
|
try {
|
|
28
|
-
const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch }, { headers: { "x-api-key": key } });
|
|
28
|
+
const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch, scan_mode: opts.mode ?? "fast" }, { headers: { "x-api-key": key } });
|
|
29
29
|
spinner.succeed(`Scan ID: ${data.scan_id}`);
|
|
30
30
|
if (opts.skipInteractive)
|
|
31
31
|
return;
|
|
@@ -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
|
}
|
|
@@ -52,14 +55,17 @@ export async function runRemoteScan(opts) {
|
|
|
52
55
|
}
|
|
53
56
|
else {
|
|
54
57
|
try {
|
|
55
|
-
const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch }, { headers: { "x-api-key": key } });
|
|
58
|
+
const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch, scan_mode: opts.mode ?? "fast" }, { headers: { "x-api-key": key } });
|
|
56
59
|
if (opts.skipInteractive)
|
|
57
60
|
return;
|
|
58
61
|
const exitCode = await handleScanStatus(data.scan_id, { "x-api-key": key }, opts.format ?? "md", opts.quiet);
|
|
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) {
|
|
@@ -81,6 +87,7 @@ function addRunOptions(cmd) {
|
|
|
81
87
|
.option("-b, --branch <branch>", "branch (default: current else main)")
|
|
82
88
|
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
83
89
|
.option("-f, --format <format>", "json | md", "md")
|
|
90
|
+
.option("-m, --mode <mode>", "scan mode: fast | plus", "fast")
|
|
84
91
|
.option("--skip-interactive", "do not wait for scan to complete")
|
|
85
92
|
.option("--quiet", "suppress status messages");
|
|
86
93
|
}
|
|
@@ -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: [
|
|
@@ -20,6 +20,7 @@ export function createScanGroupCommand() {
|
|
|
20
20
|
.option("-b, --branch <branch>", "branch (default: current else main)")
|
|
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
|
+
.option("-m, --mode <mode>", "scan mode: fast | plus", "fast")
|
|
23
24
|
.option("--skip-interactive", "do not wait for scan to complete")
|
|
24
25
|
.option("--quiet", "suppress status messages")
|
|
25
26
|
.action(async (opts) => {
|
|
@@ -28,10 +29,12 @@ export function createScanGroupCommand() {
|
|
|
28
29
|
// Root scan group — default action is remote backend scan
|
|
29
30
|
const scanGroup = new Command("scan")
|
|
30
31
|
.description("Scan for security issues. Default: remote backend scan. Use 'scan local' for local secret scanning.")
|
|
32
|
+
.enablePositionalOptions()
|
|
31
33
|
.option("-r, --repo <repo>", "org/repo (default: current)")
|
|
32
34
|
.option("-b, --branch <branch>", "branch (default: current else main)")
|
|
33
35
|
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
34
36
|
.option("-f, --format <format>", "json | md", "md")
|
|
37
|
+
.option("-m, --mode <mode>", "scan mode: fast | plus", "fast")
|
|
35
38
|
.option("--skip-interactive", "do not wait for scan to complete")
|
|
36
39
|
.option("--quiet", "suppress status messages");
|
|
37
40
|
scanGroup.addCommand(localCmd);
|
|
@@ -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) {
|