@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.
@@ -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
+ }