@rafter-security/cli 0.5.9 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent/audit.js +13 -9
- package/dist/commands/agent/init.js +4 -4
- package/dist/commands/agent/scan.js +18 -8
- package/dist/commands/backend/run.js +12 -5
- package/dist/commands/mcp/server.js +4 -1
- package/dist/commands/scan/index.js +3 -0
- package/dist/core/audit-logger.js +106 -7
- package/dist/core/config-manager.js +97 -1
- package/dist/core/custom-patterns.js +20 -17
- package/dist/core/policy-loader.js +25 -2
- package/dist/index.js +4 -1
- package/dist/scanners/gitleaks.js +3 -2
- package/dist/scanners/regex-scanner.js +4 -0
- package/dist/scanners/secret-patterns.js +6 -6
- package/dist/utils/api.js +18 -0
- package/dist/utils/binary-manager.js +67 -1
- package/package.json +4 -3
- package/resources/skills/rafter/SKILL.md +119 -0
- package/resources/skills/rafter-agent-security/SKILL.md +334 -0
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
|
|
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()}-${
|
|
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()}-${
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|