@rafter-security/cli 0.5.1 → 0.5.5
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 +1 -1
- package/dist/commands/agent/audit-skill.js +7 -1
- package/dist/commands/agent/baseline.js +203 -0
- package/dist/commands/agent/index.js +8 -0
- package/dist/commands/agent/init.js +83 -32
- package/dist/commands/agent/install-hook.js +43 -48
- package/dist/commands/agent/scan.js +109 -12
- package/dist/commands/agent/status.js +115 -0
- package/dist/commands/agent/update-gitleaks.js +40 -0
- package/dist/commands/agent/verify.js +117 -0
- package/dist/commands/completion.js +368 -0
- package/dist/commands/hook/index.js +2 -0
- package/dist/commands/hook/posttool.js +73 -0
- package/dist/core/audit-logger.js +41 -0
- package/dist/core/config-defaults.js +4 -0
- package/dist/core/config-manager.js +16 -0
- package/dist/core/custom-patterns.js +157 -0
- package/dist/core/risk-rules.js +8 -1
- package/dist/index.js +4 -1
- package/dist/scanners/regex-scanner.js +7 -11
- package/dist/utils/binary-manager.js +150 -19
- package/dist/utils/skill-manager.js +22 -9
- package/package.json +1 -1
- package/resources/pre-push-hook.sh +60 -0
- package/resources/rafter-security-skill.md +7 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load custom secret patterns from ~/.rafter/patterns/
|
|
3
|
+
* and suppression rules from .rafterignore.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { getRafterDir } from "./config-defaults.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Custom pattern loading
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* Load user-defined patterns from ~/.rafter/patterns/*.txt and *.json.
|
|
13
|
+
*
|
|
14
|
+
* .txt — one regex per line (comments with # ignored)
|
|
15
|
+
* .json — array of {name, pattern, severity?} objects
|
|
16
|
+
*
|
|
17
|
+
* Returns Pattern[] merged with DEFAULT_SECRET_PATTERNS by callers.
|
|
18
|
+
*/
|
|
19
|
+
export function loadCustomPatterns() {
|
|
20
|
+
const patternsDir = path.join(getRafterDir(), "patterns");
|
|
21
|
+
if (!fs.existsSync(patternsDir))
|
|
22
|
+
return [];
|
|
23
|
+
const results = [];
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = fs.readdirSync(patternsDir, { withFileTypes: true });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (!entry.isFile())
|
|
33
|
+
continue;
|
|
34
|
+
const file = path.join(patternsDir, entry.name);
|
|
35
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
36
|
+
if (ext === ".txt") {
|
|
37
|
+
results.push(...loadTxtPatterns(file));
|
|
38
|
+
}
|
|
39
|
+
else if (ext === ".json") {
|
|
40
|
+
results.push(...loadJsonPatterns(file));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
function loadTxtPatterns(file) {
|
|
46
|
+
try {
|
|
47
|
+
const lines = fs.readFileSync(file, "utf-8").split("\n");
|
|
48
|
+
const patterns = [];
|
|
49
|
+
for (const raw of lines) {
|
|
50
|
+
const line = raw.trim();
|
|
51
|
+
if (!line || line.startsWith("#"))
|
|
52
|
+
continue;
|
|
53
|
+
patterns.push({
|
|
54
|
+
name: `Custom (${path.basename(file, ".txt")})`,
|
|
55
|
+
regex: line,
|
|
56
|
+
severity: "high",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return patterns;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function loadJsonPatterns(file) {
|
|
66
|
+
try {
|
|
67
|
+
const data = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
68
|
+
if (!Array.isArray(data))
|
|
69
|
+
return [];
|
|
70
|
+
const patterns = [];
|
|
71
|
+
for (const entry of data) {
|
|
72
|
+
if (typeof entry.pattern !== "string")
|
|
73
|
+
continue;
|
|
74
|
+
patterns.push({
|
|
75
|
+
name: entry.name ?? `Custom (${path.basename(file, ".json")})`,
|
|
76
|
+
regex: entry.pattern,
|
|
77
|
+
severity: entry.severity ?? "high",
|
|
78
|
+
description: entry.description,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return patterns;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parse .rafterignore from the given directory (project root).
|
|
89
|
+
*
|
|
90
|
+
* Format — one entry per line:
|
|
91
|
+
* path/glob → suppress all findings in matching files
|
|
92
|
+
* path/glob:pattern-name → suppress specific pattern in matching files
|
|
93
|
+
*
|
|
94
|
+
* Lines starting with # are comments.
|
|
95
|
+
*/
|
|
96
|
+
export function loadSuppressions(projectRoot = process.cwd()) {
|
|
97
|
+
const file = path.join(projectRoot, ".rafterignore");
|
|
98
|
+
if (!fs.existsSync(file))
|
|
99
|
+
return [];
|
|
100
|
+
const suppressions = [];
|
|
101
|
+
try {
|
|
102
|
+
const lines = fs.readFileSync(file, "utf-8").split("\n");
|
|
103
|
+
for (const raw of lines) {
|
|
104
|
+
const line = raw.trim();
|
|
105
|
+
if (!line || line.startsWith("#"))
|
|
106
|
+
continue;
|
|
107
|
+
const colonIdx = line.indexOf(":");
|
|
108
|
+
if (colonIdx === -1) {
|
|
109
|
+
suppressions.push({ pathGlob: line });
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
suppressions.push({
|
|
113
|
+
pathGlob: line.slice(0, colonIdx).trim(),
|
|
114
|
+
patternName: line.slice(colonIdx + 1).trim() || undefined,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// ignore unreadable .rafterignore
|
|
121
|
+
}
|
|
122
|
+
return suppressions;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Returns true if a finding should be suppressed.
|
|
126
|
+
*/
|
|
127
|
+
export function isSuppressed(filePath, patternName, suppressions) {
|
|
128
|
+
for (const s of suppressions) {
|
|
129
|
+
if (matchGlob(s.pathGlob, filePath)) {
|
|
130
|
+
if (!s.patternName || s.patternName.toLowerCase() === patternName.toLowerCase()) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Minimal glob matcher: supports * (within segment) and ** (cross-segment).
|
|
139
|
+
* Not full micromatch — covers the 90% case for .rafterignore.
|
|
140
|
+
*/
|
|
141
|
+
function matchGlob(glob, filePath) {
|
|
142
|
+
// Normalise separators
|
|
143
|
+
const g = glob.replace(/\\/g, "/");
|
|
144
|
+
const f = filePath.replace(/\\/g, "/");
|
|
145
|
+
// Escape regex special chars except * which we handle specially
|
|
146
|
+
const escaped = g
|
|
147
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
148
|
+
.replace(/\*\*/g, "\x00") // placeholder for **
|
|
149
|
+
.replace(/\*/g, "[^/]*") // * = anything within one segment
|
|
150
|
+
.replace(/\x00/g, ".*"); // ** = anything including /
|
|
151
|
+
try {
|
|
152
|
+
return new RegExp(`(^|/)${escaped}(/|$)`).test(f);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/core/risk-rules.js
CHANGED
|
@@ -17,7 +17,10 @@ export const HIGH_PATTERNS = [
|
|
|
17
17
|
/chmod\s+777/,
|
|
18
18
|
/curl.*\|\s*(bash|sh|zsh|dash)\b/,
|
|
19
19
|
/wget.*\|\s*(bash|sh|zsh|dash)\b/,
|
|
20
|
-
/git\s+push\s
|
|
20
|
+
/git\s+push\b.*\s--force\b/, // --force anywhere after push
|
|
21
|
+
/git\s+push\b.*\s-[a-zA-Z]*f\b/, // -f or combined flags like -vf
|
|
22
|
+
/git\s+push\b.*\s--force-(with-lease|if-includes)\b/, // specific force variants
|
|
23
|
+
/git\s+push\s+\S*\s+\+/, // refspec force: git push origin +main
|
|
21
24
|
/docker\s+system\s+prune/,
|
|
22
25
|
/npm\s+publish/,
|
|
23
26
|
/pypi.*upload/,
|
|
@@ -45,6 +48,10 @@ export const DEFAULT_REQUIRE_APPROVAL = [
|
|
|
45
48
|
"wget.*\\|\\s*(bash|sh|zsh|dash)\\b",
|
|
46
49
|
"chmod 777",
|
|
47
50
|
"git push --force",
|
|
51
|
+
"git push -f",
|
|
52
|
+
"git push --force-with-lease",
|
|
53
|
+
"git push --force-if-includes",
|
|
54
|
+
"git push .* \\+",
|
|
48
55
|
];
|
|
49
56
|
/**
|
|
50
57
|
* Assess risk level of a command string.
|
package/dist/index.js
CHANGED
|
@@ -9,10 +9,11 @@ import { createCiCommand } from "./commands/ci/index.js";
|
|
|
9
9
|
import { createHookCommand } from "./commands/hook/index.js";
|
|
10
10
|
import { createMcpCommand } from "./commands/mcp/index.js";
|
|
11
11
|
import { createPolicyCommand } from "./commands/policy/index.js";
|
|
12
|
+
import { createCompletionCommand } from "./commands/completion.js";
|
|
12
13
|
import { checkForUpdate } from "./utils/update-checker.js";
|
|
13
14
|
import { setAgentMode } from "./utils/formatter.js";
|
|
14
15
|
dotenv.config();
|
|
15
|
-
const VERSION = "0.5.
|
|
16
|
+
const VERSION = "0.5.5";
|
|
16
17
|
const program = new Command()
|
|
17
18
|
.name("rafter")
|
|
18
19
|
.description("Rafter CLI")
|
|
@@ -39,6 +40,8 @@ program.addCommand(createHookCommand());
|
|
|
39
40
|
program.addCommand(createMcpCommand());
|
|
40
41
|
// Policy commands
|
|
41
42
|
program.addCommand(createPolicyCommand());
|
|
43
|
+
// Shell completions
|
|
44
|
+
program.addCommand(createCompletionCommand());
|
|
42
45
|
// Non-blocking update check — runs after command, prints to stderr
|
|
43
46
|
checkForUpdate(VERSION).then((notice) => {
|
|
44
47
|
if (notice)
|
|
@@ -2,9 +2,10 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { PatternEngine } from "../core/pattern-engine.js";
|
|
4
4
|
import { DEFAULT_SECRET_PATTERNS } from "./secret-patterns.js";
|
|
5
|
+
import { loadCustomPatterns, loadSuppressions, isSuppressed } from "../core/custom-patterns.js";
|
|
5
6
|
export class RegexScanner {
|
|
6
7
|
constructor(customPatterns) {
|
|
7
|
-
const patterns = [...DEFAULT_SECRET_PATTERNS];
|
|
8
|
+
const patterns = [...DEFAULT_SECRET_PATTERNS, ...loadCustomPatterns()];
|
|
8
9
|
if (customPatterns) {
|
|
9
10
|
for (const cp of customPatterns) {
|
|
10
11
|
patterns.push({
|
|
@@ -15,6 +16,7 @@ export class RegexScanner {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
this.engine = new PatternEngine(patterns);
|
|
19
|
+
this.suppressions = loadSuppressions();
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* Scan a single file for secrets
|
|
@@ -22,18 +24,12 @@ export class RegexScanner {
|
|
|
22
24
|
scanFile(filePath) {
|
|
23
25
|
try {
|
|
24
26
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
matches
|
|
29
|
-
};
|
|
27
|
+
const raw = this.engine.scanWithPosition(content);
|
|
28
|
+
const matches = raw.filter((m) => !isSuppressed(filePath, m.pattern.name, this.suppressions));
|
|
29
|
+
return { file: filePath, matches };
|
|
30
30
|
}
|
|
31
31
|
catch (e) {
|
|
32
|
-
|
|
33
|
-
return {
|
|
34
|
-
file: filePath,
|
|
35
|
-
matches: []
|
|
36
|
-
};
|
|
32
|
+
return { file: filePath, matches: [] };
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
/**
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import https from "https";
|
|
4
|
-
import { exec } from "child_process";
|
|
5
|
+
import { exec, execSync } from "child_process";
|
|
5
6
|
import { promisify } from "util";
|
|
6
7
|
import { getBinDir } from "../core/config-defaults.js";
|
|
7
8
|
import * as tar from "tar";
|
|
8
9
|
const execAsync = promisify(exec);
|
|
9
|
-
const GITLEAKS_VERSION = "8.18.2";
|
|
10
|
+
export const GITLEAKS_VERSION = "8.18.2";
|
|
10
11
|
export class BinaryManager {
|
|
11
12
|
constructor() {
|
|
12
13
|
this.binDir = getBinDir();
|
|
@@ -52,6 +53,20 @@ export class BinaryManager {
|
|
|
52
53
|
const gitleaksPath = this.getGitleaksPath();
|
|
53
54
|
return fs.existsSync(gitleaksPath);
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Find gitleaks on system PATH (like Python's shutil.which)
|
|
58
|
+
*/
|
|
59
|
+
findGitleaksOnPath() {
|
|
60
|
+
const cmd = process.platform === "win32" ? "where gitleaks" : "which gitleaks";
|
|
61
|
+
try {
|
|
62
|
+
const result = execSync(cmd, { timeout: 5000, encoding: "utf-8" });
|
|
63
|
+
const found = result.trim().split("\n")[0].trim();
|
|
64
|
+
return found || null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
55
70
|
/**
|
|
56
71
|
* Verify Gitleaks binary works
|
|
57
72
|
*/
|
|
@@ -70,9 +85,72 @@ export class BinaryManager {
|
|
|
70
85
|
}
|
|
71
86
|
}
|
|
72
87
|
/**
|
|
73
|
-
*
|
|
88
|
+
* Run gitleaks version and return {ok, stdout, stderr}
|
|
89
|
+
*/
|
|
90
|
+
async verifyGitleaksVerbose(binaryPath) {
|
|
91
|
+
const gitleaksPath = binaryPath ?? this.getGitleaksPath();
|
|
92
|
+
try {
|
|
93
|
+
const { stdout, stderr } = await execAsync(`"${gitleaksPath}" version`, { timeout: 5000 });
|
|
94
|
+
const ok = stdout.includes("gitleaks version");
|
|
95
|
+
return { ok, stdout: stdout.trim(), stderr: stderr.trim() };
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
const err = e;
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
stdout: (err.stdout ?? "").trim(),
|
|
102
|
+
stderr: (err.stderr ?? String(e)).trim(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Collect diagnostic context for a failed binary (file type, uname, glibc/musl)
|
|
74
108
|
*/
|
|
75
|
-
async
|
|
109
|
+
async collectBinaryDiagnostics(binaryPath) {
|
|
110
|
+
const gitleaksPath = binaryPath ?? this.getGitleaksPath();
|
|
111
|
+
const lines = [];
|
|
112
|
+
try {
|
|
113
|
+
const { stdout: fileOut } = await execAsync(`file "${gitleaksPath}"`, { timeout: 5000 });
|
|
114
|
+
lines.push(` file: ${fileOut.trim()}`);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
lines.push(` file: (unavailable)`);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const { stdout: uname } = await execAsync("uname -a", { timeout: 5000 });
|
|
121
|
+
lines.push(` uname: ${uname.trim()}`);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
lines.push(` uname: (unavailable)`);
|
|
125
|
+
}
|
|
126
|
+
lines.push(` node arch: ${process.arch}, platform: ${process.platform}`);
|
|
127
|
+
// Detect glibc vs musl on Linux
|
|
128
|
+
if (process.platform === "linux") {
|
|
129
|
+
try {
|
|
130
|
+
const { stdout: ldd } = await execAsync("ldd --version 2>&1 || true", { timeout: 5000 });
|
|
131
|
+
if (ldd.includes("musl")) {
|
|
132
|
+
lines.push(" libc: musl (gitleaks linux builds target glibc; musl systems need a musl build or static binary)");
|
|
133
|
+
}
|
|
134
|
+
else if (ldd.includes("GLIBC") || ldd.includes("GNU")) {
|
|
135
|
+
const match = ldd.match(/(\d+\.\d+)/);
|
|
136
|
+
lines.push(` libc: glibc ${match ? match[1] : "(version unknown)"}`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
lines.push(" libc: unknown");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
lines.push(" libc: (detection failed)");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Download and install Gitleaks.
|
|
150
|
+
* @param onProgress Optional progress callback.
|
|
151
|
+
* @param version Gitleaks version to install (defaults to GITLEAKS_VERSION).
|
|
152
|
+
*/
|
|
153
|
+
async downloadGitleaks(onProgress, version = GITLEAKS_VERSION) {
|
|
76
154
|
const log = onProgress || (() => { });
|
|
77
155
|
// Check platform support
|
|
78
156
|
if (!this.isPlatformSupported()) {
|
|
@@ -84,17 +162,20 @@ export class BinaryManager {
|
|
|
84
162
|
}
|
|
85
163
|
const platform = this.getPlatformString();
|
|
86
164
|
const arch = this.getArchString();
|
|
87
|
-
const url = this.getDownloadUrl(platform, arch);
|
|
88
|
-
log(`Downloading Gitleaks v${
|
|
165
|
+
const url = this.getDownloadUrl(platform, arch, version);
|
|
166
|
+
log(`Downloading Gitleaks v${version} for ${platform}/${arch}...`);
|
|
167
|
+
log(` URL: ${url}`);
|
|
89
168
|
const archivePath = path.join(this.binDir, platform === "windows" ? "gitleaks.zip" : "gitleaks.tar.gz");
|
|
90
169
|
try {
|
|
91
170
|
// Download archive
|
|
92
171
|
await this.downloadFile(url, archivePath, log);
|
|
172
|
+
// Log downloaded file size as basic integrity signal
|
|
173
|
+
const stats = fs.statSync(archivePath);
|
|
174
|
+
log(` Downloaded: ${(stats.size / 1024).toFixed(1)} KB`);
|
|
93
175
|
// Extract binary
|
|
94
176
|
log("Extracting binary...");
|
|
95
177
|
if (platform === "windows") {
|
|
96
|
-
|
|
97
|
-
throw new Error("Windows support coming soon");
|
|
178
|
+
await this.extractZip(archivePath);
|
|
98
179
|
}
|
|
99
180
|
else {
|
|
100
181
|
await this.extractTarball(archivePath);
|
|
@@ -102,12 +183,22 @@ export class BinaryManager {
|
|
|
102
183
|
// Make executable (Unix systems)
|
|
103
184
|
if (process.platform !== "win32") {
|
|
104
185
|
await execAsync(`chmod +x "${this.getGitleaksPath()}"`);
|
|
186
|
+
log(" chmod +x applied");
|
|
105
187
|
}
|
|
106
|
-
// Verify it works
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
109
|
-
|
|
188
|
+
// Verify it works — capture output for diagnostics
|
|
189
|
+
const { ok, stdout: verOut, stderr: verErr } = await this.verifyGitleaksVerbose();
|
|
190
|
+
if (!ok) {
|
|
191
|
+
const diag = await this.collectBinaryDiagnostics();
|
|
192
|
+
const binaryPath = this.getGitleaksPath();
|
|
193
|
+
throw new Error(`Gitleaks binary failed to execute.\n` +
|
|
194
|
+
` Binary: ${binaryPath}\n` +
|
|
195
|
+
` URL: ${url}\n` +
|
|
196
|
+
(verOut ? ` gitleaks version stdout: ${verOut}\n` : "") +
|
|
197
|
+
(verErr ? ` gitleaks version stderr: ${verErr}\n` : "") +
|
|
198
|
+
`Diagnostics:\n${diag}\n` +
|
|
199
|
+
`Fix: ensure the binary matches your OS/arch, or install gitleaks manually and ensure it is on PATH.`);
|
|
110
200
|
}
|
|
201
|
+
log(` Verified: ${verOut}`);
|
|
111
202
|
// Clean up archive
|
|
112
203
|
if (fs.existsSync(archivePath)) {
|
|
113
204
|
fs.unlinkSync(archivePath);
|
|
@@ -166,15 +257,15 @@ export class BinaryManager {
|
|
|
166
257
|
throw new Error(`Unsupported architecture: ${arch}`);
|
|
167
258
|
}
|
|
168
259
|
/**
|
|
169
|
-
* Get download URL for platform/arch
|
|
260
|
+
* Get download URL for platform/arch/version
|
|
170
261
|
*/
|
|
171
|
-
getDownloadUrl(platform, arch) {
|
|
172
|
-
const baseUrl = `https://github.com/gitleaks/gitleaks/releases/download/v${
|
|
262
|
+
getDownloadUrl(platform, arch, version = GITLEAKS_VERSION) {
|
|
263
|
+
const baseUrl = `https://github.com/gitleaks/gitleaks/releases/download/v${version}`;
|
|
173
264
|
if (platform === "windows") {
|
|
174
|
-
return `${baseUrl}/gitleaks_${
|
|
265
|
+
return `${baseUrl}/gitleaks_${version}_windows_${arch}.zip`;
|
|
175
266
|
}
|
|
176
267
|
else {
|
|
177
|
-
return `${baseUrl}/gitleaks_${
|
|
268
|
+
return `${baseUrl}/gitleaks_${version}_${platform}_${arch}.tar.gz`;
|
|
178
269
|
}
|
|
179
270
|
}
|
|
180
271
|
/**
|
|
@@ -231,13 +322,53 @@ export class BinaryManager {
|
|
|
231
322
|
});
|
|
232
323
|
}
|
|
233
324
|
/**
|
|
234
|
-
* Extract
|
|
325
|
+
* Extract zip (Windows) — uses PowerShell's Expand-Archive, then copies
|
|
326
|
+
* only the gitleaks.exe binary to binDir. Cleans up the temp extract dir.
|
|
327
|
+
*/
|
|
328
|
+
async extractZip(zipPath) {
|
|
329
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-gitleaks-"));
|
|
330
|
+
try {
|
|
331
|
+
// PowerShell 5+ ships on all supported Windows versions
|
|
332
|
+
await execAsync(`powershell -NoProfile -Command "Expand-Archive -Force -LiteralPath '${zipPath}' -DestinationPath '${tempDir}'"`, { timeout: 30000 });
|
|
333
|
+
// Find gitleaks.exe — may be at root or inside a subdirectory
|
|
334
|
+
const findBinary = (dir) => {
|
|
335
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
336
|
+
const full = path.join(dir, entry);
|
|
337
|
+
if (entry === "gitleaks.exe")
|
|
338
|
+
return full;
|
|
339
|
+
if (fs.statSync(full).isDirectory()) {
|
|
340
|
+
const found = findBinary(full);
|
|
341
|
+
if (found)
|
|
342
|
+
return found;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
};
|
|
347
|
+
const found = findBinary(tempDir);
|
|
348
|
+
if (!found)
|
|
349
|
+
throw new Error("gitleaks.exe not found in archive");
|
|
350
|
+
fs.copyFileSync(found, path.join(this.binDir, "gitleaks.exe"));
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Extract tarball — binary only, strip packaging extras (LICENSE, README.md).
|
|
358
|
+
*
|
|
359
|
+
* The gitleaks release tarball has all files at the archive root (no top-level
|
|
360
|
+
* directory), so strip: 0 (the default). With strip: 1, node-tar reduces the
|
|
361
|
+
* single-component paths to empty strings; the filter never matches "gitleaks"
|
|
362
|
+
* and nothing is extracted. The filter alone is sufficient.
|
|
235
363
|
*/
|
|
236
364
|
async extractTarball(tarballPath) {
|
|
237
365
|
await tar.extract({
|
|
238
366
|
file: tarballPath,
|
|
239
367
|
cwd: this.binDir,
|
|
240
|
-
|
|
368
|
+
filter: (p) => {
|
|
369
|
+
const base = path.basename(p);
|
|
370
|
+
return base === "gitleaks" || base === "gitleaks.exe";
|
|
371
|
+
},
|
|
241
372
|
});
|
|
242
373
|
}
|
|
243
374
|
}
|
|
@@ -151,17 +151,17 @@ export class SkillManager {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
/**
|
|
154
|
-
* Install Rafter Security skill to OpenClaw
|
|
154
|
+
* Install Rafter Security skill to OpenClaw (verbose result)
|
|
155
155
|
*/
|
|
156
|
-
async
|
|
157
|
-
if (!this.isOpenClawInstalled()) {
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
156
|
+
async installRafterSkillVerbose(force = false) {
|
|
160
157
|
const skillPath = this.getRafterSkillPath();
|
|
161
158
|
const sourcePath = this.getRafterSkillSourcePath();
|
|
159
|
+
if (!this.isOpenClawInstalled()) {
|
|
160
|
+
return { ok: false, sourcePath, destPath: skillPath, error: `OpenClaw skills directory not found: ${this.getOpenClawSkillsDir()}` };
|
|
161
|
+
}
|
|
162
162
|
// Check if already installed and not forcing
|
|
163
163
|
if (!force && this.isRafterSkillInstalled()) {
|
|
164
|
-
return true;
|
|
164
|
+
return { ok: true, sourcePath, destPath: skillPath };
|
|
165
165
|
}
|
|
166
166
|
try {
|
|
167
167
|
// Ensure skills directory exists
|
|
@@ -169,6 +169,10 @@ export class SkillManager {
|
|
|
169
169
|
if (!fs.existsSync(skillsDir)) {
|
|
170
170
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
171
171
|
}
|
|
172
|
+
// Verify source exists
|
|
173
|
+
if (!fs.existsSync(sourcePath)) {
|
|
174
|
+
return { ok: false, sourcePath, destPath: skillPath, error: `Source skill file not found: ${sourcePath}` };
|
|
175
|
+
}
|
|
172
176
|
// Copy skill file
|
|
173
177
|
const sourceContent = fs.readFileSync(sourcePath, "utf-8");
|
|
174
178
|
fs.writeFileSync(skillPath, sourceContent, "utf-8");
|
|
@@ -180,12 +184,21 @@ export class SkillManager {
|
|
|
180
184
|
}
|
|
181
185
|
// Migrate old skill-auditor if present
|
|
182
186
|
await this.migrateOldSkill();
|
|
183
|
-
return true;
|
|
187
|
+
return { ok: true, sourcePath, destPath: skillPath };
|
|
184
188
|
}
|
|
185
189
|
catch (e) {
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
return { ok: false, sourcePath, destPath: skillPath, error: String(e) };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Install Rafter Security skill to OpenClaw
|
|
195
|
+
*/
|
|
196
|
+
async installRafterSkill(force = false) {
|
|
197
|
+
const result = await this.installRafterSkillVerbose(force);
|
|
198
|
+
if (!result.ok && result.error) {
|
|
199
|
+
console.error(`Failed to install Rafter Security skill: ${result.error}`);
|
|
188
200
|
}
|
|
201
|
+
return result.ok;
|
|
189
202
|
}
|
|
190
203
|
/**
|
|
191
204
|
* Backup current skill before updating
|
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Rafter Security Pre-Push Hook
|
|
3
|
+
# Scans commits being pushed for secrets
|
|
4
|
+
|
|
5
|
+
# Colors for output
|
|
6
|
+
RED='\033[0;31m'
|
|
7
|
+
YELLOW='\033[1;33m'
|
|
8
|
+
GREEN='\033[0;32m'
|
|
9
|
+
NC='\033[0m' # No Color
|
|
10
|
+
|
|
11
|
+
# Check if rafter is installed
|
|
12
|
+
if ! command -v rafter &> /dev/null; then
|
|
13
|
+
echo -e "${YELLOW}⚠️ Warning: rafter CLI not found in PATH${NC}"
|
|
14
|
+
echo " Install: npm install -g @rafter-security/cli"
|
|
15
|
+
echo " Skipping secret scan..."
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
ZERO_SHA="0000000000000000000000000000000000000000"
|
|
20
|
+
FOUND_SECRETS=0
|
|
21
|
+
|
|
22
|
+
while read local_ref local_sha remote_ref remote_sha; do
|
|
23
|
+
# Skip branch deletions
|
|
24
|
+
if [ "$local_sha" = "$ZERO_SHA" ]; then
|
|
25
|
+
continue
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [ "$remote_sha" = "$ZERO_SHA" ]; then
|
|
29
|
+
# New branch — scan all commits on this branch not on any remote branch
|
|
30
|
+
ref_arg=$(git rev-list --max-parents=0 "$local_sha" 2>/dev/null | head -1)
|
|
31
|
+
if [ -z "$ref_arg" ]; then
|
|
32
|
+
ref_arg="$local_sha^"
|
|
33
|
+
fi
|
|
34
|
+
else
|
|
35
|
+
# Existing branch — scan only new commits
|
|
36
|
+
ref_arg="$remote_sha"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
echo "🔍 Rafter: Scanning commits being pushed ($local_ref)..."
|
|
40
|
+
|
|
41
|
+
rafter agent scan --diff "$ref_arg" --quiet
|
|
42
|
+
EXIT_CODE=$?
|
|
43
|
+
|
|
44
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
45
|
+
FOUND_SECRETS=1
|
|
46
|
+
fi
|
|
47
|
+
done
|
|
48
|
+
|
|
49
|
+
if [ $FOUND_SECRETS -ne 0 ]; then
|
|
50
|
+
echo -e "${RED}❌ Push blocked: Secrets detected in commits being pushed${NC}"
|
|
51
|
+
echo ""
|
|
52
|
+
echo " Run: rafter agent scan --diff <remote-sha>"
|
|
53
|
+
echo " To see details and remediate."
|
|
54
|
+
echo ""
|
|
55
|
+
echo " To bypass (NOT recommended): git push --no-verify"
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
echo -e "${GREEN}✓ No secrets detected${NC}"
|
|
60
|
+
exit 0
|
|
@@ -46,6 +46,13 @@ rafter agent scan <path>
|
|
|
46
46
|
- Private keys (RSA, SSH, etc.)
|
|
47
47
|
- 21+ secret patterns
|
|
48
48
|
|
|
49
|
+
**Exit codes:**
|
|
50
|
+
- `0` — clean, no secrets
|
|
51
|
+
- `1` — secrets found
|
|
52
|
+
- `2` — runtime error (path not found, not a git repo)
|
|
53
|
+
|
|
54
|
+
**JSON output** (`--json`): Array of `{file, matches[]}` objects. Each match contains `pattern` (name, severity, description), `line`, `column`, and `redacted` value. Raw secrets are never included.
|
|
55
|
+
|
|
49
56
|
---
|
|
50
57
|
|
|
51
58
|
### /rafter-bash
|