@rafter-security/cli 0.5.3 → 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/dist/commands/agent/audit-skill.js +1 -1
- package/dist/commands/agent/baseline.js +203 -0
- package/dist/commands/agent/index.js +4 -0
- package/dist/commands/agent/init.js +5 -3
- package/dist/commands/agent/install-hook.js +41 -47
- package/dist/commands/agent/scan.js +40 -8
- package/dist/commands/agent/update-gitleaks.js +40 -0
- package/dist/commands/completion.js +308 -110
- package/dist/core/audit-logger.js +41 -0
- package/dist/core/config-defaults.js +4 -0
- package/dist/core/risk-rules.js +5 -3
- package/dist/index.js +1 -1
- package/dist/utils/binary-manager.js +52 -14
- package/package.json +1 -1
- package/resources/pre-push-hook.sh +60 -0
|
@@ -18,7 +18,7 @@ async function auditSkill(skillPath, opts) {
|
|
|
18
18
|
// Validate skill file exists
|
|
19
19
|
if (!fs.existsSync(skillPath)) {
|
|
20
20
|
console.error(`Error: Skill file not found: ${skillPath}`);
|
|
21
|
-
process.exit(
|
|
21
|
+
process.exit(2);
|
|
22
22
|
}
|
|
23
23
|
const absolutePath = path.resolve(skillPath);
|
|
24
24
|
const skillContent = fs.readFileSync(absolutePath, "utf-8");
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fmt } from "../../utils/formatter.js";
|
|
6
|
+
import { RegexScanner } from "../../scanners/regex-scanner.js";
|
|
7
|
+
import { GitleaksScanner } from "../../scanners/gitleaks.js";
|
|
8
|
+
import { ConfigManager } from "../../core/config-manager.js";
|
|
9
|
+
const BASELINE_PATH = path.join(os.homedir(), ".rafter", "baseline.json");
|
|
10
|
+
function loadBaseline() {
|
|
11
|
+
if (!fs.existsSync(BASELINE_PATH)) {
|
|
12
|
+
return { version: 1, created: "", updated: "", entries: [] };
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf-8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { version: 1, created: "", updated: "", entries: [] };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function saveBaseline(baseline) {
|
|
22
|
+
const dir = path.dirname(BASELINE_PATH);
|
|
23
|
+
if (!fs.existsSync(dir)) {
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2), "utf-8");
|
|
27
|
+
}
|
|
28
|
+
export function createBaselineCommand() {
|
|
29
|
+
const baseline = new Command("baseline")
|
|
30
|
+
.description("Manage the findings baseline (allowlist for known findings)");
|
|
31
|
+
baseline.addCommand(createBaselineCreateCommand());
|
|
32
|
+
baseline.addCommand(createBaselineShowCommand());
|
|
33
|
+
baseline.addCommand(createBaselineClearCommand());
|
|
34
|
+
baseline.addCommand(createBaselineAddCommand());
|
|
35
|
+
return baseline;
|
|
36
|
+
}
|
|
37
|
+
function createBaselineCreateCommand() {
|
|
38
|
+
return new Command("create")
|
|
39
|
+
.description("Scan and save all current findings as the baseline")
|
|
40
|
+
.argument("[path]", "Path to scan", ".")
|
|
41
|
+
.option("--engine <engine>", "Scan engine: gitleaks or patterns", "auto")
|
|
42
|
+
.action(async (scanPath, opts) => {
|
|
43
|
+
const resolvedPath = path.resolve(scanPath);
|
|
44
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
45
|
+
console.error(`Error: Path not found: ${resolvedPath}`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
const manager = new ConfigManager();
|
|
49
|
+
const cfg = manager.loadWithPolicy();
|
|
50
|
+
const scanCfg = cfg.agent?.scan;
|
|
51
|
+
console.error(`Scanning ${resolvedPath} to build baseline...`);
|
|
52
|
+
const engine = await selectEngine(opts.engine || "auto");
|
|
53
|
+
let results;
|
|
54
|
+
if (fs.statSync(resolvedPath).isDirectory()) {
|
|
55
|
+
results = await scanDirectory(resolvedPath, engine, scanCfg);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
results = await scanFile(resolvedPath, engine);
|
|
59
|
+
}
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const r of results) {
|
|
63
|
+
for (const m of r.matches) {
|
|
64
|
+
entries.push({
|
|
65
|
+
file: r.file,
|
|
66
|
+
line: m.line ?? null,
|
|
67
|
+
pattern: m.pattern.name,
|
|
68
|
+
addedAt: now,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const existing = loadBaseline();
|
|
73
|
+
const baseline = {
|
|
74
|
+
version: 1,
|
|
75
|
+
created: existing.created || now,
|
|
76
|
+
updated: now,
|
|
77
|
+
entries,
|
|
78
|
+
};
|
|
79
|
+
saveBaseline(baseline);
|
|
80
|
+
if (entries.length === 0) {
|
|
81
|
+
console.log(fmt.success("No findings — baseline is empty (all clean)"));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
console.log(fmt.success(`Baseline saved: ${entries.length} finding(s) recorded`));
|
|
85
|
+
console.log(` Location: ${BASELINE_PATH}`);
|
|
86
|
+
console.log();
|
|
87
|
+
console.log("Future scans with --baseline will suppress these findings.");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function createBaselineShowCommand() {
|
|
92
|
+
return new Command("show")
|
|
93
|
+
.description("Show current baseline entries")
|
|
94
|
+
.option("--json", "Output as JSON")
|
|
95
|
+
.action((opts) => {
|
|
96
|
+
const baseline = loadBaseline();
|
|
97
|
+
if (opts.json) {
|
|
98
|
+
console.log(JSON.stringify(baseline, null, 2));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (baseline.entries.length === 0) {
|
|
102
|
+
console.log("Baseline is empty. Run: rafter agent baseline create");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log(`Baseline: ${baseline.entries.length} entries`);
|
|
106
|
+
if (baseline.updated) {
|
|
107
|
+
console.log(`Updated: ${baseline.updated}`);
|
|
108
|
+
}
|
|
109
|
+
console.log();
|
|
110
|
+
// Group by file
|
|
111
|
+
const byFile = new Map();
|
|
112
|
+
for (const entry of baseline.entries) {
|
|
113
|
+
const list = byFile.get(entry.file) || [];
|
|
114
|
+
list.push(entry);
|
|
115
|
+
byFile.set(entry.file, list);
|
|
116
|
+
}
|
|
117
|
+
for (const [file, entries] of byFile) {
|
|
118
|
+
console.log(fmt.info(file));
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
const loc = e.line != null ? `line ${e.line}` : "unknown location";
|
|
121
|
+
console.log(` ${e.pattern} (${loc})`);
|
|
122
|
+
}
|
|
123
|
+
console.log();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function createBaselineClearCommand() {
|
|
128
|
+
return new Command("clear")
|
|
129
|
+
.description("Remove all baseline entries")
|
|
130
|
+
.action(() => {
|
|
131
|
+
if (!fs.existsSync(BASELINE_PATH)) {
|
|
132
|
+
console.log("No baseline file found — nothing to clear.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
fs.unlinkSync(BASELINE_PATH);
|
|
136
|
+
console.log(fmt.success("Baseline cleared"));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function createBaselineAddCommand() {
|
|
140
|
+
return new Command("add")
|
|
141
|
+
.description("Manually add a finding to the baseline")
|
|
142
|
+
.requiredOption("--file <path>", "File path")
|
|
143
|
+
.requiredOption("--pattern <name>", "Pattern name (e.g. 'AWS Access Key')")
|
|
144
|
+
.option("--line <number>", "Line number")
|
|
145
|
+
.action((opts) => {
|
|
146
|
+
const baseline = loadBaseline();
|
|
147
|
+
const now = new Date().toISOString();
|
|
148
|
+
const entry = {
|
|
149
|
+
file: path.resolve(opts.file),
|
|
150
|
+
line: opts.line != null ? parseInt(opts.line, 10) : null,
|
|
151
|
+
pattern: opts.pattern,
|
|
152
|
+
addedAt: now,
|
|
153
|
+
};
|
|
154
|
+
baseline.entries.push(entry);
|
|
155
|
+
baseline.updated = now;
|
|
156
|
+
if (!baseline.created)
|
|
157
|
+
baseline.created = now;
|
|
158
|
+
saveBaseline(baseline);
|
|
159
|
+
console.log(fmt.success(`Added to baseline: ${opts.pattern} in ${opts.file}`));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// ── helpers ─────────────────────────────────────────────────────────
|
|
163
|
+
async function selectEngine(preference) {
|
|
164
|
+
if (preference === "patterns")
|
|
165
|
+
return "patterns";
|
|
166
|
+
if (preference === "gitleaks") {
|
|
167
|
+
const g = new GitleaksScanner();
|
|
168
|
+
return (await g.isAvailable()) ? "gitleaks" : "patterns";
|
|
169
|
+
}
|
|
170
|
+
const g = new GitleaksScanner();
|
|
171
|
+
return (await g.isAvailable()) ? "gitleaks" : "patterns";
|
|
172
|
+
}
|
|
173
|
+
async function scanFile(filePath, engine) {
|
|
174
|
+
if (engine === "gitleaks") {
|
|
175
|
+
try {
|
|
176
|
+
const g = new GitleaksScanner();
|
|
177
|
+
const r = await g.scanFile(filePath);
|
|
178
|
+
return r.matches.length > 0 ? [r] : [];
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
const s = new RegexScanner();
|
|
182
|
+
const r = s.scanFile(filePath);
|
|
183
|
+
return r.matches.length > 0 ? [r] : [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const s = new RegexScanner();
|
|
187
|
+
const r = s.scanFile(filePath);
|
|
188
|
+
return r.matches.length > 0 ? [r] : [];
|
|
189
|
+
}
|
|
190
|
+
async function scanDirectory(dirPath, engine, scanCfg) {
|
|
191
|
+
if (engine === "gitleaks") {
|
|
192
|
+
try {
|
|
193
|
+
const g = new GitleaksScanner();
|
|
194
|
+
return await g.scanDirectory(dirPath);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
const s = new RegexScanner();
|
|
198
|
+
return s.scanDirectory(dirPath, { excludePaths: scanCfg?.excludePaths });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const s = new RegexScanner();
|
|
202
|
+
return s.scanDirectory(dirPath, { excludePaths: scanCfg?.excludePaths });
|
|
203
|
+
}
|
|
@@ -8,6 +8,8 @@ import { createAuditSkillCommand } from "./audit-skill.js";
|
|
|
8
8
|
import { createInstallHookCommand } from "./install-hook.js";
|
|
9
9
|
import { createVerifyCommand } from "./verify.js";
|
|
10
10
|
import { createStatusCommand } from "./status.js";
|
|
11
|
+
import { createUpdateGitleaksCommand } from "./update-gitleaks.js";
|
|
12
|
+
import { createBaselineCommand } from "./baseline.js";
|
|
11
13
|
export function createAgentCommand() {
|
|
12
14
|
const agent = new Command("agent")
|
|
13
15
|
.description("Agent security features");
|
|
@@ -21,5 +23,7 @@ export function createAgentCommand() {
|
|
|
21
23
|
agent.addCommand(createInstallHookCommand());
|
|
22
24
|
agent.addCommand(createVerifyCommand());
|
|
23
25
|
agent.addCommand(createStatusCommand());
|
|
26
|
+
agent.addCommand(createUpdateGitleaksCommand());
|
|
27
|
+
agent.addCommand(createBaselineCommand());
|
|
24
28
|
return agent;
|
|
25
29
|
}
|
|
@@ -96,6 +96,7 @@ export function createInitCommand() {
|
|
|
96
96
|
.option("--skip-claude-code", "Skip Claude Code skill installation")
|
|
97
97
|
.option("--claude-code", "Force Claude Code skill installation")
|
|
98
98
|
.option("--skip-gitleaks", "Skip Gitleaks binary download")
|
|
99
|
+
.option("--update", "Re-download gitleaks and reinstall integrations without resetting config")
|
|
99
100
|
.action(async (opts) => {
|
|
100
101
|
console.log(fmt.header("Rafter Agent Security Setup"));
|
|
101
102
|
console.log(fmt.divider());
|
|
@@ -151,7 +152,7 @@ export function createInitCommand() {
|
|
|
151
152
|
console.log(fmt.info("To fix: install gitleaks (https://github.com/gitleaks/gitleaks/releases) and ensure it is on PATH, then re-run 'rafter agent init'."));
|
|
152
153
|
console.log();
|
|
153
154
|
};
|
|
154
|
-
if (binaryManager.isGitleaksInstalled()) {
|
|
155
|
+
if (!opts.update && binaryManager.isGitleaksInstalled()) {
|
|
155
156
|
// Local binary exists — verify it actually works
|
|
156
157
|
const verResult = await binaryManager.verifyGitleaksVerbose();
|
|
157
158
|
if (verResult.ok) {
|
|
@@ -164,8 +165,9 @@ export function createInitCommand() {
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
else {
|
|
167
|
-
// Not installed locally — check PATH
|
|
168
|
-
|
|
168
|
+
// Not installed locally (or --update forcing re-download) — check PATH first
|
|
169
|
+
// unless --update was passed (in that case force a fresh managed install)
|
|
170
|
+
const pathBinary = opts.update ? null : binaryManager.findGitleaksOnPath();
|
|
169
171
|
if (pathBinary) {
|
|
170
172
|
const verResult = await binaryManager.verifyGitleaksVerbose(pathBinary);
|
|
171
173
|
if (verResult.ok) {
|
|
@@ -8,25 +8,36 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
8
8
|
const __dirname = path.dirname(__filename);
|
|
9
9
|
export function createInstallHookCommand() {
|
|
10
10
|
return new Command("install-hook")
|
|
11
|
-
.description("Install
|
|
11
|
+
.description("Install git hook to scan for secrets")
|
|
12
12
|
.option("--global", "Install globally for all repos (via git config)")
|
|
13
|
+
.option("--push", "Install pre-push hook instead of pre-commit")
|
|
13
14
|
.action(async (opts) => {
|
|
14
15
|
await installHook(opts);
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
async function installHook(opts) {
|
|
19
|
+
const hookName = opts.push ? "pre-push" : "pre-commit";
|
|
20
|
+
const templateName = opts.push ? "pre-push-hook.sh" : "pre-commit-hook.sh";
|
|
18
21
|
if (opts.global) {
|
|
19
|
-
await installGlobalHook();
|
|
22
|
+
await installGlobalHook(hookName, templateName);
|
|
20
23
|
}
|
|
21
24
|
else {
|
|
22
|
-
await installLocalHook();
|
|
25
|
+
await installLocalHook(hookName, templateName);
|
|
23
26
|
}
|
|
24
27
|
}
|
|
28
|
+
function getTemplatePath(templateName) {
|
|
29
|
+
const templatePath = path.join(__dirname, "..", "..", "..", "resources", templateName);
|
|
30
|
+
if (!fs.existsSync(templatePath)) {
|
|
31
|
+
console.error("❌ Error: Hook template not found");
|
|
32
|
+
console.error(` Expected at: ${templatePath}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
return templatePath;
|
|
36
|
+
}
|
|
25
37
|
/**
|
|
26
|
-
* Install
|
|
38
|
+
* Install hook for current repository
|
|
27
39
|
*/
|
|
28
|
-
async function installLocalHook() {
|
|
29
|
-
// Check if in a git repository
|
|
40
|
+
async function installLocalHook(hookName, templateName) {
|
|
30
41
|
try {
|
|
31
42
|
execSync("git rev-parse --git-dir", { stdio: "pipe" });
|
|
32
43
|
}
|
|
@@ -35,80 +46,63 @@ async function installLocalHook() {
|
|
|
35
46
|
console.error(" Run this command from inside a git repository");
|
|
36
47
|
process.exit(1);
|
|
37
48
|
}
|
|
38
|
-
// Get .git directory
|
|
39
49
|
const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf-8" }).trim();
|
|
40
50
|
const hooksDir = path.resolve(gitDir, "hooks");
|
|
41
|
-
const hookPath = path.join(hooksDir,
|
|
42
|
-
// Ensure hooks directory exists
|
|
51
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
43
52
|
if (!fs.existsSync(hooksDir)) {
|
|
44
53
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
45
54
|
}
|
|
46
|
-
// Check if hook already exists
|
|
47
55
|
if (fs.existsSync(hookPath)) {
|
|
48
56
|
const existing = fs.readFileSync(hookPath, "utf-8");
|
|
49
|
-
|
|
50
|
-
if (existing.includes(
|
|
51
|
-
console.log(
|
|
57
|
+
const marker = hookName === "pre-push" ? "Rafter Security Pre-Push Hook" : "Rafter Security Pre-Commit Hook";
|
|
58
|
+
if (existing.includes(marker)) {
|
|
59
|
+
console.log(`✓ Rafter ${hookName} hook already installed`);
|
|
52
60
|
return;
|
|
53
61
|
}
|
|
54
|
-
// Backup existing hook
|
|
55
62
|
const backupPath = `${hookPath}.backup-${Date.now()}`;
|
|
56
63
|
fs.copyFileSync(hookPath, backupPath);
|
|
57
64
|
console.log(`📦 Backed up existing hook to: ${path.basename(backupPath)}`);
|
|
58
65
|
}
|
|
59
|
-
|
|
60
|
-
const templatePath = path.join(__dirname, "..", "..", "..", "resources", "pre-commit-hook.sh");
|
|
61
|
-
if (!fs.existsSync(templatePath)) {
|
|
62
|
-
console.error("❌ Error: Hook template not found");
|
|
63
|
-
console.error(` Expected at: ${templatePath}`);
|
|
64
|
-
process.exit(1);
|
|
65
|
-
}
|
|
66
|
-
// Copy hook template
|
|
67
|
-
const hookContent = fs.readFileSync(templatePath, "utf-8");
|
|
66
|
+
const hookContent = fs.readFileSync(getTemplatePath(templateName), "utf-8");
|
|
68
67
|
fs.writeFileSync(hookPath, hookContent, "utf-8");
|
|
69
|
-
// Make executable
|
|
70
68
|
fs.chmodSync(hookPath, 0o755);
|
|
71
|
-
console.log(
|
|
69
|
+
console.log(`✓ Installed Rafter ${hookName} hook`);
|
|
72
70
|
console.log(` Location: ${hookPath}`);
|
|
73
71
|
console.log();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
if (hookName === "pre-push") {
|
|
73
|
+
console.log("The hook will:");
|
|
74
|
+
console.log(" • Scan commits being pushed for secrets");
|
|
75
|
+
console.log(" • Block pushes if secrets are detected");
|
|
76
|
+
console.log(" • Can be bypassed with: git push --no-verify (not recommended)");
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.log("The hook will:");
|
|
80
|
+
console.log(" • Scan staged files for secrets before each commit");
|
|
81
|
+
console.log(" • Block commits if secrets are detected");
|
|
82
|
+
console.log(" • Can be bypassed with: git commit --no-verify (not recommended)");
|
|
83
|
+
}
|
|
78
84
|
console.log();
|
|
79
85
|
}
|
|
80
86
|
/**
|
|
81
|
-
* Install
|
|
87
|
+
* Install hook globally for all repositories
|
|
82
88
|
*/
|
|
83
|
-
async function installGlobalHook() {
|
|
84
|
-
// Create global hooks directory
|
|
89
|
+
async function installGlobalHook(hookName, templateName) {
|
|
85
90
|
const homeDir = os.homedir();
|
|
86
91
|
if (!homeDir) {
|
|
87
92
|
console.error("❌ Error: Could not determine home directory");
|
|
88
93
|
process.exit(1);
|
|
89
94
|
}
|
|
90
95
|
const globalHooksDir = path.join(homeDir, ".rafter", "git-hooks");
|
|
91
|
-
const hookPath = path.join(globalHooksDir,
|
|
92
|
-
// Create directory
|
|
96
|
+
const hookPath = path.join(globalHooksDir, hookName);
|
|
93
97
|
if (!fs.existsSync(globalHooksDir)) {
|
|
94
98
|
fs.mkdirSync(globalHooksDir, { recursive: true });
|
|
95
99
|
}
|
|
96
|
-
|
|
97
|
-
const templatePath = path.join(__dirname, "..", "..", "..", "resources", "pre-commit-hook.sh");
|
|
98
|
-
if (!fs.existsSync(templatePath)) {
|
|
99
|
-
console.error("❌ Error: Hook template not found");
|
|
100
|
-
console.error(` Expected at: ${templatePath}`);
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
// Copy hook template
|
|
104
|
-
const hookContent = fs.readFileSync(templatePath, "utf-8");
|
|
100
|
+
const hookContent = fs.readFileSync(getTemplatePath(templateName), "utf-8");
|
|
105
101
|
fs.writeFileSync(hookPath, hookContent, "utf-8");
|
|
106
|
-
// Make executable
|
|
107
102
|
fs.chmodSync(hookPath, 0o755);
|
|
108
|
-
// Configure git to use global hooks directory
|
|
109
103
|
try {
|
|
110
104
|
execSync(`git config --global core.hooksPath "${globalHooksDir}"`, { stdio: "pipe" });
|
|
111
|
-
console.log(
|
|
105
|
+
console.log(`✓ Installed Rafter ${hookName} hook globally`);
|
|
112
106
|
console.log(` Location: ${hookPath}`);
|
|
113
107
|
console.log(` Git config: core.hooksPath = ${globalHooksDir}`);
|
|
114
108
|
console.log();
|
|
@@ -118,7 +112,7 @@ async function installGlobalHook() {
|
|
|
118
112
|
console.log(` git config --global --unset core.hooksPath`);
|
|
119
113
|
console.log();
|
|
120
114
|
console.log("To install per-repository instead:");
|
|
121
|
-
console.log(` cd <repo> && rafter agent install-hook`);
|
|
115
|
+
console.log(` cd <repo> && rafter agent install-hook${hookName === "pre-push" ? " --push" : ""}`);
|
|
122
116
|
console.log();
|
|
123
117
|
}
|
|
124
118
|
catch (e) {
|
|
@@ -4,8 +4,33 @@ import { GitleaksScanner } from "../../scanners/gitleaks.js";
|
|
|
4
4
|
import { ConfigManager } from "../../core/config-manager.js";
|
|
5
5
|
import { execSync, execFileSync } from "child_process";
|
|
6
6
|
import fs from "fs";
|
|
7
|
+
import os from "os";
|
|
7
8
|
import path from "path";
|
|
8
9
|
import { fmt } from "../../utils/formatter.js";
|
|
10
|
+
function loadBaselineEntries() {
|
|
11
|
+
const baselinePath = path.join(os.homedir(), ".rafter", "baseline.json");
|
|
12
|
+
if (!fs.existsSync(baselinePath))
|
|
13
|
+
return [];
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(fs.readFileSync(baselinePath, "utf-8"));
|
|
16
|
+
return data.entries || [];
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function applyBaseline(results, entries) {
|
|
23
|
+
if (entries.length === 0)
|
|
24
|
+
return results;
|
|
25
|
+
return results
|
|
26
|
+
.map((r) => ({
|
|
27
|
+
...r,
|
|
28
|
+
matches: r.matches.filter((m) => !entries.some((e) => e.file === r.file &&
|
|
29
|
+
e.pattern === m.pattern.name &&
|
|
30
|
+
(e.line == null || e.line === (m.line ?? null)))),
|
|
31
|
+
}))
|
|
32
|
+
.filter((r) => r.matches.length > 0);
|
|
33
|
+
}
|
|
9
34
|
export function createScanCommand() {
|
|
10
35
|
return new Command("scan")
|
|
11
36
|
.description("Scan files or directories for secrets")
|
|
@@ -16,19 +41,21 @@ export function createScanCommand() {
|
|
|
16
41
|
.option("--staged", "Scan only git staged files")
|
|
17
42
|
.option("--diff <ref>", "Scan files changed since a git ref")
|
|
18
43
|
.option("--engine <engine>", "Scan engine: gitleaks or patterns", "auto")
|
|
44
|
+
.option("--baseline", "Filter findings present in the saved baseline")
|
|
19
45
|
.action(async (scanPath, opts) => {
|
|
20
46
|
// Load policy-merged config for excludePaths/customPatterns
|
|
21
47
|
const manager = new ConfigManager();
|
|
22
48
|
const cfg = manager.loadWithPolicy();
|
|
23
49
|
const scanCfg = cfg.agent?.scan;
|
|
50
|
+
const baselineEntries = opts.baseline ? loadBaselineEntries() : [];
|
|
24
51
|
// Handle --diff flag
|
|
25
52
|
if (opts.diff) {
|
|
26
|
-
await scanDiffFiles(opts.diff, opts, scanCfg);
|
|
53
|
+
await scanDiffFiles(opts.diff, opts, scanCfg, baselineEntries);
|
|
27
54
|
return;
|
|
28
55
|
}
|
|
29
56
|
// Handle --staged flag
|
|
30
57
|
if (opts.staged) {
|
|
31
|
-
await scanStagedFiles(opts, scanCfg);
|
|
58
|
+
await scanStagedFiles(opts, scanCfg, baselineEntries);
|
|
32
59
|
return;
|
|
33
60
|
}
|
|
34
61
|
const resolvedPath = path.resolve(scanPath);
|
|
@@ -54,7 +81,7 @@ export function createScanCommand() {
|
|
|
54
81
|
}
|
|
55
82
|
results = await scanFile(resolvedPath, engine, scanCfg);
|
|
56
83
|
}
|
|
57
|
-
outputScanResults(results, opts);
|
|
84
|
+
outputScanResults(applyBaseline(results, baselineEntries), opts);
|
|
58
85
|
});
|
|
59
86
|
}
|
|
60
87
|
/**
|
|
@@ -89,13 +116,14 @@ function outputSarif(results) {
|
|
|
89
116
|
}
|
|
90
117
|
}
|
|
91
118
|
const sarif = {
|
|
92
|
-
$schema: "https://
|
|
119
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
93
120
|
version: "2.1.0",
|
|
94
121
|
runs: [
|
|
95
122
|
{
|
|
96
123
|
tool: {
|
|
97
124
|
driver: {
|
|
98
125
|
name: "rafter",
|
|
126
|
+
version: "0.5.5",
|
|
99
127
|
informationUri: "https://rafter.so",
|
|
100
128
|
rules: Array.from(rules.values()),
|
|
101
129
|
},
|
|
@@ -112,6 +140,10 @@ function outputSarif(results) {
|
|
|
112
140
|
*/
|
|
113
141
|
function outputScanResults(results, opts, context) {
|
|
114
142
|
const format = opts.format ?? (opts.json ? "json" : "text");
|
|
143
|
+
if (!["text", "json", "sarif"].includes(format)) {
|
|
144
|
+
console.error(`Invalid format: ${format}. Valid values: text, json, sarif`);
|
|
145
|
+
process.exit(2);
|
|
146
|
+
}
|
|
115
147
|
if (format === "sarif") {
|
|
116
148
|
outputSarif(results);
|
|
117
149
|
return;
|
|
@@ -163,7 +195,7 @@ function outputScanResults(results, opts, context) {
|
|
|
163
195
|
/**
|
|
164
196
|
* Scan files changed since a git ref
|
|
165
197
|
*/
|
|
166
|
-
async function scanDiffFiles(ref, opts, scanCfg) {
|
|
198
|
+
async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = []) {
|
|
167
199
|
try {
|
|
168
200
|
const diffOutput = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACM", ref], {
|
|
169
201
|
encoding: "utf-8",
|
|
@@ -191,7 +223,7 @@ async function scanDiffFiles(ref, opts, scanCfg) {
|
|
|
191
223
|
const results = await scanFile(filePath, engine, scanCfg);
|
|
192
224
|
allResults.push(...results);
|
|
193
225
|
}
|
|
194
|
-
outputScanResults(allResults, opts, `files changed since ${ref}`);
|
|
226
|
+
outputScanResults(applyBaseline(allResults, baselineEntries), opts, `files changed since ${ref}`);
|
|
195
227
|
}
|
|
196
228
|
catch (error) {
|
|
197
229
|
if (error.status === 128) {
|
|
@@ -204,7 +236,7 @@ async function scanDiffFiles(ref, opts, scanCfg) {
|
|
|
204
236
|
/**
|
|
205
237
|
* Scan git staged files for secrets
|
|
206
238
|
*/
|
|
207
|
-
async function scanStagedFiles(opts, scanCfg) {
|
|
239
|
+
async function scanStagedFiles(opts, scanCfg, baselineEntries = []) {
|
|
208
240
|
try {
|
|
209
241
|
const stagedFilesOutput = execSync("git diff --cached --name-only --diff-filter=ACM", {
|
|
210
242
|
encoding: "utf-8",
|
|
@@ -232,7 +264,7 @@ async function scanStagedFiles(opts, scanCfg) {
|
|
|
232
264
|
const results = await scanFile(filePath, engine, scanCfg);
|
|
233
265
|
allResults.push(...results);
|
|
234
266
|
}
|
|
235
|
-
outputScanResults(allResults, opts, "staged files");
|
|
267
|
+
outputScanResults(applyBaseline(allResults, baselineEntries), opts, "staged files");
|
|
236
268
|
}
|
|
237
269
|
catch (error) {
|
|
238
270
|
if (error.status === 128) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { BinaryManager, GITLEAKS_VERSION } from "../../utils/binary-manager.js";
|
|
3
|
+
import { fmt } from "../../utils/formatter.js";
|
|
4
|
+
export function createUpdateGitleaksCommand() {
|
|
5
|
+
return new Command("update-gitleaks")
|
|
6
|
+
.description("Update (or reinstall) the managed gitleaks binary")
|
|
7
|
+
.option("--version <version>", "Gitleaks version to install", GITLEAKS_VERSION)
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
const bm = new BinaryManager();
|
|
10
|
+
if (!bm.isPlatformSupported()) {
|
|
11
|
+
const { platform, arch } = bm.getPlatformInfo();
|
|
12
|
+
console.error(fmt.error(`Gitleaks not available for ${platform}/${arch}`));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
// Show current version if installed
|
|
16
|
+
if (bm.isGitleaksInstalled()) {
|
|
17
|
+
const current = await bm.getGitleaksVersion();
|
|
18
|
+
console.log(fmt.info(`Current: ${current}`));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.log(fmt.info("Gitleaks not currently installed (managed binary)"));
|
|
22
|
+
}
|
|
23
|
+
console.log(fmt.info(`Installing gitleaks v${opts.version}...`));
|
|
24
|
+
console.log();
|
|
25
|
+
try {
|
|
26
|
+
await bm.downloadGitleaks((msg) => console.log(` ${msg}`), opts.version);
|
|
27
|
+
console.log();
|
|
28
|
+
const installed = await bm.getGitleaksVersion();
|
|
29
|
+
console.log(fmt.success(`Gitleaks updated: ${installed}`));
|
|
30
|
+
console.log(fmt.info(` Binary: ${bm.getGitleaksPath()}`));
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.log();
|
|
34
|
+
console.error(fmt.error(`Update failed: ${e}`));
|
|
35
|
+
console.log(fmt.info("To fix: install gitleaks manually (https://github.com/gitleaks/gitleaks/releases) " +
|
|
36
|
+
"and ensure it is on PATH."));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|