@rafter-security/cli 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/dist/commands/agent/audit.js +15 -3
- package/dist/commands/agent/exec.js +9 -8
- package/dist/commands/agent/init.js +62 -26
- package/dist/commands/agent/scan.js +113 -101
- package/dist/commands/ci/index.js +8 -0
- package/dist/commands/ci/init.js +191 -0
- package/dist/commands/hook/index.js +8 -0
- package/dist/commands/hook/pretool.js +122 -0
- package/dist/commands/mcp/index.js +8 -0
- package/dist/commands/mcp/server.js +205 -0
- package/dist/commands/policy/export.js +81 -0
- package/dist/commands/policy/index.js +8 -0
- package/dist/core/audit-logger.js +2 -33
- package/dist/core/command-interceptor.js +6 -50
- package/dist/core/config-defaults.js +4 -15
- package/dist/core/config-manager.js +52 -0
- package/dist/core/policy-loader.js +167 -0
- package/dist/core/risk-rules.js +67 -0
- package/dist/index.js +23 -2
- package/dist/scanners/gitleaks.js +7 -6
- package/dist/scanners/regex-scanner.js +22 -2
- package/dist/utils/formatter.js +52 -0
- package/package.json +7 -3
- package/resources/pre-commit-hook.sh +45 -0
- package/resources/rafter-security-skill.md +316 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { RegexScanner } from "../../scanners/regex-scanner.js";
|
|
3
3
|
import { GitleaksScanner } from "../../scanners/gitleaks.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ConfigManager } from "../../core/config-manager.js";
|
|
5
|
+
import { execSync, execFileSync } from "child_process";
|
|
5
6
|
import fs from "fs";
|
|
6
7
|
import path from "path";
|
|
8
|
+
import { fmt } from "../../utils/formatter.js";
|
|
7
9
|
export function createScanCommand() {
|
|
8
10
|
return new Command("scan")
|
|
9
11
|
.description("Scan files or directories for secrets")
|
|
@@ -11,11 +13,21 @@ export function createScanCommand() {
|
|
|
11
13
|
.option("-q, --quiet", "Only output if secrets found")
|
|
12
14
|
.option("--json", "Output as JSON")
|
|
13
15
|
.option("--staged", "Scan only git staged files")
|
|
16
|
+
.option("--diff <ref>", "Scan files changed since a git ref")
|
|
14
17
|
.option("--engine <engine>", "Scan engine: gitleaks or patterns", "auto")
|
|
15
18
|
.action(async (scanPath, opts) => {
|
|
19
|
+
// Load policy-merged config for excludePaths/customPatterns
|
|
20
|
+
const manager = new ConfigManager();
|
|
21
|
+
const cfg = manager.loadWithPolicy();
|
|
22
|
+
const scanCfg = cfg.agent?.scan;
|
|
23
|
+
// Handle --diff flag
|
|
24
|
+
if (opts.diff) {
|
|
25
|
+
await scanDiffFiles(opts.diff, opts, scanCfg);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
16
28
|
// Handle --staged flag
|
|
17
29
|
if (opts.staged) {
|
|
18
|
-
await scanStagedFiles(opts);
|
|
30
|
+
await scanStagedFiles(opts, scanCfg);
|
|
19
31
|
return;
|
|
20
32
|
}
|
|
21
33
|
const resolvedPath = path.resolve(scanPath);
|
|
@@ -25,7 +37,7 @@ export function createScanCommand() {
|
|
|
25
37
|
process.exit(1);
|
|
26
38
|
}
|
|
27
39
|
// Determine scan engine
|
|
28
|
-
const engine = await selectEngine(opts.engine, opts.quiet);
|
|
40
|
+
const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
|
|
29
41
|
// Determine if path is file or directory
|
|
30
42
|
const stats = fs.statSync(resolvedPath);
|
|
31
43
|
let results;
|
|
@@ -33,61 +45,109 @@ export function createScanCommand() {
|
|
|
33
45
|
if (!opts.quiet) {
|
|
34
46
|
console.error(`Scanning directory: ${resolvedPath} (${engine})`);
|
|
35
47
|
}
|
|
36
|
-
results = await scanDirectory(resolvedPath, engine);
|
|
48
|
+
results = await scanDirectory(resolvedPath, engine, scanCfg);
|
|
37
49
|
}
|
|
38
50
|
else {
|
|
39
51
|
if (!opts.quiet) {
|
|
40
52
|
console.error(`Scanning file: ${resolvedPath} (${engine})`);
|
|
41
53
|
}
|
|
42
|
-
results = await scanFile(resolvedPath, engine);
|
|
54
|
+
results = await scanFile(resolvedPath, engine, scanCfg);
|
|
43
55
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
outputScanResults(results, opts);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Shared output logic for scan results
|
|
61
|
+
*/
|
|
62
|
+
function outputScanResults(results, opts, context) {
|
|
63
|
+
if (opts.json) {
|
|
64
|
+
console.log(JSON.stringify(results, null, 2));
|
|
65
|
+
process.exit(results.length > 0 ? 1 : 0);
|
|
66
|
+
}
|
|
67
|
+
if (results.length === 0) {
|
|
68
|
+
if (!opts.quiet) {
|
|
69
|
+
const msg = context ? `No secrets detected in ${context}` : "No secrets detected";
|
|
70
|
+
console.log(`\n${fmt.success(msg)}\n`);
|
|
47
71
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
console.log(`\n${fmt.warning(`Found secrets in ${results.length} file(s):`)}\n`);
|
|
75
|
+
let totalMatches = 0;
|
|
76
|
+
for (const result of results) {
|
|
77
|
+
console.log(`\n${fmt.info(result.file)}`);
|
|
78
|
+
for (const match of result.matches) {
|
|
79
|
+
totalMatches++;
|
|
80
|
+
const location = match.line ? `Line ${match.line}` : "Unknown location";
|
|
81
|
+
const sev = fmt.severity(match.pattern.severity);
|
|
82
|
+
console.log(` ${sev} ${match.pattern.name}`);
|
|
83
|
+
console.log(` Location: ${location}`);
|
|
84
|
+
console.log(` Pattern: ${match.pattern.description || match.pattern.regex}`);
|
|
85
|
+
console.log(` Redacted: ${match.redacted}`);
|
|
86
|
+
console.log();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
console.log(`\n${fmt.warning(`Total: ${totalMatches} secret(s) detected in ${results.length} file(s)`)}\n`);
|
|
90
|
+
if (context === "staged files") {
|
|
91
|
+
console.log(`${fmt.error("Commit blocked. Remove secrets before committing.")}\n`);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
console.log(`Run 'rafter agent audit' to see the security log.\n`);
|
|
95
|
+
}
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Scan files changed since a git ref
|
|
100
|
+
*/
|
|
101
|
+
async function scanDiffFiles(ref, opts, scanCfg) {
|
|
102
|
+
try {
|
|
103
|
+
const diffOutput = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACM", ref], {
|
|
104
|
+
encoding: "utf-8",
|
|
105
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
106
|
+
}).trim();
|
|
107
|
+
if (!diffOutput) {
|
|
108
|
+
if (!opts.quiet) {
|
|
109
|
+
console.log(fmt.success(`No files changed since ${ref}`));
|
|
74
110
|
}
|
|
111
|
+
process.exit(0);
|
|
75
112
|
}
|
|
76
|
-
|
|
113
|
+
const changedFiles = diffOutput.split("\n").map(f => f.trim()).filter(f => f);
|
|
114
|
+
if (!opts.quiet) {
|
|
115
|
+
console.error(`Scanning ${changedFiles.length} file(s) changed since ${ref}...`);
|
|
116
|
+
}
|
|
117
|
+
const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
|
|
118
|
+
const allResults = [];
|
|
119
|
+
for (const file of changedFiles) {
|
|
120
|
+
const filePath = path.resolve(file);
|
|
121
|
+
if (!fs.existsSync(filePath))
|
|
122
|
+
continue;
|
|
123
|
+
const stats = fs.statSync(filePath);
|
|
124
|
+
if (!stats.isFile())
|
|
125
|
+
continue;
|
|
126
|
+
const results = await scanFile(filePath, engine, scanCfg);
|
|
127
|
+
allResults.push(...results);
|
|
128
|
+
}
|
|
129
|
+
outputScanResults(allResults, opts, `files changed since ${ref}`);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.status === 128) {
|
|
133
|
+
console.error("Error: Not in a git repository or invalid ref");
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
77
138
|
}
|
|
78
139
|
/**
|
|
79
140
|
* Scan git staged files for secrets
|
|
80
141
|
*/
|
|
81
|
-
async function scanStagedFiles(opts) {
|
|
142
|
+
async function scanStagedFiles(opts, scanCfg) {
|
|
82
143
|
try {
|
|
83
|
-
// Get list of staged files
|
|
84
144
|
const stagedFilesOutput = execSync("git diff --cached --name-only --diff-filter=ACM", {
|
|
85
145
|
encoding: "utf-8",
|
|
86
146
|
stdio: ["pipe", "pipe", "ignore"]
|
|
87
147
|
}).trim();
|
|
88
148
|
if (!stagedFilesOutput) {
|
|
89
149
|
if (!opts.quiet) {
|
|
90
|
-
console.log("
|
|
150
|
+
console.log(fmt.success("No files staged for commit"));
|
|
91
151
|
}
|
|
92
152
|
process.exit(0);
|
|
93
153
|
}
|
|
@@ -95,56 +155,19 @@ async function scanStagedFiles(opts) {
|
|
|
95
155
|
if (!opts.quiet) {
|
|
96
156
|
console.error(`Scanning ${stagedFiles.length} staged file(s)...`);
|
|
97
157
|
}
|
|
98
|
-
// Determine scan engine
|
|
99
158
|
const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
|
|
100
|
-
// Scan each staged file
|
|
101
159
|
const allResults = [];
|
|
102
160
|
for (const file of stagedFiles) {
|
|
103
161
|
const filePath = path.resolve(file);
|
|
104
|
-
|
|
105
|
-
if (!fs.existsSync(filePath)) {
|
|
162
|
+
if (!fs.existsSync(filePath))
|
|
106
163
|
continue;
|
|
107
|
-
}
|
|
108
|
-
// Skip if not a regular file
|
|
109
164
|
const stats = fs.statSync(filePath);
|
|
110
|
-
if (!stats.isFile())
|
|
165
|
+
if (!stats.isFile())
|
|
111
166
|
continue;
|
|
112
|
-
|
|
113
|
-
const results = await scanFile(filePath, engine);
|
|
167
|
+
const results = await scanFile(filePath, engine, scanCfg);
|
|
114
168
|
allResults.push(...results);
|
|
115
169
|
}
|
|
116
|
-
|
|
117
|
-
if (opts.json) {
|
|
118
|
-
console.log(JSON.stringify(allResults, null, 2));
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
if (allResults.length === 0) {
|
|
122
|
-
if (!opts.quiet) {
|
|
123
|
-
console.log("\n✓ No secrets detected in staged files\n");
|
|
124
|
-
}
|
|
125
|
-
process.exit(0);
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
console.log(`\n⚠️ Found secrets in ${allResults.length} staged file(s):\n`);
|
|
129
|
-
let totalMatches = 0;
|
|
130
|
-
for (const result of allResults) {
|
|
131
|
-
console.log(`\n📄 ${result.file}`);
|
|
132
|
-
for (const match of result.matches) {
|
|
133
|
-
totalMatches++;
|
|
134
|
-
const location = match.line ? `Line ${match.line}` : "Unknown location";
|
|
135
|
-
const severity = getSeverityEmoji(match.pattern.severity);
|
|
136
|
-
console.log(` ${severity} [${match.pattern.severity.toUpperCase()}] ${match.pattern.name}`);
|
|
137
|
-
console.log(` Location: ${location}`);
|
|
138
|
-
console.log(` Pattern: ${match.pattern.description || match.pattern.regex}`);
|
|
139
|
-
console.log(` Redacted: ${match.redacted}`);
|
|
140
|
-
console.log();
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
console.log(`\n⚠️ Total: ${totalMatches} secret(s) detected in ${allResults.length} file(s)\n`);
|
|
144
|
-
console.log("❌ Commit blocked. Remove secrets before committing.\n");
|
|
145
|
-
process.exit(1);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
170
|
+
outputScanResults(allResults, opts, "staged files");
|
|
148
171
|
}
|
|
149
172
|
catch (error) {
|
|
150
173
|
if (error.status === 128) {
|
|
@@ -154,15 +177,6 @@ async function scanStagedFiles(opts) {
|
|
|
154
177
|
throw error;
|
|
155
178
|
}
|
|
156
179
|
}
|
|
157
|
-
function getSeverityEmoji(severity) {
|
|
158
|
-
const emojiMap = {
|
|
159
|
-
critical: "🔴",
|
|
160
|
-
high: "🟠",
|
|
161
|
-
medium: "🟡",
|
|
162
|
-
low: "🟢"
|
|
163
|
-
};
|
|
164
|
-
return emojiMap[severity] || "⚪";
|
|
165
|
-
}
|
|
166
180
|
/**
|
|
167
181
|
* Select scan engine based on availability and user preference
|
|
168
182
|
*/
|
|
@@ -175,7 +189,7 @@ async function selectEngine(preference, quiet) {
|
|
|
175
189
|
const available = await gitleaks.isAvailable();
|
|
176
190
|
if (!available) {
|
|
177
191
|
if (!quiet) {
|
|
178
|
-
console.error("
|
|
192
|
+
console.error(fmt.warning("Gitleaks requested but not available, using patterns"));
|
|
179
193
|
}
|
|
180
194
|
return "patterns";
|
|
181
195
|
}
|
|
@@ -189,7 +203,7 @@ async function selectEngine(preference, quiet) {
|
|
|
189
203
|
/**
|
|
190
204
|
* Scan a file with selected engine
|
|
191
205
|
*/
|
|
192
|
-
async function scanFile(filePath, engine) {
|
|
206
|
+
async function scanFile(filePath, engine, scanCfg) {
|
|
193
207
|
if (engine === "gitleaks") {
|
|
194
208
|
try {
|
|
195
209
|
const gitleaks = new GitleaksScanner();
|
|
@@ -197,15 +211,14 @@ async function scanFile(filePath, engine) {
|
|
|
197
211
|
return result.matches.length > 0 ? [result] : [];
|
|
198
212
|
}
|
|
199
213
|
catch (e) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const scanner = new RegexScanner();
|
|
214
|
+
console.error(fmt.warning("Gitleaks scan failed, falling back to patterns"));
|
|
215
|
+
const scanner = new RegexScanner(scanCfg?.customPatterns);
|
|
203
216
|
const result = scanner.scanFile(filePath);
|
|
204
217
|
return result.matches.length > 0 ? [result] : [];
|
|
205
218
|
}
|
|
206
219
|
}
|
|
207
220
|
else {
|
|
208
|
-
const scanner = new RegexScanner();
|
|
221
|
+
const scanner = new RegexScanner(scanCfg?.customPatterns);
|
|
209
222
|
const result = scanner.scanFile(filePath);
|
|
210
223
|
return result.matches.length > 0 ? [result] : [];
|
|
211
224
|
}
|
|
@@ -213,21 +226,20 @@ async function scanFile(filePath, engine) {
|
|
|
213
226
|
/**
|
|
214
227
|
* Scan a directory with selected engine
|
|
215
228
|
*/
|
|
216
|
-
async function scanDirectory(dirPath, engine) {
|
|
229
|
+
async function scanDirectory(dirPath, engine, scanCfg) {
|
|
217
230
|
if (engine === "gitleaks") {
|
|
218
231
|
try {
|
|
219
232
|
const gitleaks = new GitleaksScanner();
|
|
220
233
|
return await gitleaks.scanDirectory(dirPath);
|
|
221
234
|
}
|
|
222
235
|
catch (e) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return scanner.scanDirectory(dirPath);
|
|
236
|
+
console.error(fmt.warning("Gitleaks scan failed, falling back to patterns"));
|
|
237
|
+
const scanner = new RegexScanner(scanCfg?.customPatterns);
|
|
238
|
+
return scanner.scanDirectory(dirPath, { excludePaths: scanCfg?.excludePaths });
|
|
227
239
|
}
|
|
228
240
|
}
|
|
229
241
|
else {
|
|
230
|
-
const scanner = new RegexScanner();
|
|
231
|
-
return scanner.scanDirectory(dirPath);
|
|
242
|
+
const scanner = new RegexScanner(scanCfg?.customPatterns);
|
|
243
|
+
return scanner.scanDirectory(dirPath, { excludePaths: scanCfg?.excludePaths });
|
|
232
244
|
}
|
|
233
245
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fmt } from "../../utils/formatter.js";
|
|
5
|
+
export function createCiInitCommand() {
|
|
6
|
+
return new Command("init")
|
|
7
|
+
.description("Generate CI/CD pipeline config for secret scanning")
|
|
8
|
+
.option("--platform <platform>", "CI platform: github, gitlab, circleci (default: auto-detect)")
|
|
9
|
+
.option("--output <path>", "Output file path (default: platform-specific)")
|
|
10
|
+
.option("--with-backend", "Include backend security audit job (requires RAFTER_API_KEY)")
|
|
11
|
+
.action((opts) => {
|
|
12
|
+
const platform = opts.platform || detectPlatform();
|
|
13
|
+
if (!platform) {
|
|
14
|
+
console.error(fmt.error("Could not auto-detect CI platform."));
|
|
15
|
+
console.error("Specify one with --platform github|gitlab|circleci");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const validPlatforms = ["github", "gitlab", "circleci"];
|
|
19
|
+
if (!validPlatforms.includes(platform)) {
|
|
20
|
+
console.error(fmt.error(`Unknown platform: ${platform}`));
|
|
21
|
+
console.error(`Valid options: ${validPlatforms.join(", ")}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const { content, defaultPath } = generateTemplate(platform, !!opts.withBackend);
|
|
25
|
+
const outputPath = opts.output || defaultPath;
|
|
26
|
+
const outputDir = path.dirname(outputPath);
|
|
27
|
+
if (!fs.existsSync(outputDir)) {
|
|
28
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
fs.writeFileSync(outputPath, content, "utf-8");
|
|
31
|
+
console.log(fmt.success(`Generated ${platform} CI config at ${outputPath}`));
|
|
32
|
+
console.log();
|
|
33
|
+
console.log("Next steps:");
|
|
34
|
+
console.log(` 1. Review the generated file: ${outputPath}`);
|
|
35
|
+
if (opts.withBackend) {
|
|
36
|
+
if (platform === "github") {
|
|
37
|
+
console.log(" 2. Add RAFTER_API_KEY to repo Settings > Secrets > Actions");
|
|
38
|
+
}
|
|
39
|
+
else if (platform === "gitlab") {
|
|
40
|
+
console.log(" 2. Add RAFTER_API_KEY to Settings > CI/CD > Variables");
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(" 2. Add RAFTER_API_KEY to project environment variables");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
console.log(` ${opts.withBackend ? "3" : "2"}. Commit and push to trigger the pipeline`);
|
|
47
|
+
console.log();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function detectPlatform() {
|
|
51
|
+
if (fs.existsSync(".github"))
|
|
52
|
+
return "github";
|
|
53
|
+
if (fs.existsSync(".gitlab-ci.yml"))
|
|
54
|
+
return "gitlab";
|
|
55
|
+
if (fs.existsSync(".circleci"))
|
|
56
|
+
return "circleci";
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function generateTemplate(platform, withBackend) {
|
|
60
|
+
switch (platform) {
|
|
61
|
+
case "github":
|
|
62
|
+
return { content: githubTemplate(withBackend), defaultPath: ".github/workflows/rafter-security.yml" };
|
|
63
|
+
case "gitlab":
|
|
64
|
+
return { content: gitlabTemplate(withBackend), defaultPath: ".gitlab-ci-rafter.yml" };
|
|
65
|
+
case "circleci":
|
|
66
|
+
return { content: circleciTemplate(withBackend), defaultPath: ".circleci/rafter-security.yml" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function githubTemplate(withBackend) {
|
|
70
|
+
let yaml = `# Generated by: rafter ci init
|
|
71
|
+
name: Rafter Security
|
|
72
|
+
|
|
73
|
+
on:
|
|
74
|
+
push:
|
|
75
|
+
branches: [main]
|
|
76
|
+
pull_request:
|
|
77
|
+
branches: [main]
|
|
78
|
+
|
|
79
|
+
permissions:
|
|
80
|
+
contents: read
|
|
81
|
+
|
|
82
|
+
jobs:
|
|
83
|
+
secret-scan:
|
|
84
|
+
runs-on: ubuntu-latest
|
|
85
|
+
steps:
|
|
86
|
+
- uses: actions/checkout@v4
|
|
87
|
+
|
|
88
|
+
- name: Install Rafter CLI
|
|
89
|
+
run: npm install -g @rafter-security/cli
|
|
90
|
+
|
|
91
|
+
- name: Scan for secrets
|
|
92
|
+
run: rafter agent scan . --quiet
|
|
93
|
+
`;
|
|
94
|
+
if (withBackend) {
|
|
95
|
+
yaml += `
|
|
96
|
+
security-audit:
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
needs: secret-scan
|
|
99
|
+
steps:
|
|
100
|
+
- uses: actions/checkout@v4
|
|
101
|
+
|
|
102
|
+
- name: Install Rafter CLI
|
|
103
|
+
run: npm install -g @rafter-security/cli
|
|
104
|
+
|
|
105
|
+
- name: Run security audit
|
|
106
|
+
env:
|
|
107
|
+
RAFTER_API_KEY: \${{ secrets.RAFTER_API_KEY }}
|
|
108
|
+
run: rafter run --format json --quiet
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
return yaml;
|
|
112
|
+
}
|
|
113
|
+
function gitlabTemplate(withBackend) {
|
|
114
|
+
let yaml = `# Generated by: rafter ci init
|
|
115
|
+
stages:
|
|
116
|
+
- security
|
|
117
|
+
|
|
118
|
+
secret-scan:
|
|
119
|
+
stage: security
|
|
120
|
+
image: node:20
|
|
121
|
+
script:
|
|
122
|
+
- npm install -g @rafter-security/cli
|
|
123
|
+
- rafter agent scan . --quiet
|
|
124
|
+
rules:
|
|
125
|
+
- if: $CI_PIPELINE_SOURCE == "push"
|
|
126
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
127
|
+
`;
|
|
128
|
+
if (withBackend) {
|
|
129
|
+
yaml += `
|
|
130
|
+
security-audit:
|
|
131
|
+
stage: security
|
|
132
|
+
image: node:20
|
|
133
|
+
needs: [secret-scan]
|
|
134
|
+
script:
|
|
135
|
+
- npm install -g @rafter-security/cli
|
|
136
|
+
- rafter run --format json --quiet
|
|
137
|
+
variables:
|
|
138
|
+
RAFTER_API_KEY: $RAFTER_API_KEY
|
|
139
|
+
rules:
|
|
140
|
+
- if: $CI_PIPELINE_SOURCE == "push"
|
|
141
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
return yaml;
|
|
145
|
+
}
|
|
146
|
+
function circleciTemplate(withBackend) {
|
|
147
|
+
let yaml = `# Generated by: rafter ci init
|
|
148
|
+
version: 2.1
|
|
149
|
+
|
|
150
|
+
jobs:
|
|
151
|
+
secret-scan:
|
|
152
|
+
docker:
|
|
153
|
+
- image: cimg/node:20.0
|
|
154
|
+
steps:
|
|
155
|
+
- checkout
|
|
156
|
+
- run:
|
|
157
|
+
name: Install Rafter CLI
|
|
158
|
+
command: npm install -g @rafter-security/cli
|
|
159
|
+
- run:
|
|
160
|
+
name: Scan for secrets
|
|
161
|
+
command: rafter agent scan . --quiet
|
|
162
|
+
`;
|
|
163
|
+
if (withBackend) {
|
|
164
|
+
yaml += `
|
|
165
|
+
security-audit:
|
|
166
|
+
docker:
|
|
167
|
+
- image: cimg/node:20.0
|
|
168
|
+
steps:
|
|
169
|
+
- checkout
|
|
170
|
+
- run:
|
|
171
|
+
name: Install Rafter CLI
|
|
172
|
+
command: npm install -g @rafter-security/cli
|
|
173
|
+
- run:
|
|
174
|
+
name: Run security audit
|
|
175
|
+
command: rafter run --format json --quiet
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
yaml += `
|
|
179
|
+
workflows:
|
|
180
|
+
security:
|
|
181
|
+
jobs:
|
|
182
|
+
- secret-scan`;
|
|
183
|
+
if (withBackend) {
|
|
184
|
+
yaml += `
|
|
185
|
+
- security-audit:
|
|
186
|
+
requires:
|
|
187
|
+
- secret-scan`;
|
|
188
|
+
}
|
|
189
|
+
yaml += "\n";
|
|
190
|
+
return yaml;
|
|
191
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createHookPretoolCommand } from "./pretool.js";
|
|
3
|
+
export function createHookCommand() {
|
|
4
|
+
const hook = new Command("hook")
|
|
5
|
+
.description("Hook handlers for agent platform integration");
|
|
6
|
+
hook.addCommand(createHookPretoolCommand());
|
|
7
|
+
return hook;
|
|
8
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { CommandInterceptor } from "../../core/command-interceptor.js";
|
|
3
|
+
import { RegexScanner } from "../../scanners/regex-scanner.js";
|
|
4
|
+
import { AuditLogger } from "../../core/audit-logger.js";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
export function createHookPretoolCommand() {
|
|
7
|
+
return new Command("pretool")
|
|
8
|
+
.description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
const input = await readStdin();
|
|
11
|
+
let payload;
|
|
12
|
+
try {
|
|
13
|
+
payload = JSON.parse(input);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Can't parse → fail open
|
|
17
|
+
writeDecision({ decision: "allow" });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const decision = evaluateToolCall(payload);
|
|
21
|
+
writeDecision(decision);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function evaluateToolCall(payload) {
|
|
25
|
+
const { tool_name, tool_input } = payload;
|
|
26
|
+
if (tool_name === "Bash") {
|
|
27
|
+
return evaluateBash(tool_input.command || "");
|
|
28
|
+
}
|
|
29
|
+
if (tool_name === "Write" || tool_name === "Edit") {
|
|
30
|
+
return evaluateWrite(tool_input);
|
|
31
|
+
}
|
|
32
|
+
return { decision: "allow" };
|
|
33
|
+
}
|
|
34
|
+
function evaluateBash(command) {
|
|
35
|
+
const interceptor = new CommandInterceptor();
|
|
36
|
+
const audit = new AuditLogger();
|
|
37
|
+
const evaluation = interceptor.evaluate(command);
|
|
38
|
+
// Blocked — hard deny
|
|
39
|
+
if (!evaluation.allowed && !evaluation.requiresApproval) {
|
|
40
|
+
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
|
|
41
|
+
return {
|
|
42
|
+
decision: "deny",
|
|
43
|
+
reason: `Blocked by Rafter policy: ${evaluation.reason}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Requires approval — deny (agent can't provide interactive approval)
|
|
47
|
+
if (evaluation.requiresApproval) {
|
|
48
|
+
audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
|
|
49
|
+
return {
|
|
50
|
+
decision: "deny",
|
|
51
|
+
reason: `Rafter policy requires approval: ${evaluation.reason}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Git commit/push — scan staged files for secrets
|
|
55
|
+
const trimmed = command.trim();
|
|
56
|
+
if (trimmed.startsWith("git commit") || trimmed.startsWith("git push")) {
|
|
57
|
+
const scanResult = scanStagedFiles();
|
|
58
|
+
if (scanResult.secretsFound) {
|
|
59
|
+
audit.logSecretDetected("staged files", `${scanResult.count} secret(s)`, "blocked");
|
|
60
|
+
return {
|
|
61
|
+
decision: "deny",
|
|
62
|
+
reason: `${scanResult.count} secret(s) detected in ${scanResult.files} staged file(s). Run 'rafter agent scan --staged' for details.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
audit.logCommandIntercepted(command, true, "allowed");
|
|
67
|
+
return { decision: "allow" };
|
|
68
|
+
}
|
|
69
|
+
function evaluateWrite(toolInput) {
|
|
70
|
+
// Write uses "content", Edit uses "new_string"
|
|
71
|
+
const content = toolInput.content || toolInput.new_string || "";
|
|
72
|
+
if (!content) {
|
|
73
|
+
return { decision: "allow" };
|
|
74
|
+
}
|
|
75
|
+
const scanner = new RegexScanner();
|
|
76
|
+
if (scanner.hasSecrets(content)) {
|
|
77
|
+
const matches = scanner.scanText(content);
|
|
78
|
+
const names = [...new Set(matches.map(m => m.pattern.name))];
|
|
79
|
+
const audit = new AuditLogger();
|
|
80
|
+
audit.logSecretDetected(toolInput.file_path || "file content", names.join(", "), "blocked");
|
|
81
|
+
return {
|
|
82
|
+
decision: "deny",
|
|
83
|
+
reason: `Secret detected in file content: ${names.join(", ")}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { decision: "allow" };
|
|
87
|
+
}
|
|
88
|
+
function scanStagedFiles() {
|
|
89
|
+
try {
|
|
90
|
+
const stagedOutput = execSync("git diff --cached --name-only --diff-filter=ACM", {
|
|
91
|
+
encoding: "utf-8",
|
|
92
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
93
|
+
}).trim();
|
|
94
|
+
if (!stagedOutput) {
|
|
95
|
+
return { secretsFound: false, count: 0, files: 0 };
|
|
96
|
+
}
|
|
97
|
+
const stagedFiles = stagedOutput.split("\n").filter(f => f.trim());
|
|
98
|
+
const scanner = new RegexScanner();
|
|
99
|
+
const results = scanner.scanFiles(stagedFiles);
|
|
100
|
+
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
|
|
101
|
+
return {
|
|
102
|
+
secretsFound: results.length > 0,
|
|
103
|
+
count: totalMatches,
|
|
104
|
+
files: results.length,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { secretsFound: false, count: 0, files: 0 };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function readStdin() {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
let data = "";
|
|
114
|
+
process.stdin.setEncoding("utf-8");
|
|
115
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
116
|
+
process.stdin.on("end", () => { resolve(data); });
|
|
117
|
+
process.stdin.resume();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function writeDecision(decision) {
|
|
121
|
+
process.stdout.write(JSON.stringify(decision) + "\n");
|
|
122
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createMcpServeCommand } from "./server.js";
|
|
3
|
+
export function createMcpCommand() {
|
|
4
|
+
const mcp = new Command("mcp")
|
|
5
|
+
.description("MCP server for cross-platform security tools");
|
|
6
|
+
mcp.addCommand(createMcpServeCommand());
|
|
7
|
+
return mcp;
|
|
8
|
+
}
|