@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.
@@ -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
+ }
@@ -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+--force/,
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.0";
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 matches = this.engine.scanWithPosition(content);
26
- return {
27
- file: filePath,
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
- // If file can't be read (binary, permissions, etc.), return empty matches
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
- * Download and install Gitleaks
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 downloadGitleaks(onProgress) {
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${GITLEAKS_VERSION} for ${platform}/${arch}...`);
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
- // For Windows, we'd need unzip - for now just error
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 works = await this.verifyGitleaks();
108
- if (!works) {
109
- throw new Error("Downloaded binary doesn't execute correctly");
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${GITLEAKS_VERSION}`;
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_${GITLEAKS_VERSION}_windows_${arch}.zip`;
265
+ return `${baseUrl}/gitleaks_${version}_windows_${arch}.zip`;
175
266
  }
176
267
  else {
177
- return `${baseUrl}/gitleaks_${GITLEAKS_VERSION}_${platform}_${arch}.tar.gz`;
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 tarball
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
- strip: 0
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 installRafterSkill(force = false) {
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
- console.error(`Failed to install Rafter Security skill: ${e}`);
187
- return false;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rafter-security/cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "rafter": "./dist/index.js"
@@ -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