@rafter-security/cli 0.1.0 → 0.4.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.
package/dist/index.js CHANGED
@@ -1,281 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import axios from "axios";
4
- import ora from "ora";
5
3
  import * as dotenv from "dotenv";
6
- import { execSync } from "child_process";
4
+ import { createRunCommand } from "./commands/backend/run.js";
5
+ import { createGetCommand } from "./commands/backend/get.js";
6
+ import { createUsageCommand } from "./commands/backend/usage.js";
7
+ import { createAgentCommand } from "./commands/agent/index.js";
8
+ import { checkForUpdate } from "./utils/update-checker.js";
7
9
  dotenv.config();
10
+ const VERSION = "0.4.1";
8
11
  const program = new Command()
9
12
  .name("rafter")
10
13
  .description("Rafter CLI")
11
- .version("0.1.0");
12
- const API = "https://rafter.so/api/";
13
- // Exit codes
14
- const EXIT_SUCCESS = 0;
15
- const EXIT_GENERAL_ERROR = 1;
16
- const EXIT_SCAN_NOT_FOUND = 2;
17
- const EXIT_QUOTA_EXHAUSTED = 3;
18
- function resolveKey(cliKey) {
19
- if (cliKey)
20
- return cliKey;
21
- if (process.env.RAFTER_API_KEY)
22
- return process.env.RAFTER_API_KEY;
23
- console.error("No API key provided. Use --api-key or set RAFTER_API_KEY");
24
- process.exit(EXIT_GENERAL_ERROR);
25
- }
26
- function writePayload(data, fmt, quiet) {
27
- const payload = fmt === "md" && data.markdown ? data.markdown : JSON.stringify(data, null, quiet ? 0 : 2);
28
- // Stream to stdout for pipelines
29
- process.stdout.write(payload);
30
- return EXIT_SUCCESS;
31
- }
32
- function git(cmd) {
33
- return execSync(`git ${cmd}`, { stdio: ["ignore", "pipe", "ignore"] })
34
- .toString()
35
- .trim();
36
- }
37
- function safeBranch(gitFn) {
38
- try {
39
- return gitFn("symbolic-ref --quiet --short HEAD");
40
- }
41
- catch {
42
- return gitFn("rev-parse --short HEAD");
43
- }
44
- }
45
- function parseRemote(url) {
46
- url = url.replace(/^(https?:\/\/|git@)/, "").replace(":", "/");
47
- if (url.endsWith(".git"))
48
- url = url.slice(0, -4);
49
- const parts = url.split("/");
50
- return parts.slice(-2).join("/"); // owner/repo
51
- }
52
- function detectRepo(opts) {
53
- if (opts.repo && opts.branch)
54
- return opts;
55
- const repoEnv = process.env.GITHUB_REPOSITORY || process.env.CI_REPOSITORY;
56
- const branchEnv = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH || process.env.CI_BRANCH;
57
- let repoSlug = opts.repo || repoEnv;
58
- let branch = opts.branch || branchEnv;
59
- try {
60
- if (!repoSlug || !branch) {
61
- if (git("rev-parse --is-inside-work-tree") !== "true")
62
- throw new Error("not a repo");
63
- if (!repoSlug)
64
- repoSlug = parseRemote(git("remote get-url origin"));
65
- if (!branch) {
66
- try {
67
- branch = safeBranch(git);
68
- }
69
- catch {
70
- branch = "main";
71
- }
72
- }
73
- }
74
- if ((!opts.repo || !opts.branch) && !opts.quiet) {
75
- console.error(`Repo auto-detected: ${repoSlug} @ ${branch} (note: scanning remote)`);
76
- }
77
- return { repo: repoSlug, branch };
78
- }
79
- catch {
80
- throw new Error("Could not auto-detect Git repository. Please pass --repo and --branch explicitly.");
81
- }
82
- }
83
- async function handleScanStatus(scan_id, headers, fmt, quiet) {
84
- // First poll
85
- let poll;
86
- try {
87
- poll = await axios.get(`${API}/static/scan`, { params: { scan_id, format: fmt }, headers });
88
- }
89
- catch (e) {
90
- if (e.response?.status === 404) {
91
- console.error(`Scan '${scan_id}' not found`);
92
- return EXIT_SCAN_NOT_FOUND;
93
- }
94
- console.error(`Error: ${e.response?.data || e.message}`);
95
- return EXIT_GENERAL_ERROR;
96
- }
97
- let status = poll.data.status;
98
- if (["queued", "pending", "processing"].includes(status)) {
99
- if (!quiet) {
100
- const spinner = ora("Waiting for scan to complete... (this could take several minutes)").start();
101
- while (["queued", "pending", "processing"].includes(status)) {
102
- await new Promise((r) => setTimeout(r, 10000));
103
- poll = await axios.get(`${API}/static/scan`, { params: { scan_id, format: fmt }, headers });
104
- status = poll.data.status;
105
- if (status === "completed") {
106
- spinner.succeed("Scan completed");
107
- return writePayload(poll.data, fmt, quiet);
108
- }
109
- else if (status === "failed") {
110
- spinner.fail("Scan failed");
111
- return EXIT_GENERAL_ERROR;
112
- }
113
- }
114
- console.error(`Scan status: ${status}`);
115
- }
116
- else {
117
- while (["queued", "pending", "processing"].includes(status)) {
118
- await new Promise((r) => setTimeout(r, 10000));
119
- poll = await axios.get(`${API}/static/scan`, { params: { scan_id, format: fmt }, headers });
120
- status = poll.data.status;
121
- if (status === "completed") {
122
- return writePayload(poll.data, fmt, quiet);
123
- }
124
- else if (status === "failed") {
125
- return EXIT_GENERAL_ERROR;
126
- }
127
- }
128
- }
129
- }
130
- else if (status === "completed") {
131
- if (!quiet) {
132
- console.error("Scan completed");
133
- }
134
- return writePayload(poll.data, fmt, quiet);
135
- }
136
- else if (status === "failed") {
137
- console.error("Scan failed");
138
- return EXIT_GENERAL_ERROR;
139
- }
140
- else {
141
- if (!quiet) {
142
- console.error(`Scan status: ${status}`);
143
- }
144
- }
145
- return writePayload(poll.data, fmt, quiet);
146
- }
147
- program
148
- .name("rafter")
149
- .description("Rafter CLI");
150
- program
151
- .command("run")
152
- .option("-r, --repo <repo>", "org/repo (default: current)")
153
- .option("-b, --branch <branch>", "branch (default: current else main)")
154
- .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
155
- .option("-f, --format <format>", "json | md", "json")
156
- .option("--skip-interactive", "do not wait for scan to complete")
157
- .option("--quiet", "suppress status messages")
158
- .action(async (opts) => {
159
- const key = resolveKey(opts.apiKey);
160
- let repo, branch;
161
- try {
162
- ({ repo, branch } = detectRepo({ repo: opts.repo, branch: opts.branch, quiet: opts.quiet }));
163
- }
164
- catch (e) {
165
- if (e instanceof Error) {
166
- console.error(e.message);
167
- }
168
- else {
169
- console.error(e);
170
- }
171
- process.exit(EXIT_GENERAL_ERROR);
172
- }
173
- if (!opts.quiet) {
174
- const spinner = ora("Submitting scan").start();
175
- try {
176
- const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch }, { headers: { "x-api-key": key } });
177
- spinner.succeed(`Scan ID: ${data.scan_id}`);
178
- if (opts.skipInteractive)
179
- return;
180
- const exitCode = await handleScanStatus(data.scan_id, { "x-api-key": key }, opts.format, opts.quiet);
181
- process.exit(exitCode);
182
- }
183
- catch (e) {
184
- spinner.fail("Request failed");
185
- if (e.response?.status === 429) {
186
- console.error("Quota exhausted");
187
- process.exit(EXIT_QUOTA_EXHAUSTED);
188
- }
189
- else if (e.response?.data) {
190
- console.error(e.response.data);
191
- }
192
- else if (e instanceof Error) {
193
- console.error(e.message);
194
- }
195
- else {
196
- console.error(e);
197
- }
198
- process.exit(EXIT_GENERAL_ERROR);
199
- }
200
- }
201
- else {
202
- try {
203
- const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch }, { headers: { "x-api-key": key } });
204
- if (opts.skipInteractive)
205
- return;
206
- const exitCode = await handleScanStatus(data.scan_id, { "x-api-key": key }, opts.format, opts.quiet);
207
- process.exit(exitCode);
208
- }
209
- catch (e) {
210
- if (e.response?.status === 429) {
211
- process.exit(EXIT_QUOTA_EXHAUSTED);
212
- }
213
- else if (e.response?.data) {
214
- console.error(e.response.data);
215
- }
216
- else if (e instanceof Error) {
217
- console.error(e.message);
218
- }
219
- else {
220
- console.error(e);
221
- }
222
- process.exit(EXIT_GENERAL_ERROR);
223
- }
224
- }
225
- });
226
- program
227
- .command("get")
228
- .argument("<scan_id>")
229
- .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
230
- .option("-f, --format <format>", "json | md", "json")
231
- .option("--interactive", "poll until done")
232
- .option("--quiet", "suppress status messages")
233
- .action(async (scan_id, opts) => {
234
- const key = resolveKey(opts.apiKey);
235
- if (!opts.interactive) {
236
- try {
237
- const { data } = await axios.get(`${API}/static/scan`, { params: { scan_id, format: opts.format }, headers: { "x-api-key": key } });
238
- const exitCode = writePayload(data, opts.format, opts.quiet);
239
- process.exit(exitCode);
240
- }
241
- catch (e) {
242
- if (e.response?.status === 404) {
243
- console.error(`Scan '${scan_id}' not found`);
244
- process.exit(EXIT_SCAN_NOT_FOUND);
245
- }
246
- else if (e.response?.data) {
247
- console.error(e.response.data);
248
- }
249
- else if (e instanceof Error) {
250
- console.error(e.message);
251
- }
252
- else {
253
- console.error(e);
254
- }
255
- process.exit(EXIT_GENERAL_ERROR);
256
- }
257
- return;
258
- }
259
- const exitCode = await handleScanStatus(scan_id, { "x-api-key": key }, opts.format, opts.quiet);
260
- process.exit(exitCode);
261
- });
262
- program
263
- .command("usage")
264
- .option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
265
- .action(async (opts) => {
266
- const key = resolveKey(opts.apiKey);
267
- try {
268
- const { data } = await axios.get(`${API}/static/usage`, { headers: { "x-api-key": key } });
269
- console.log(JSON.stringify(data, null, 2));
270
- }
271
- catch (e) {
272
- if (e.response?.data) {
273
- console.error(e.response.data);
274
- }
275
- else {
276
- console.error(e.message);
277
- }
278
- process.exit(EXIT_GENERAL_ERROR);
279
- }
14
+ .version(VERSION);
15
+ // Backend commands (existing)
16
+ program.addCommand(createRunCommand());
17
+ program.addCommand(createGetCommand());
18
+ program.addCommand(createUsageCommand());
19
+ // Agent commands
20
+ program.addCommand(createAgentCommand());
21
+ // Non-blocking update check — runs after command, prints to stderr
22
+ checkForUpdate(VERSION).then((notice) => {
23
+ if (notice)
24
+ process.stderr.write(notice);
280
25
  });
281
26
  program.parse();
@@ -0,0 +1,205 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import { BinaryManager } from "../utils/binary-manager.js";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ const execAsync = promisify(exec);
7
+ export class GitleaksScanner {
8
+ constructor() {
9
+ this.binaryManager = new BinaryManager();
10
+ }
11
+ /**
12
+ * Check if Gitleaks is available
13
+ */
14
+ async isAvailable() {
15
+ if (!this.binaryManager.isGitleaksInstalled()) {
16
+ return false;
17
+ }
18
+ return await this.binaryManager.verifyGitleaks();
19
+ }
20
+ /**
21
+ * Scan a file with Gitleaks
22
+ */
23
+ async scanFile(filePath) {
24
+ if (!await this.isAvailable()) {
25
+ throw new Error("Gitleaks not available");
26
+ }
27
+ const gitleaksPath = this.binaryManager.getGitleaksPath();
28
+ const tmpReport = path.join("/tmp", `gitleaks-${Date.now()}.json`);
29
+ try {
30
+ // Run gitleaks detect on file
31
+ await execAsync(`"${gitleaksPath}" detect --no-git -f json -r "${tmpReport}" -s "${filePath}"`, { timeout: 30000 });
32
+ // If no leaks found, gitleaks exits 0 with empty report
33
+ if (!fs.existsSync(tmpReport)) {
34
+ return { file: filePath, matches: [] };
35
+ }
36
+ const results = this.parseResults(tmpReport);
37
+ // Clean up report
38
+ fs.unlinkSync(tmpReport);
39
+ // Convert to our format
40
+ return {
41
+ file: filePath,
42
+ matches: results.map(r => this.convertToPatternMatch(r))
43
+ };
44
+ }
45
+ catch (e) {
46
+ // Clean up report
47
+ if (fs.existsSync(tmpReport)) {
48
+ fs.unlinkSync(tmpReport);
49
+ }
50
+ // Gitleaks exits with code 1 when leaks found
51
+ if (e.code === 1 && fs.existsSync(tmpReport)) {
52
+ const results = this.parseResults(tmpReport);
53
+ fs.unlinkSync(tmpReport);
54
+ return {
55
+ file: filePath,
56
+ matches: results.map(r => this.convertToPatternMatch(r))
57
+ };
58
+ }
59
+ throw new Error(`Gitleaks scan failed: ${e.message}`);
60
+ }
61
+ }
62
+ /**
63
+ * Scan multiple files
64
+ */
65
+ async scanFiles(filePaths) {
66
+ const results = [];
67
+ for (const filePath of filePaths) {
68
+ try {
69
+ const result = await this.scanFile(filePath);
70
+ if (result.matches.length > 0) {
71
+ results.push(result);
72
+ }
73
+ }
74
+ catch {
75
+ // Skip files that can't be scanned
76
+ }
77
+ }
78
+ return results;
79
+ }
80
+ /**
81
+ * Scan a directory
82
+ */
83
+ async scanDirectory(dirPath) {
84
+ if (!await this.isAvailable()) {
85
+ throw new Error("Gitleaks not available");
86
+ }
87
+ const gitleaksPath = this.binaryManager.getGitleaksPath();
88
+ const tmpReport = path.join("/tmp", `gitleaks-${Date.now()}.json`);
89
+ try {
90
+ // Run gitleaks detect on directory
91
+ await execAsync(`"${gitleaksPath}" detect --no-git -f json -r "${tmpReport}" -s "${dirPath}"`, { timeout: 60000 });
92
+ // No leaks found
93
+ if (!fs.existsSync(tmpReport)) {
94
+ return [];
95
+ }
96
+ const results = this.parseResults(tmpReport);
97
+ fs.unlinkSync(tmpReport);
98
+ // Group by file
99
+ return this.groupByFile(results);
100
+ }
101
+ catch (e) {
102
+ // Clean up report
103
+ if (fs.existsSync(tmpReport)) {
104
+ const results = this.parseResults(tmpReport);
105
+ fs.unlinkSync(tmpReport);
106
+ // Gitleaks exits 1 when leaks found
107
+ if (e.code === 1) {
108
+ return this.groupByFile(results);
109
+ }
110
+ }
111
+ throw new Error(`Gitleaks scan failed: ${e.message}`);
112
+ }
113
+ }
114
+ /**
115
+ * Parse Gitleaks JSON report
116
+ */
117
+ parseResults(reportPath) {
118
+ try {
119
+ const content = fs.readFileSync(reportPath, "utf-8");
120
+ if (!content.trim()) {
121
+ return [];
122
+ }
123
+ return JSON.parse(content);
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ /**
130
+ * Convert Gitleaks result to our PatternMatch format
131
+ */
132
+ convertToPatternMatch(result) {
133
+ // Map Gitleaks severity to our levels
134
+ const severity = this.getSeverity(result.RuleID, result.Tags);
135
+ return {
136
+ pattern: {
137
+ name: result.RuleID || result.Description,
138
+ regex: "", // Gitleaks doesn't expose the regex
139
+ severity,
140
+ description: result.Description
141
+ },
142
+ match: result.Secret || result.Match,
143
+ line: result.StartLine,
144
+ column: result.StartColumn,
145
+ redacted: this.redact(result.Secret || result.Match)
146
+ };
147
+ }
148
+ /**
149
+ * Determine severity from Gitleaks rule ID and tags
150
+ */
151
+ getSeverity(ruleID, tags) {
152
+ const lowerID = ruleID.toLowerCase();
153
+ // Critical: Private keys, passwords, database credentials
154
+ if (lowerID.includes("private-key") ||
155
+ lowerID.includes("password") ||
156
+ lowerID.includes("database") ||
157
+ tags.includes("key") && tags.includes("secret")) {
158
+ return "critical";
159
+ }
160
+ // High: API keys, access tokens
161
+ if (lowerID.includes("api-key") ||
162
+ lowerID.includes("access-token") ||
163
+ lowerID.includes("secret-key") ||
164
+ tags.includes("api")) {
165
+ return "high";
166
+ }
167
+ // Medium: Generic secrets
168
+ if (lowerID.includes("generic") ||
169
+ tags.includes("generic")) {
170
+ return "medium";
171
+ }
172
+ // Default to high for safety
173
+ return "high";
174
+ }
175
+ /**
176
+ * Redact a secret
177
+ */
178
+ redact(match) {
179
+ if (match.length <= 8) {
180
+ return "*".repeat(match.length);
181
+ }
182
+ const visibleChars = 4;
183
+ const start = match.substring(0, visibleChars);
184
+ const end = match.substring(match.length - visibleChars);
185
+ const middle = "*".repeat(match.length - (visibleChars * 2));
186
+ return start + middle + end;
187
+ }
188
+ /**
189
+ * Group results by file
190
+ */
191
+ groupByFile(results) {
192
+ const grouped = new Map();
193
+ for (const result of results) {
194
+ const file = result.File;
195
+ if (!grouped.has(file)) {
196
+ grouped.set(file, []);
197
+ }
198
+ grouped.get(file).push(this.convertToPatternMatch(result));
199
+ }
200
+ return Array.from(grouped.entries()).map(([file, matches]) => ({
201
+ file,
202
+ matches
203
+ }));
204
+ }
205
+ }
@@ -0,0 +1,125 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { PatternEngine } from "../core/pattern-engine.js";
4
+ import { DEFAULT_SECRET_PATTERNS } from "./secret-patterns.js";
5
+ export class RegexScanner {
6
+ constructor() {
7
+ this.engine = new PatternEngine(DEFAULT_SECRET_PATTERNS);
8
+ }
9
+ /**
10
+ * Scan a single file for secrets
11
+ */
12
+ scanFile(filePath) {
13
+ try {
14
+ const content = fs.readFileSync(filePath, "utf-8");
15
+ const matches = this.engine.scanWithPosition(content);
16
+ return {
17
+ file: filePath,
18
+ matches
19
+ };
20
+ }
21
+ catch (e) {
22
+ // If file can't be read (binary, permissions, etc.), return empty matches
23
+ return {
24
+ file: filePath,
25
+ matches: []
26
+ };
27
+ }
28
+ }
29
+ /**
30
+ * Scan multiple files
31
+ */
32
+ scanFiles(filePaths) {
33
+ const results = [];
34
+ for (const filePath of filePaths) {
35
+ const result = this.scanFile(filePath);
36
+ if (result.matches.length > 0) {
37
+ results.push(result);
38
+ }
39
+ }
40
+ return results;
41
+ }
42
+ /**
43
+ * Scan a directory recursively
44
+ */
45
+ scanDirectory(dirPath, options) {
46
+ const exclude = options?.exclude || [
47
+ "node_modules",
48
+ ".git",
49
+ "dist",
50
+ "build",
51
+ ".next",
52
+ "coverage",
53
+ ".vscode",
54
+ ".idea"
55
+ ];
56
+ const files = this.walkDirectory(dirPath, exclude, options?.maxDepth || 10);
57
+ return this.scanFiles(files);
58
+ }
59
+ /**
60
+ * Scan text content directly
61
+ */
62
+ scanText(text) {
63
+ return this.engine.scan(text);
64
+ }
65
+ /**
66
+ * Redact secrets from text
67
+ */
68
+ redact(text) {
69
+ return this.engine.redactText(text);
70
+ }
71
+ /**
72
+ * Check if text contains secrets
73
+ */
74
+ hasSecrets(text) {
75
+ return this.engine.hasMatches(text);
76
+ }
77
+ /**
78
+ * Walk directory and collect file paths
79
+ */
80
+ walkDirectory(dir, exclude, maxDepth, currentDepth = 0) {
81
+ if (currentDepth >= maxDepth) {
82
+ return [];
83
+ }
84
+ const files = [];
85
+ try {
86
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
87
+ for (const entry of entries) {
88
+ const fullPath = path.join(dir, entry.name);
89
+ // Skip excluded directories
90
+ if (exclude.includes(entry.name)) {
91
+ continue;
92
+ }
93
+ if (entry.isDirectory()) {
94
+ files.push(...this.walkDirectory(fullPath, exclude, maxDepth, currentDepth + 1));
95
+ }
96
+ else if (entry.isFile()) {
97
+ // Skip binary files by extension
98
+ if (!this.isBinaryFile(entry.name)) {
99
+ files.push(fullPath);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ catch (e) {
105
+ // Skip directories we can't read
106
+ }
107
+ return files;
108
+ }
109
+ /**
110
+ * Check if file is likely binary based on extension
111
+ */
112
+ isBinaryFile(filename) {
113
+ const binaryExtensions = [
114
+ ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico",
115
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
116
+ ".zip", ".tar", ".gz", ".rar", ".7z",
117
+ ".exe", ".dll", ".so", ".dylib",
118
+ ".mp3", ".mp4", ".avi", ".mov",
119
+ ".woff", ".woff2", ".ttf", ".eot",
120
+ ".pyc", ".class", ".o", ".a"
121
+ ];
122
+ const ext = path.extname(filename).toLowerCase();
123
+ return binaryExtensions.includes(ext);
124
+ }
125
+ }