@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.
@@ -51,13 +51,17 @@ export function createAuditCommand() {
51
51
  if (entry.action?.riskLevel) {
52
52
  console.log(` Risk: ${entry.action.riskLevel}`);
53
53
  }
54
- console.log(` Check: ${entry.securityCheck.passed ? "PASSED" : "FAILED"}`);
55
- if (entry.securityCheck.reason) {
56
- console.log(` Reason: ${entry.securityCheck.reason}`);
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
- console.log(` Action: ${entry.resolution.actionTaken}`);
59
- if (entry.resolution.overrideReason) {
60
- console.log(` Override: ${entry.resolution.overrideReason}`);
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.actionTaken;
137
+ const action = entry.resolution?.actionTaken ?? "unknown";
134
138
  const suffix = `[${action}]`;
135
139
  if (entry.eventType === "secret_detected") {
136
- const reason = entry.securityCheck.reason ?? "";
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.reason) {
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, "..", "..", "..", ".claude", "skills", "rafter", "SKILL.md");
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, "..", "..", "..", ".claude", "skills", "rafter-agent-security", "SKILL.md");
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, "..", "..", "..", ".claude", "skills", "rafter", "SKILL.md");
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, "..", "..", "..", ".claude", "skills", "rafter-agent-security", "SKILL.md");
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 { execSync, execFileSync } from "child_process";
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: "0.5.7",
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 = execSync("git diff --cached --name-only --diff-filter=ACM", {
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.response?.status === 429) {
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.response?.status === 429) {
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: "0.5.0" }, { capabilities: { tools: {}, resources: {} } });
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
- fetch(webhookUrl, {
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
- }).catch(() => {
73
- // Silently ignore webhook failures
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()}-${Math.random().toString(36).substring(7)}`;
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 config = JSON.parse(content);
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: entry.severity ?? "high",
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
- * Minimal glob matcher: supports * (within segment) and ** (cross-segment).
139
- * Not full micromatch — covers the 90% case for .rafterignore.
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
- // Escape regex special chars except * which we handle specially
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) || !policy.scan.customPatterns.every((v) => v && typeof v === "object" && typeof v.name === "string" && v.name !== "" && typeof v.regex === "string" && v.regex !== "" && typeof v.severity === "string")) {
140
- console.error(`Warning: "scan.custom_patterns" must be an array of objects with name, regex, severity — ignoring.`);
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) {