@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.
@@ -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;
@@ -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: "0.5.7",
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.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
  }
@@ -59,7 +62,10 @@ export async function runRemoteScan(opts) {
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) {
@@ -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: [
@@ -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
- 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) {
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 VERSION = "0.5.7";
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()}-${Math.random().toString(36).slice(2, 8)}.json`);
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()}-${Math.random().toString(36).slice(2, 8)}.json`);
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
- await execAsync(`powershell -NoProfile -Command "Expand-Archive -Force -LiteralPath '${zipPath}' -DestinationPath '${tempDir}'"`, { timeout: 30000 });
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.5.9",
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.6.8",
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.7"
31
+ "tar": "^7.5.10"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/js-yaml": "^4.0.9",