@rafter-security/cli 0.5.5 → 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.
Files changed (40) hide show
  1. package/README.md +15 -3
  2. package/dist/commands/agent/audit-skill.js +1 -1
  3. package/dist/commands/agent/audit.js +106 -6
  4. package/dist/commands/agent/baseline.js +10 -0
  5. package/dist/commands/agent/exec.js +1 -1
  6. package/dist/commands/agent/init.js +366 -26
  7. package/dist/commands/agent/scan.js +160 -16
  8. package/dist/commands/agent/status.js +65 -4
  9. package/dist/commands/agent/verify.js +18 -4
  10. package/dist/commands/backend/run.js +76 -62
  11. package/dist/commands/ci/init.js +10 -3
  12. package/dist/commands/completion.js +21 -9
  13. package/dist/commands/hook/posttool.js +21 -7
  14. package/dist/commands/hook/pretool.js +50 -13
  15. package/dist/commands/issues/dedup.js +39 -0
  16. package/dist/commands/issues/from-scan.js +143 -0
  17. package/dist/commands/issues/from-text.js +185 -0
  18. package/dist/commands/issues/github-client.js +85 -0
  19. package/dist/commands/issues/index.js +25 -0
  20. package/dist/commands/issues/issue-builder.js +101 -0
  21. package/dist/commands/mcp/server.js +4 -1
  22. package/dist/commands/policy/export.js +7 -2
  23. package/dist/commands/scan/index.js +45 -0
  24. package/dist/core/audit-logger.js +106 -7
  25. package/dist/core/config-defaults.js +24 -0
  26. package/dist/core/config-manager.js +116 -3
  27. package/dist/core/custom-patterns.js +20 -17
  28. package/dist/core/pattern-engine.js +26 -1
  29. package/dist/core/policy-loader.js +25 -2
  30. package/dist/index.js +11 -2
  31. package/dist/scanners/gitleaks.js +8 -7
  32. package/dist/scanners/regex-scanner.js +16 -1
  33. package/dist/scanners/secret-patterns.js +6 -6
  34. package/dist/utils/api.js +18 -0
  35. package/dist/utils/binary-manager.js +74 -7
  36. package/dist/utils/skill-manager.js +5 -3
  37. package/package.json +5 -3
  38. package/resources/pre-commit-hook.sh +2 -2
  39. package/resources/pre-push-hook.sh +2 -2
  40. package/resources/rafter-security-skill.md +7 -11
@@ -0,0 +1,25 @@
1
+ /**
2
+ * rafter issues — GitHub Issues integration.
3
+ *
4
+ * Subcommands:
5
+ * rafter issues create --from-scan <id> Create issues from backend scan results
6
+ * rafter issues create --from-local <path> Create issues from local scan JSON
7
+ * rafter issues create --from-text Create issue from natural text (stdin/file/inline)
8
+ */
9
+ import { Command } from "commander";
10
+ import { createFromScanCommand } from "./from-scan.js";
11
+ import { createFromTextCommand } from "./from-text.js";
12
+ export function createIssuesCommand() {
13
+ const issuesGroup = new Command("issues")
14
+ .description("GitHub Issues integration — create issues from scan results or text");
15
+ const createGroup = new Command("create")
16
+ .description("Create GitHub issues from scan findings or natural text");
17
+ createGroup.addCommand(createFromScanCommand());
18
+ createGroup.addCommand(createFromTextCommand());
19
+ // Default action for `rafter issues create` with no subcommand — show help
20
+ createGroup.action(() => {
21
+ createGroup.help();
22
+ });
23
+ issuesGroup.addCommand(createGroup);
24
+ return issuesGroup;
25
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Build structured GitHub issues from scan findings.
3
+ *
4
+ * Handles both:
5
+ * - Backend scan vulnerabilities (SAST/policy findings)
6
+ * - Local scan results (secret detection)
7
+ */
8
+ import { fingerprint, embedFingerprint } from "./dedup.js";
9
+ function severityLabel(level) {
10
+ const map = {
11
+ error: "critical",
12
+ critical: "critical",
13
+ warning: "high",
14
+ high: "high",
15
+ note: "medium",
16
+ medium: "medium",
17
+ low: "low",
18
+ };
19
+ return map[level.toLowerCase()] || "medium";
20
+ }
21
+ function severityEmoji(level) {
22
+ const sev = severityLabel(level);
23
+ const emojis = {
24
+ critical: "🔴",
25
+ high: "🟠",
26
+ medium: "🟡",
27
+ low: "🟢",
28
+ };
29
+ return emojis[sev] || "🟡";
30
+ }
31
+ export function buildFromBackendVulnerability(vuln) {
32
+ const sev = severityLabel(vuln.level);
33
+ const emoji = severityEmoji(vuln.level);
34
+ const fp = fingerprint(vuln.file, vuln.ruleId);
35
+ const title = `${emoji} [${sev.toUpperCase()}] ${vuln.ruleId}: ${truncate(vuln.message, 80)}`;
36
+ let body = `## Security Finding\n\n`;
37
+ body += `**Rule:** \`${vuln.ruleId}\`\n`;
38
+ body += `**Severity:** ${sev}\n`;
39
+ body += `**File:** \`${vuln.file}\``;
40
+ if (vuln.line)
41
+ body += ` (line ${vuln.line})`;
42
+ body += `\n\n`;
43
+ body += `### Description\n\n${vuln.message}\n\n`;
44
+ body += `### Remediation\n\nReview and fix the finding in \`${vuln.file}\`.\n`;
45
+ body += `\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n`;
46
+ const labels = [
47
+ "security",
48
+ `severity:${sev}`,
49
+ `rule:${vuln.ruleId}`,
50
+ ];
51
+ return {
52
+ title,
53
+ body: embedFingerprint(body, fp),
54
+ labels,
55
+ fingerprint: fp,
56
+ };
57
+ }
58
+ export function buildFromLocalMatch(file, match) {
59
+ const sev = severityLabel(match.pattern.severity);
60
+ const emoji = severityEmoji(match.pattern.severity);
61
+ const fp = fingerprint(file, match.pattern.name);
62
+ const title = `${emoji} [${sev.toUpperCase()}] Secret detected: ${match.pattern.name} in ${basename(file)}`;
63
+ let body = `## Secret Detection\n\n`;
64
+ body += `**Pattern:** \`${match.pattern.name}\`\n`;
65
+ body += `**Severity:** ${sev}\n`;
66
+ body += `**File:** \`${file}\``;
67
+ if (match.line)
68
+ body += ` (line ${match.line})`;
69
+ body += `\n`;
70
+ if (match.redacted) {
71
+ body += `**Match:** \`${match.redacted}\`\n`;
72
+ }
73
+ body += `\n`;
74
+ if (match.pattern.description) {
75
+ body += `### Description\n\n${match.pattern.description}\n\n`;
76
+ }
77
+ body += `### Remediation\n\n`;
78
+ body += `1. Rotate the exposed credential immediately\n`;
79
+ body += `2. Remove the secret from source code\n`;
80
+ body += `3. Use environment variables or a secrets manager instead\n`;
81
+ body += `\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n`;
82
+ const labels = [
83
+ "security",
84
+ "secret-detected",
85
+ `severity:${sev}`,
86
+ ];
87
+ return {
88
+ title,
89
+ body: embedFingerprint(body, fp),
90
+ labels,
91
+ fingerprint: fp,
92
+ };
93
+ }
94
+ function truncate(s, max) {
95
+ if (s.length <= max)
96
+ return s;
97
+ return s.slice(0, max - 3) + "...";
98
+ }
99
+ function basename(filepath) {
100
+ return filepath.split("/").pop() || filepath;
101
+ }
@@ -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: [
@@ -54,9 +54,14 @@ function generateCodexConfig() {
54
54
  const policy = cfg.agent?.commandPolicy;
55
55
  const blocked = policy?.blockedPatterns || [];
56
56
  const approval = policy?.requireApproval || [];
57
- let toml = `# Rafter security policy for OpenAI Codex
57
+ let toml = `# Rafter security policy for OpenAI Codex CLI
58
58
  # Generated by: rafter policy export --format codex
59
- # Docs: https://docs.rafter.so/cli/pretool-hooks
59
+ #
60
+ # Usage: Save this file to your project root as codex-policy.toml
61
+ # then reference it in your Codex sandbox configuration.
62
+ #
63
+ # For full Codex integration, run: rafter agent init
64
+ # This auto-detects Codex CLI and installs skills to ~/.agents/skills/
60
65
 
61
66
  `;
62
67
  if (blocked.length > 0) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * rafter scan — top-level scan command group.
3
+ *
4
+ * Default (no subcommand): remote backend scan (same as `rafter run`)
5
+ * rafter scan remote: explicit alias for remote backend scan
6
+ * rafter scan local [path]: local secret scanner (was `rafter agent scan`)
7
+ */
8
+ import { Command } from "commander";
9
+ import { runRemoteScan } from "../backend/run.js";
10
+ import { createScanCommand as createLocalScanCommand } from "../agent/scan.js";
11
+ export function createScanGroupCommand() {
12
+ // "local" subcommand — reuses agent/scan.ts logic, renamed
13
+ const localCmd = createLocalScanCommand();
14
+ localCmd.name("local");
15
+ localCmd.description("Scan files or directories for secrets (local)");
16
+ // "remote" subcommand — same handler as `rafter run`
17
+ const remoteCmd = new Command("remote")
18
+ .description("Trigger a remote backend security scan (explicit alias for 'rafter run')")
19
+ .option("-r, --repo <repo>", "org/repo (default: current)")
20
+ .option("-b, --branch <branch>", "branch (default: current else main)")
21
+ .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
22
+ .option("-f, --format <format>", "json | md", "md")
23
+ .option("--skip-interactive", "do not wait for scan to complete")
24
+ .option("--quiet", "suppress status messages")
25
+ .action(async (opts) => {
26
+ await runRemoteScan(opts);
27
+ });
28
+ // Root scan group — default action is remote backend scan
29
+ const scanGroup = new Command("scan")
30
+ .description("Scan for security issues. Default: remote backend scan. Use 'scan local' for local secret scanning.")
31
+ .enablePositionalOptions()
32
+ .option("-r, --repo <repo>", "org/repo (default: current)")
33
+ .option("-b, --branch <branch>", "branch (default: current else main)")
34
+ .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
35
+ .option("-f, --format <format>", "json | md", "md")
36
+ .option("--skip-interactive", "do not wait for scan to complete")
37
+ .option("--quiet", "suppress status messages");
38
+ scanGroup.addCommand(localCmd);
39
+ scanGroup.addCommand(remoteCmd);
40
+ // When invoked with no subcommand, run remote backend scan
41
+ scanGroup.action(async (opts) => {
42
+ await runRemoteScan(opts);
43
+ });
44
+ return scanGroup;
45
+ }
@@ -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
@@ -19,6 +19,30 @@ export function getDefaultConfig() {
19
19
  claudeCode: {
20
20
  enabled: false,
21
21
  mcpPath: path.join(os.homedir(), ".claude", "mcp", "rafter-security.json")
22
+ },
23
+ codex: {
24
+ enabled: false,
25
+ skillsDir: path.join(os.homedir(), ".agents", "skills")
26
+ },
27
+ gemini: {
28
+ enabled: false,
29
+ configPath: path.join(os.homedir(), ".gemini", "settings.json")
30
+ },
31
+ aider: {
32
+ enabled: false,
33
+ configPath: path.join(os.homedir(), ".aider.conf.yml")
34
+ },
35
+ cursor: {
36
+ enabled: false,
37
+ mcpPath: path.join(os.homedir(), ".cursor", "mcp.json")
38
+ },
39
+ windsurf: {
40
+ enabled: false,
41
+ mcpPath: path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json")
42
+ },
43
+ continueDev: {
44
+ enabled: false,
45
+ configPath: path.join(os.homedir(), ".continue", "config.json")
22
46
  }
23
47
  },
24
48
  skills: {
@@ -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
  }
@@ -179,10 +275,27 @@ export class ConfigManager {
179
275
  * Migrate config to latest version
180
276
  */
181
277
  migrate(config) {
182
- // For now, just ensure version is current
183
- // In future, handle version-specific migrations
278
+ let dirty = false;
184
279
  if (config.version !== CONFIG_VERSION) {
185
280
  config.version = CONFIG_VERSION;
281
+ dirty = true;
282
+ }
283
+ // Fix overly broad curl/wget pipe-to-shell patterns.
284
+ // Old pattern: "curl.*\|.*sh" — matches any command containing "sh" after a pipe
285
+ // (e.g. `grep "curl\|sh"` or `git push`). Replace with word-bounded shell names.
286
+ const badToGood = {
287
+ "curl.*\\|.*sh": "curl.*\\|\\s*(bash|sh|zsh|dash)\\b",
288
+ "wget.*\\|.*sh": "wget.*\\|\\s*(bash|sh|zsh|dash)\\b",
289
+ };
290
+ const approval = config.agent?.commandPolicy?.requireApproval;
291
+ if (Array.isArray(approval)) {
292
+ const fixed = approval.map(p => badToGood[p] ?? p);
293
+ if (fixed.some((p, i) => p !== approval[i])) {
294
+ config.agent.commandPolicy.requireApproval = fixed;
295
+ dirty = true;
296
+ }
297
+ }
298
+ if (dirty) {
186
299
  this.save(config);
187
300
  }
188
301
  return config;
@@ -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
  }
@@ -1,3 +1,7 @@
1
+ const GENERIC_PATTERN_NAMES = new Set(["Generic API Key", "Generic Secret"]);
2
+ const VARIABLE_NAME_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/;
3
+ const LOWERCASE_IDENT_RE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
4
+ const QUOTED_VALUE_RE = /['"]([^'"]+)['"]/;
1
5
  export class PatternEngine {
2
6
  constructor(patterns) {
3
7
  this.patterns = patterns;
@@ -11,6 +15,8 @@ export class PatternEngine {
11
15
  const regex = this.createRegex(pattern.regex);
12
16
  let match;
13
17
  while ((match = regex.exec(text)) !== null) {
18
+ if (this.isFalsePositive(pattern, match[0]))
19
+ continue;
14
20
  matches.push({
15
21
  pattern,
16
22
  match: match[0],
@@ -32,6 +38,8 @@ export class PatternEngine {
32
38
  const regex = this.createRegex(pattern.regex);
33
39
  let match;
34
40
  while ((match = regex.exec(line)) !== null) {
41
+ if (this.isFalsePositive(pattern, match[0]))
42
+ continue;
35
43
  matches.push({
36
44
  pattern,
37
45
  match: match[0],
@@ -51,7 +59,7 @@ export class PatternEngine {
51
59
  let redacted = text;
52
60
  for (const pattern of this.patterns) {
53
61
  const regex = this.createRegex(pattern.regex);
54
- redacted = redacted.replace(regex, (match) => this.redact(match));
62
+ redacted = redacted.replace(regex, (match) => this.isFalsePositive(pattern, match) ? match : this.redact(match));
55
63
  }
56
64
  return redacted;
57
65
  }
@@ -67,6 +75,23 @@ export class PatternEngine {
67
75
  getPatternsBySeverity(severity) {
68
76
  return this.patterns.filter(p => p.severity === severity);
69
77
  }
78
+ /**
79
+ * Check if a match from a generic pattern looks like a variable name
80
+ * rather than an actual secret value.
81
+ */
82
+ isFalsePositive(pattern, matchText) {
83
+ if (!GENERIC_PATTERN_NAMES.has(pattern.name))
84
+ return false;
85
+ const m = QUOTED_VALUE_RE.exec(matchText);
86
+ if (!m)
87
+ return false;
88
+ const value = m[1];
89
+ if (VARIABLE_NAME_RE.test(value))
90
+ return true;
91
+ if (LOWERCASE_IDENT_RE.test(value))
92
+ return true;
93
+ return false;
94
+ }
70
95
  /**
71
96
  * Create RegExp from pattern string, extracting inline flags
72
97
  */