@rafter-security/cli 0.1.0 → 0.4.0
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
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 {
|
|
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.0";
|
|
8
11
|
const program = new Command()
|
|
9
12
|
.name("rafter")
|
|
10
13
|
.description("Rafter CLI")
|
|
11
|
-
.version(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
}
|