@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/README.md +475 -5
- package/dist/commands/agent/audit-skill.js +199 -0
- package/dist/commands/agent/audit.js +65 -0
- package/dist/commands/agent/config.js +54 -0
- package/dist/commands/agent/exec.js +120 -0
- package/dist/commands/agent/index.js +21 -0
- package/dist/commands/agent/init.js +162 -0
- package/dist/commands/agent/install-hook.js +128 -0
- package/dist/commands/agent/scan.js +233 -0
- package/dist/commands/backend/get.js +41 -0
- package/dist/commands/backend/run.js +84 -0
- package/dist/commands/backend/scan-status.js +67 -0
- package/dist/commands/backend/usage.js +23 -0
- package/dist/core/audit-logger.js +203 -0
- package/dist/core/command-interceptor.js +165 -0
- package/dist/core/config-defaults.js +71 -0
- package/dist/core/config-manager.js +147 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/pattern-engine.js +105 -0
- package/dist/index.js +17 -272
- package/dist/scanners/gitleaks.js +205 -0
- package/dist/scanners/regex-scanner.js +125 -0
- package/dist/scanners/secret-patterns.js +155 -0
- package/dist/utils/api.js +20 -0
- package/dist/utils/binary-manager.js +243 -0
- package/dist/utils/git.js +52 -0
- package/dist/utils/skill-manager.js +346 -0
- package/dist/utils/update-checker.js +93 -0
- package/package.json +13 -8
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
export function createInstallHookCommand() {
|
|
9
|
+
return new Command("install-hook")
|
|
10
|
+
.description("Install pre-commit hook to scan for secrets")
|
|
11
|
+
.option("--global", "Install globally for all repos (via git config)")
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
await installHook(opts);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function installHook(opts) {
|
|
17
|
+
if (opts.global) {
|
|
18
|
+
await installGlobalHook();
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
await installLocalHook();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Install pre-commit hook for current repository
|
|
26
|
+
*/
|
|
27
|
+
async function installLocalHook() {
|
|
28
|
+
// Check if in a git repository
|
|
29
|
+
try {
|
|
30
|
+
execSync("git rev-parse --git-dir", { stdio: "pipe" });
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.error("❌ Error: Not in a git repository");
|
|
34
|
+
console.error(" Run this command from inside a git repository");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
// Get .git directory
|
|
38
|
+
const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf-8" }).trim();
|
|
39
|
+
const hooksDir = path.resolve(gitDir, "hooks");
|
|
40
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
41
|
+
// Ensure hooks directory exists
|
|
42
|
+
if (!fs.existsSync(hooksDir)) {
|
|
43
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
// Check if hook already exists
|
|
46
|
+
if (fs.existsSync(hookPath)) {
|
|
47
|
+
const existing = fs.readFileSync(hookPath, "utf-8");
|
|
48
|
+
// Check if it's already a Rafter hook
|
|
49
|
+
if (existing.includes("Rafter Security Pre-Commit Hook")) {
|
|
50
|
+
console.log("✓ Rafter pre-commit hook already installed");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Backup existing hook
|
|
54
|
+
const backupPath = `${hookPath}.backup-${Date.now()}`;
|
|
55
|
+
fs.copyFileSync(hookPath, backupPath);
|
|
56
|
+
console.log(`📦 Backed up existing hook to: ${path.basename(backupPath)}`);
|
|
57
|
+
}
|
|
58
|
+
// Get hook template path
|
|
59
|
+
const templatePath = path.join(__dirname, "..", "..", "..", "resources", "pre-commit-hook.sh");
|
|
60
|
+
if (!fs.existsSync(templatePath)) {
|
|
61
|
+
console.error("❌ Error: Hook template not found");
|
|
62
|
+
console.error(` Expected at: ${templatePath}`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
// Copy hook template
|
|
66
|
+
const hookContent = fs.readFileSync(templatePath, "utf-8");
|
|
67
|
+
fs.writeFileSync(hookPath, hookContent, "utf-8");
|
|
68
|
+
// Make executable
|
|
69
|
+
fs.chmodSync(hookPath, 0o755);
|
|
70
|
+
console.log("✓ Installed Rafter pre-commit hook");
|
|
71
|
+
console.log(` Location: ${hookPath}`);
|
|
72
|
+
console.log();
|
|
73
|
+
console.log("The hook will:");
|
|
74
|
+
console.log(" • Scan staged files for secrets before each commit");
|
|
75
|
+
console.log(" • Block commits if secrets are detected");
|
|
76
|
+
console.log(" • Can be bypassed with: git commit --no-verify (not recommended)");
|
|
77
|
+
console.log();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Install pre-commit hook globally for all repositories
|
|
81
|
+
*/
|
|
82
|
+
async function installGlobalHook() {
|
|
83
|
+
// Create global hooks directory
|
|
84
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
85
|
+
if (!homeDir) {
|
|
86
|
+
console.error("❌ Error: Could not determine home directory");
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const globalHooksDir = path.join(homeDir, ".rafter", "git-hooks");
|
|
90
|
+
const hookPath = path.join(globalHooksDir, "pre-commit");
|
|
91
|
+
// Create directory
|
|
92
|
+
if (!fs.existsSync(globalHooksDir)) {
|
|
93
|
+
fs.mkdirSync(globalHooksDir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
// Get hook template path
|
|
96
|
+
const templatePath = path.join(__dirname, "..", "..", "..", "resources", "pre-commit-hook.sh");
|
|
97
|
+
if (!fs.existsSync(templatePath)) {
|
|
98
|
+
console.error("❌ Error: Hook template not found");
|
|
99
|
+
console.error(` Expected at: ${templatePath}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
// Copy hook template
|
|
103
|
+
const hookContent = fs.readFileSync(templatePath, "utf-8");
|
|
104
|
+
fs.writeFileSync(hookPath, hookContent, "utf-8");
|
|
105
|
+
// Make executable
|
|
106
|
+
fs.chmodSync(hookPath, 0o755);
|
|
107
|
+
// Configure git to use global hooks directory
|
|
108
|
+
try {
|
|
109
|
+
execSync(`git config --global core.hooksPath "${globalHooksDir}"`, { stdio: "pipe" });
|
|
110
|
+
console.log("✓ Installed Rafter pre-commit hook globally");
|
|
111
|
+
console.log(` Location: ${hookPath}`);
|
|
112
|
+
console.log(` Git config: core.hooksPath = ${globalHooksDir}`);
|
|
113
|
+
console.log();
|
|
114
|
+
console.log("The hook will apply to ALL git repositories on this machine.");
|
|
115
|
+
console.log();
|
|
116
|
+
console.log("To disable globally:");
|
|
117
|
+
console.log(` git config --global --unset core.hooksPath`);
|
|
118
|
+
console.log();
|
|
119
|
+
console.log("To install per-repository instead:");
|
|
120
|
+
console.log(` cd <repo> && rafter agent install-hook`);
|
|
121
|
+
console.log();
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
console.error("❌ Failed to configure global git hooks");
|
|
125
|
+
console.error(" You may need to manually set: git config --global core.hooksPath ~/.rafter/git-hooks");
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { RegexScanner } from "../../scanners/regex-scanner.js";
|
|
3
|
+
import { GitleaksScanner } from "../../scanners/gitleaks.js";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
export function createScanCommand() {
|
|
8
|
+
return new Command("scan")
|
|
9
|
+
.description("Scan files or directories for secrets")
|
|
10
|
+
.argument("[path]", "File or directory to scan", ".")
|
|
11
|
+
.option("-q, --quiet", "Only output if secrets found")
|
|
12
|
+
.option("--json", "Output as JSON")
|
|
13
|
+
.option("--staged", "Scan only git staged files")
|
|
14
|
+
.option("--engine <engine>", "Scan engine: gitleaks or patterns", "auto")
|
|
15
|
+
.action(async (scanPath, opts) => {
|
|
16
|
+
// Handle --staged flag
|
|
17
|
+
if (opts.staged) {
|
|
18
|
+
await scanStagedFiles(opts);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const resolvedPath = path.resolve(scanPath);
|
|
22
|
+
// Check if path exists
|
|
23
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
24
|
+
console.error(`Error: Path not found: ${resolvedPath}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
// Determine scan engine
|
|
28
|
+
const engine = await selectEngine(opts.engine, opts.quiet);
|
|
29
|
+
// Determine if path is file or directory
|
|
30
|
+
const stats = fs.statSync(resolvedPath);
|
|
31
|
+
let results;
|
|
32
|
+
if (stats.isDirectory()) {
|
|
33
|
+
if (!opts.quiet) {
|
|
34
|
+
console.error(`Scanning directory: ${resolvedPath} (${engine})`);
|
|
35
|
+
}
|
|
36
|
+
results = await scanDirectory(resolvedPath, engine);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
if (!opts.quiet) {
|
|
40
|
+
console.error(`Scanning file: ${resolvedPath} (${engine})`);
|
|
41
|
+
}
|
|
42
|
+
results = await scanFile(resolvedPath, engine);
|
|
43
|
+
}
|
|
44
|
+
// Output results
|
|
45
|
+
if (opts.json) {
|
|
46
|
+
console.log(JSON.stringify(results, null, 2));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (results.length === 0) {
|
|
50
|
+
if (!opts.quiet) {
|
|
51
|
+
console.log("\n✓ No secrets detected\n");
|
|
52
|
+
}
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.log(`\n⚠️ Found secrets in ${results.length} file(s):\n`);
|
|
57
|
+
let totalMatches = 0;
|
|
58
|
+
for (const result of results) {
|
|
59
|
+
console.log(`\n📄 ${result.file}`);
|
|
60
|
+
for (const match of result.matches) {
|
|
61
|
+
totalMatches++;
|
|
62
|
+
const location = match.line ? `Line ${match.line}` : "Unknown location";
|
|
63
|
+
const severity = getSeverityEmoji(match.pattern.severity);
|
|
64
|
+
console.log(` ${severity} [${match.pattern.severity.toUpperCase()}] ${match.pattern.name}`);
|
|
65
|
+
console.log(` Location: ${location}`);
|
|
66
|
+
console.log(` Pattern: ${match.pattern.description || match.pattern.regex}`);
|
|
67
|
+
console.log(` Redacted: ${match.redacted}`);
|
|
68
|
+
console.log();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
console.log(`\n⚠️ Total: ${totalMatches} secret(s) detected in ${results.length} file(s)\n`);
|
|
72
|
+
console.log("Run 'rafter agent audit' to see the security log.\n");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Scan git staged files for secrets
|
|
80
|
+
*/
|
|
81
|
+
async function scanStagedFiles(opts) {
|
|
82
|
+
try {
|
|
83
|
+
// Get list of staged files
|
|
84
|
+
const stagedFilesOutput = execSync("git diff --cached --name-only --diff-filter=ACM", {
|
|
85
|
+
encoding: "utf-8",
|
|
86
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
87
|
+
}).trim();
|
|
88
|
+
if (!stagedFilesOutput) {
|
|
89
|
+
if (!opts.quiet) {
|
|
90
|
+
console.log("✓ No files staged for commit");
|
|
91
|
+
}
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
const stagedFiles = stagedFilesOutput.split("\n").map(f => f.trim()).filter(f => f);
|
|
95
|
+
if (!opts.quiet) {
|
|
96
|
+
console.error(`Scanning ${stagedFiles.length} staged file(s)...`);
|
|
97
|
+
}
|
|
98
|
+
// Determine scan engine
|
|
99
|
+
const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
|
|
100
|
+
// Scan each staged file
|
|
101
|
+
const allResults = [];
|
|
102
|
+
for (const file of stagedFiles) {
|
|
103
|
+
const filePath = path.resolve(file);
|
|
104
|
+
// Skip if file doesn't exist (might be deleted)
|
|
105
|
+
if (!fs.existsSync(filePath)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Skip if not a regular file
|
|
109
|
+
const stats = fs.statSync(filePath);
|
|
110
|
+
if (!stats.isFile()) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const results = await scanFile(filePath, engine);
|
|
114
|
+
allResults.push(...results);
|
|
115
|
+
}
|
|
116
|
+
// Output results (same as regular scan)
|
|
117
|
+
if (opts.json) {
|
|
118
|
+
console.log(JSON.stringify(allResults, null, 2));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
if (allResults.length === 0) {
|
|
122
|
+
if (!opts.quiet) {
|
|
123
|
+
console.log("\n✓ No secrets detected in staged files\n");
|
|
124
|
+
}
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.log(`\n⚠️ Found secrets in ${allResults.length} staged file(s):\n`);
|
|
129
|
+
let totalMatches = 0;
|
|
130
|
+
for (const result of allResults) {
|
|
131
|
+
console.log(`\n📄 ${result.file}`);
|
|
132
|
+
for (const match of result.matches) {
|
|
133
|
+
totalMatches++;
|
|
134
|
+
const location = match.line ? `Line ${match.line}` : "Unknown location";
|
|
135
|
+
const severity = getSeverityEmoji(match.pattern.severity);
|
|
136
|
+
console.log(` ${severity} [${match.pattern.severity.toUpperCase()}] ${match.pattern.name}`);
|
|
137
|
+
console.log(` Location: ${location}`);
|
|
138
|
+
console.log(` Pattern: ${match.pattern.description || match.pattern.regex}`);
|
|
139
|
+
console.log(` Redacted: ${match.redacted}`);
|
|
140
|
+
console.log();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
console.log(`\n⚠️ Total: ${totalMatches} secret(s) detected in ${allResults.length} file(s)\n`);
|
|
144
|
+
console.log("❌ Commit blocked. Remove secrets before committing.\n");
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error.status === 128) {
|
|
151
|
+
console.error("Error: Not in a git repository");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function getSeverityEmoji(severity) {
|
|
158
|
+
const emojiMap = {
|
|
159
|
+
critical: "🔴",
|
|
160
|
+
high: "🟠",
|
|
161
|
+
medium: "🟡",
|
|
162
|
+
low: "🟢"
|
|
163
|
+
};
|
|
164
|
+
return emojiMap[severity] || "⚪";
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Select scan engine based on availability and user preference
|
|
168
|
+
*/
|
|
169
|
+
async function selectEngine(preference, quiet) {
|
|
170
|
+
if (preference === "patterns") {
|
|
171
|
+
return "patterns";
|
|
172
|
+
}
|
|
173
|
+
if (preference === "gitleaks") {
|
|
174
|
+
const gitleaks = new GitleaksScanner();
|
|
175
|
+
const available = await gitleaks.isAvailable();
|
|
176
|
+
if (!available) {
|
|
177
|
+
if (!quiet) {
|
|
178
|
+
console.error("⚠️ Gitleaks requested but not available, using patterns");
|
|
179
|
+
}
|
|
180
|
+
return "patterns";
|
|
181
|
+
}
|
|
182
|
+
return "gitleaks";
|
|
183
|
+
}
|
|
184
|
+
// Auto mode: try Gitleaks, fall back to patterns
|
|
185
|
+
const gitleaks = new GitleaksScanner();
|
|
186
|
+
const available = await gitleaks.isAvailable();
|
|
187
|
+
return available ? "gitleaks" : "patterns";
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Scan a file with selected engine
|
|
191
|
+
*/
|
|
192
|
+
async function scanFile(filePath, engine) {
|
|
193
|
+
if (engine === "gitleaks") {
|
|
194
|
+
try {
|
|
195
|
+
const gitleaks = new GitleaksScanner();
|
|
196
|
+
const result = await gitleaks.scanFile(filePath);
|
|
197
|
+
return result.matches.length > 0 ? [result] : [];
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
// Fall back to patterns on error
|
|
201
|
+
console.error(`⚠️ Gitleaks scan failed, falling back to patterns`);
|
|
202
|
+
const scanner = new RegexScanner();
|
|
203
|
+
const result = scanner.scanFile(filePath);
|
|
204
|
+
return result.matches.length > 0 ? [result] : [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const scanner = new RegexScanner();
|
|
209
|
+
const result = scanner.scanFile(filePath);
|
|
210
|
+
return result.matches.length > 0 ? [result] : [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Scan a directory with selected engine
|
|
215
|
+
*/
|
|
216
|
+
async function scanDirectory(dirPath, engine) {
|
|
217
|
+
if (engine === "gitleaks") {
|
|
218
|
+
try {
|
|
219
|
+
const gitleaks = new GitleaksScanner();
|
|
220
|
+
return await gitleaks.scanDirectory(dirPath);
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
// Fall back to patterns on error
|
|
224
|
+
console.error(`⚠️ Gitleaks scan failed, falling back to patterns`);
|
|
225
|
+
const scanner = new RegexScanner();
|
|
226
|
+
return scanner.scanDirectory(dirPath);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const scanner = new RegexScanner();
|
|
231
|
+
return scanner.scanDirectory(dirPath);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { API, resolveKey, writePayload, EXIT_GENERAL_ERROR, EXIT_SCAN_NOT_FOUND } from "../../utils/api.js";
|
|
4
|
+
import { handleScanStatus } from "./scan-status.js";
|
|
5
|
+
export function createGetCommand() {
|
|
6
|
+
return new Command("get")
|
|
7
|
+
.argument("<scan_id>")
|
|
8
|
+
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
9
|
+
.option("-f, --format <format>", "json | md", "md")
|
|
10
|
+
.option("--interactive", "poll until done")
|
|
11
|
+
.option("--quiet", "suppress status messages")
|
|
12
|
+
.action(async (scan_id, opts) => {
|
|
13
|
+
const key = resolveKey(opts.apiKey);
|
|
14
|
+
if (!opts.interactive) {
|
|
15
|
+
try {
|
|
16
|
+
const { data } = await axios.get(`${API}/static/scan`, { params: { scan_id, format: opts.format }, headers: { "x-api-key": key } });
|
|
17
|
+
const exitCode = writePayload(data, opts.format, opts.quiet);
|
|
18
|
+
process.exit(exitCode);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
if (e.response?.status === 404) {
|
|
22
|
+
console.error(`Scan '${scan_id}' not found`);
|
|
23
|
+
process.exit(EXIT_SCAN_NOT_FOUND);
|
|
24
|
+
}
|
|
25
|
+
else if (e.response?.data) {
|
|
26
|
+
console.error(e.response.data);
|
|
27
|
+
}
|
|
28
|
+
else if (e instanceof Error) {
|
|
29
|
+
console.error(e.message);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.error(e);
|
|
33
|
+
}
|
|
34
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const exitCode = await handleScanStatus(scan_id, { "x-api-key": key }, opts.format, opts.quiet);
|
|
39
|
+
process.exit(exitCode);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { detectRepo } from "../../utils/git.js";
|
|
5
|
+
import { API, resolveKey, EXIT_GENERAL_ERROR, EXIT_QUOTA_EXHAUSTED } from "../../utils/api.js";
|
|
6
|
+
import { handleScanStatus } from "./scan-status.js";
|
|
7
|
+
export function createRunCommand() {
|
|
8
|
+
return new Command("run")
|
|
9
|
+
.alias("scan")
|
|
10
|
+
.option("-r, --repo <repo>", "org/repo (default: current)")
|
|
11
|
+
.option("-b, --branch <branch>", "branch (default: current else main)")
|
|
12
|
+
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
13
|
+
.option("-f, --format <format>", "json | md", "md")
|
|
14
|
+
.option("--skip-interactive", "do not wait for scan to complete")
|
|
15
|
+
.option("--quiet", "suppress status messages")
|
|
16
|
+
.action(async (opts) => {
|
|
17
|
+
const key = resolveKey(opts.apiKey);
|
|
18
|
+
let repo, branch;
|
|
19
|
+
try {
|
|
20
|
+
({ repo, branch } = detectRepo({ repo: opts.repo, branch: opts.branch, quiet: opts.quiet }));
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
if (e instanceof Error) {
|
|
24
|
+
console.error(e.message);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.error(e);
|
|
28
|
+
}
|
|
29
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
30
|
+
}
|
|
31
|
+
if (!opts.quiet) {
|
|
32
|
+
const spinner = ora("Submitting scan").start();
|
|
33
|
+
try {
|
|
34
|
+
const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch }, { headers: { "x-api-key": key } });
|
|
35
|
+
spinner.succeed(`Scan ID: ${data.scan_id}`);
|
|
36
|
+
if (opts.skipInteractive)
|
|
37
|
+
return;
|
|
38
|
+
const exitCode = await handleScanStatus(data.scan_id, { "x-api-key": key }, opts.format, opts.quiet);
|
|
39
|
+
process.exit(exitCode);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
spinner.fail("Request failed");
|
|
43
|
+
if (e.response?.status === 429) {
|
|
44
|
+
console.error("Quota exhausted");
|
|
45
|
+
process.exit(EXIT_QUOTA_EXHAUSTED);
|
|
46
|
+
}
|
|
47
|
+
else if (e.response?.data) {
|
|
48
|
+
console.error(e.response.data);
|
|
49
|
+
}
|
|
50
|
+
else if (e instanceof Error) {
|
|
51
|
+
console.error(e.message);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
console.error(e);
|
|
55
|
+
}
|
|
56
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
try {
|
|
61
|
+
const { data } = await axios.post(`${API}/static/scan`, { repository_name: repo, branch_name: branch }, { headers: { "x-api-key": key } });
|
|
62
|
+
if (opts.skipInteractive)
|
|
63
|
+
return;
|
|
64
|
+
const exitCode = await handleScanStatus(data.scan_id, { "x-api-key": key }, opts.format, opts.quiet);
|
|
65
|
+
process.exit(exitCode);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e.response?.status === 429) {
|
|
69
|
+
process.exit(EXIT_QUOTA_EXHAUSTED);
|
|
70
|
+
}
|
|
71
|
+
else if (e.response?.data) {
|
|
72
|
+
console.error(e.response.data);
|
|
73
|
+
}
|
|
74
|
+
else if (e instanceof Error) {
|
|
75
|
+
console.error(e.message);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
console.error(e);
|
|
79
|
+
}
|
|
80
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { API, writePayload, EXIT_GENERAL_ERROR, EXIT_SCAN_NOT_FOUND } from "../../utils/api.js";
|
|
4
|
+
export async function handleScanStatus(scan_id, headers, fmt, quiet) {
|
|
5
|
+
// First poll
|
|
6
|
+
let poll;
|
|
7
|
+
try {
|
|
8
|
+
poll = await axios.get(`${API}/static/scan`, { params: { scan_id, format: fmt }, headers });
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
if (e.response?.status === 404) {
|
|
12
|
+
console.error(`Scan '${scan_id}' not found`);
|
|
13
|
+
return EXIT_SCAN_NOT_FOUND;
|
|
14
|
+
}
|
|
15
|
+
console.error(`Error: ${e.response?.data || e.message}`);
|
|
16
|
+
return EXIT_GENERAL_ERROR;
|
|
17
|
+
}
|
|
18
|
+
let status = poll.data.status;
|
|
19
|
+
if (["queued", "pending", "processing"].includes(status)) {
|
|
20
|
+
if (!quiet) {
|
|
21
|
+
const spinner = ora("Waiting for scan to complete... (this could take several minutes)").start();
|
|
22
|
+
while (["queued", "pending", "processing"].includes(status)) {
|
|
23
|
+
await new Promise((r) => setTimeout(r, 10000));
|
|
24
|
+
poll = await axios.get(`${API}/static/scan`, { params: { scan_id, format: fmt }, headers });
|
|
25
|
+
status = poll.data.status;
|
|
26
|
+
if (status === "completed") {
|
|
27
|
+
spinner.succeed("Scan completed");
|
|
28
|
+
return writePayload(poll.data, fmt, quiet);
|
|
29
|
+
}
|
|
30
|
+
else if (status === "failed") {
|
|
31
|
+
spinner.fail("Scan failed");
|
|
32
|
+
return EXIT_GENERAL_ERROR;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
console.error(`Scan status: ${status}`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
while (["queued", "pending", "processing"].includes(status)) {
|
|
39
|
+
await new Promise((r) => setTimeout(r, 10000));
|
|
40
|
+
poll = await axios.get(`${API}/static/scan`, { params: { scan_id, format: fmt }, headers });
|
|
41
|
+
status = poll.data.status;
|
|
42
|
+
if (status === "completed") {
|
|
43
|
+
return writePayload(poll.data, fmt, quiet);
|
|
44
|
+
}
|
|
45
|
+
else if (status === "failed") {
|
|
46
|
+
return EXIT_GENERAL_ERROR;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (status === "completed") {
|
|
52
|
+
if (!quiet) {
|
|
53
|
+
console.error("Scan completed");
|
|
54
|
+
}
|
|
55
|
+
return writePayload(poll.data, fmt, quiet);
|
|
56
|
+
}
|
|
57
|
+
else if (status === "failed") {
|
|
58
|
+
console.error("Scan failed");
|
|
59
|
+
return EXIT_GENERAL_ERROR;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
if (!quiet) {
|
|
63
|
+
console.error(`Scan status: ${status}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return writePayload(poll.data, fmt, quiet);
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { API, resolveKey, EXIT_GENERAL_ERROR } from "../../utils/api.js";
|
|
4
|
+
export function createUsageCommand() {
|
|
5
|
+
return new Command("usage")
|
|
6
|
+
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
7
|
+
.action(async (opts) => {
|
|
8
|
+
const key = resolveKey(opts.apiKey);
|
|
9
|
+
try {
|
|
10
|
+
const { data } = await axios.get(`${API}/static/usage`, { headers: { "x-api-key": key } });
|
|
11
|
+
console.log(JSON.stringify(data, null, 2));
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
if (e.response?.data) {
|
|
15
|
+
console.error(e.response.data);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.error(e.message);
|
|
19
|
+
}
|
|
20
|
+
process.exit(EXIT_GENERAL_ERROR);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|