@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/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.3",
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",
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: rafter
3
+ description: "Trigger Rafter backend security scans on GitHub repositories. Use when the user asks about SAST, code security analysis, vulnerability scanning, or wants to scan a repo for security issues before merging or deploying. Also use when starting new features or reviewing pull requests."
4
+ version: 0.6.3
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Rafter Security Scanning
9
+
10
+ Rafter provides automated security scanning for GitHub repositories via backend API.
11
+
12
+ ## Core Commands
13
+
14
+ ### Trigger a Security Scan
15
+
16
+ ```bash
17
+ rafter run [--repo org/repo] [--branch branch-name]
18
+ # or
19
+ rafter scan [--repo org/repo] [--branch branch-name]
20
+ ```
21
+
22
+ Triggers a comprehensive security scan on a repository. Auto-detects current repo and branch if in a git directory. (`scan` is an alias for `run`)
23
+
24
+ **When to use:**
25
+ - User asks: "Can you scan this code for security issues?"
26
+ - Starting work on a new feature
27
+ - Before merging a PR
28
+ - After dependency updates
29
+ - User mentions: security audit, vulnerability scan, SAST, code analysis
30
+
31
+ **Example:**
32
+ ```bash
33
+ # In a git repo
34
+ rafter scan
35
+
36
+ # Specific repo
37
+ rafter scan --repo myorg/myrepo --branch main
38
+ ```
39
+
40
+ ### Get Scan Results
41
+
42
+ ```bash
43
+ rafter get <scan-id>
44
+ ```
45
+
46
+ Retrieves results from a completed or in-progress scan.
47
+
48
+ **When to use:**
49
+ - After triggering a scan with `rafter run`
50
+ - User asks: "What were the results?" or "Did the scan finish?"
51
+ - Checking on a scan's progress
52
+
53
+ **Example:**
54
+ ```bash
55
+ rafter get scan_abc123xyz
56
+ ```
57
+
58
+ ### Check API Usage
59
+
60
+ ```bash
61
+ rafter usage
62
+ ```
63
+
64
+ View your API quota and usage statistics.
65
+
66
+ **When to use:**
67
+ - User asks about remaining scans
68
+ - Before triggering a scan to confirm quota
69
+ - User mentions: quota, usage, limits, remaining scans
70
+
71
+ ## Configuration
72
+
73
+ Rafter requires an API key. Set via:
74
+ ```bash
75
+ export RAFTER_API_KEY="your-api-key-here"
76
+ ```
77
+
78
+ Or create `.env` file:
79
+ ```bash
80
+ echo "RAFTER_API_KEY=your-api-key-here" >> .env
81
+ ```
82
+
83
+ ## Common Workflows
84
+
85
+ **Workflow 1: Quick Security Check**
86
+ 1. Trigger scan: `rafter run`
87
+ 2. Get results: `rafter get <scan-id>`
88
+ 3. Review findings and suggest fixes
89
+
90
+ **Workflow 2: Pre-PR Review**
91
+ 1. Check quota: `rafter usage`
92
+ 2. Trigger scan on feature branch: `rafter run --branch feature-branch`
93
+ 3. Review results before creating PR
94
+
95
+ **Workflow 3: Dependency Update Check**
96
+ 1. User updates dependencies
97
+ 2. Trigger scan: `rafter run`
98
+ 3. Check for new vulnerabilities
99
+
100
+ ## Output Format
101
+
102
+ Scans return:
103
+ - **Code security findings** - SAST issues, security anti-patterns, hardcoded credentials
104
+ - **Configuration issues** - Insecure settings, exposed secrets
105
+ - **Severity levels** - Each finding rated by risk impact
106
+
107
+ ## Best Practices
108
+
109
+ 1. **Proactive scanning** - Suggest scans when user is working on security-sensitive code
110
+ 2. **Quota awareness** - Check usage before triggering multiple scans
111
+ 3. **Context interpretation** - Explain findings in context of user's code
112
+ 4. **Actionable recommendations** - Provide specific fixes for each finding
113
+
114
+ ## Integration Tips
115
+
116
+ - Auto-detect git repo for convenient `rafter run` with no arguments
117
+ - Wait for scan completion or show scan ID for later retrieval
118
+ - Parse JSON output for structured analysis
119
+ - Link findings to specific files and lines when available