@rafter-security/cli 0.5.5 → 0.6.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 +15 -3
- package/dist/commands/agent/audit-skill.js +1 -1
- package/dist/commands/agent/audit.js +106 -6
- package/dist/commands/agent/baseline.js +10 -0
- package/dist/commands/agent/exec.js +1 -1
- package/dist/commands/agent/init.js +366 -26
- package/dist/commands/agent/scan.js +160 -16
- package/dist/commands/agent/status.js +65 -4
- package/dist/commands/agent/verify.js +18 -4
- package/dist/commands/backend/run.js +76 -62
- package/dist/commands/ci/init.js +10 -3
- package/dist/commands/completion.js +21 -9
- package/dist/commands/hook/posttool.js +21 -7
- package/dist/commands/hook/pretool.js +50 -13
- package/dist/commands/issues/dedup.js +39 -0
- package/dist/commands/issues/from-scan.js +143 -0
- package/dist/commands/issues/from-text.js +185 -0
- package/dist/commands/issues/github-client.js +85 -0
- package/dist/commands/issues/index.js +25 -0
- package/dist/commands/issues/issue-builder.js +101 -0
- package/dist/commands/mcp/server.js +4 -1
- package/dist/commands/policy/export.js +7 -2
- package/dist/commands/scan/index.js +45 -0
- package/dist/core/audit-logger.js +106 -7
- package/dist/core/config-defaults.js +24 -0
- package/dist/core/config-manager.js +116 -3
- package/dist/core/custom-patterns.js +20 -17
- package/dist/core/pattern-engine.js +26 -1
- package/dist/core/policy-loader.js +25 -2
- package/dist/index.js +11 -2
- package/dist/scanners/gitleaks.js +8 -7
- package/dist/scanners/regex-scanner.js +16 -1
- package/dist/scanners/secret-patterns.js +6 -6
- package/dist/utils/api.js +18 -0
- package/dist/utils/binary-manager.js +74 -7
- package/dist/utils/skill-manager.js +5 -3
- package/package.json +5 -3
- package/resources/pre-commit-hook.sh +2 -2
- package/resources/pre-push-hook.sh +2 -2
- package/resources/rafter-security-skill.md +7 -11
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rafter issues — GitHub Issues integration.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* rafter issues create --from-scan <id> Create issues from backend scan results
|
|
6
|
+
* rafter issues create --from-local <path> Create issues from local scan JSON
|
|
7
|
+
* rafter issues create --from-text Create issue from natural text (stdin/file/inline)
|
|
8
|
+
*/
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { createFromScanCommand } from "./from-scan.js";
|
|
11
|
+
import { createFromTextCommand } from "./from-text.js";
|
|
12
|
+
export function createIssuesCommand() {
|
|
13
|
+
const issuesGroup = new Command("issues")
|
|
14
|
+
.description("GitHub Issues integration — create issues from scan results or text");
|
|
15
|
+
const createGroup = new Command("create")
|
|
16
|
+
.description("Create GitHub issues from scan findings or natural text");
|
|
17
|
+
createGroup.addCommand(createFromScanCommand());
|
|
18
|
+
createGroup.addCommand(createFromTextCommand());
|
|
19
|
+
// Default action for `rafter issues create` with no subcommand — show help
|
|
20
|
+
createGroup.action(() => {
|
|
21
|
+
createGroup.help();
|
|
22
|
+
});
|
|
23
|
+
issuesGroup.addCommand(createGroup);
|
|
24
|
+
return issuesGroup;
|
|
25
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build structured GitHub issues from scan findings.
|
|
3
|
+
*
|
|
4
|
+
* Handles both:
|
|
5
|
+
* - Backend scan vulnerabilities (SAST/policy findings)
|
|
6
|
+
* - Local scan results (secret detection)
|
|
7
|
+
*/
|
|
8
|
+
import { fingerprint, embedFingerprint } from "./dedup.js";
|
|
9
|
+
function severityLabel(level) {
|
|
10
|
+
const map = {
|
|
11
|
+
error: "critical",
|
|
12
|
+
critical: "critical",
|
|
13
|
+
warning: "high",
|
|
14
|
+
high: "high",
|
|
15
|
+
note: "medium",
|
|
16
|
+
medium: "medium",
|
|
17
|
+
low: "low",
|
|
18
|
+
};
|
|
19
|
+
return map[level.toLowerCase()] || "medium";
|
|
20
|
+
}
|
|
21
|
+
function severityEmoji(level) {
|
|
22
|
+
const sev = severityLabel(level);
|
|
23
|
+
const emojis = {
|
|
24
|
+
critical: "🔴",
|
|
25
|
+
high: "🟠",
|
|
26
|
+
medium: "🟡",
|
|
27
|
+
low: "🟢",
|
|
28
|
+
};
|
|
29
|
+
return emojis[sev] || "🟡";
|
|
30
|
+
}
|
|
31
|
+
export function buildFromBackendVulnerability(vuln) {
|
|
32
|
+
const sev = severityLabel(vuln.level);
|
|
33
|
+
const emoji = severityEmoji(vuln.level);
|
|
34
|
+
const fp = fingerprint(vuln.file, vuln.ruleId);
|
|
35
|
+
const title = `${emoji} [${sev.toUpperCase()}] ${vuln.ruleId}: ${truncate(vuln.message, 80)}`;
|
|
36
|
+
let body = `## Security Finding\n\n`;
|
|
37
|
+
body += `**Rule:** \`${vuln.ruleId}\`\n`;
|
|
38
|
+
body += `**Severity:** ${sev}\n`;
|
|
39
|
+
body += `**File:** \`${vuln.file}\``;
|
|
40
|
+
if (vuln.line)
|
|
41
|
+
body += ` (line ${vuln.line})`;
|
|
42
|
+
body += `\n\n`;
|
|
43
|
+
body += `### Description\n\n${vuln.message}\n\n`;
|
|
44
|
+
body += `### Remediation\n\nReview and fix the finding in \`${vuln.file}\`.\n`;
|
|
45
|
+
body += `\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n`;
|
|
46
|
+
const labels = [
|
|
47
|
+
"security",
|
|
48
|
+
`severity:${sev}`,
|
|
49
|
+
`rule:${vuln.ruleId}`,
|
|
50
|
+
];
|
|
51
|
+
return {
|
|
52
|
+
title,
|
|
53
|
+
body: embedFingerprint(body, fp),
|
|
54
|
+
labels,
|
|
55
|
+
fingerprint: fp,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function buildFromLocalMatch(file, match) {
|
|
59
|
+
const sev = severityLabel(match.pattern.severity);
|
|
60
|
+
const emoji = severityEmoji(match.pattern.severity);
|
|
61
|
+
const fp = fingerprint(file, match.pattern.name);
|
|
62
|
+
const title = `${emoji} [${sev.toUpperCase()}] Secret detected: ${match.pattern.name} in ${basename(file)}`;
|
|
63
|
+
let body = `## Secret Detection\n\n`;
|
|
64
|
+
body += `**Pattern:** \`${match.pattern.name}\`\n`;
|
|
65
|
+
body += `**Severity:** ${sev}\n`;
|
|
66
|
+
body += `**File:** \`${file}\``;
|
|
67
|
+
if (match.line)
|
|
68
|
+
body += ` (line ${match.line})`;
|
|
69
|
+
body += `\n`;
|
|
70
|
+
if (match.redacted) {
|
|
71
|
+
body += `**Match:** \`${match.redacted}\`\n`;
|
|
72
|
+
}
|
|
73
|
+
body += `\n`;
|
|
74
|
+
if (match.pattern.description) {
|
|
75
|
+
body += `### Description\n\n${match.pattern.description}\n\n`;
|
|
76
|
+
}
|
|
77
|
+
body += `### Remediation\n\n`;
|
|
78
|
+
body += `1. Rotate the exposed credential immediately\n`;
|
|
79
|
+
body += `2. Remove the secret from source code\n`;
|
|
80
|
+
body += `3. Use environment variables or a secrets manager instead\n`;
|
|
81
|
+
body += `\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n`;
|
|
82
|
+
const labels = [
|
|
83
|
+
"security",
|
|
84
|
+
"secret-detected",
|
|
85
|
+
`severity:${sev}`,
|
|
86
|
+
];
|
|
87
|
+
return {
|
|
88
|
+
title,
|
|
89
|
+
body: embedFingerprint(body, fp),
|
|
90
|
+
labels,
|
|
91
|
+
fingerprint: fp,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function truncate(s, max) {
|
|
95
|
+
if (s.length <= max)
|
|
96
|
+
return s;
|
|
97
|
+
return s.slice(0, max - 3) + "...";
|
|
98
|
+
}
|
|
99
|
+
function basename(filepath) {
|
|
100
|
+
return filepath.split("/").pop() || filepath;
|
|
101
|
+
}
|
|
@@ -7,6 +7,9 @@ import { GitleaksScanner } from "../../scanners/gitleaks.js";
|
|
|
7
7
|
import { CommandInterceptor } from "../../core/command-interceptor.js";
|
|
8
8
|
import { AuditLogger } from "../../core/audit-logger.js";
|
|
9
9
|
import { ConfigManager } from "../../core/config-manager.js";
|
|
10
|
+
import { createRequire } from "module";
|
|
11
|
+
const _require = createRequire(import.meta.url);
|
|
12
|
+
const { version: CLI_VERSION } = _require("../../../package.json");
|
|
10
13
|
function formatScanResults(results) {
|
|
11
14
|
return results.map(r => ({
|
|
12
15
|
file: r.file,
|
|
@@ -25,7 +28,7 @@ function errorResult(message) {
|
|
|
25
28
|
return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
|
|
26
29
|
}
|
|
27
30
|
function createServer() {
|
|
28
|
-
const server = new Server({ name: "rafter", version:
|
|
31
|
+
const server = new Server({ name: "rafter", version: CLI_VERSION }, { capabilities: { tools: {}, resources: {} } });
|
|
29
32
|
// ── Tools ───────────────────────────────────────────────────────────
|
|
30
33
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
31
34
|
tools: [
|
|
@@ -54,9 +54,14 @@ function generateCodexConfig() {
|
|
|
54
54
|
const policy = cfg.agent?.commandPolicy;
|
|
55
55
|
const blocked = policy?.blockedPatterns || [];
|
|
56
56
|
const approval = policy?.requireApproval || [];
|
|
57
|
-
let toml = `# Rafter security policy for OpenAI Codex
|
|
57
|
+
let toml = `# Rafter security policy for OpenAI Codex CLI
|
|
58
58
|
# Generated by: rafter policy export --format codex
|
|
59
|
-
#
|
|
59
|
+
#
|
|
60
|
+
# Usage: Save this file to your project root as codex-policy.toml
|
|
61
|
+
# then reference it in your Codex sandbox configuration.
|
|
62
|
+
#
|
|
63
|
+
# For full Codex integration, run: rafter agent init
|
|
64
|
+
# This auto-detects Codex CLI and installs skills to ~/.agents/skills/
|
|
60
65
|
|
|
61
66
|
`;
|
|
62
67
|
if (blocked.length > 0) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rafter scan — top-level scan command group.
|
|
3
|
+
*
|
|
4
|
+
* Default (no subcommand): remote backend scan (same as `rafter run`)
|
|
5
|
+
* rafter scan remote: explicit alias for remote backend scan
|
|
6
|
+
* rafter scan local [path]: local secret scanner (was `rafter agent scan`)
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { runRemoteScan } from "../backend/run.js";
|
|
10
|
+
import { createScanCommand as createLocalScanCommand } from "../agent/scan.js";
|
|
11
|
+
export function createScanGroupCommand() {
|
|
12
|
+
// "local" subcommand — reuses agent/scan.ts logic, renamed
|
|
13
|
+
const localCmd = createLocalScanCommand();
|
|
14
|
+
localCmd.name("local");
|
|
15
|
+
localCmd.description("Scan files or directories for secrets (local)");
|
|
16
|
+
// "remote" subcommand — same handler as `rafter run`
|
|
17
|
+
const remoteCmd = new Command("remote")
|
|
18
|
+
.description("Trigger a remote backend security scan (explicit alias for 'rafter run')")
|
|
19
|
+
.option("-r, --repo <repo>", "org/repo (default: current)")
|
|
20
|
+
.option("-b, --branch <branch>", "branch (default: current else main)")
|
|
21
|
+
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
22
|
+
.option("-f, --format <format>", "json | md", "md")
|
|
23
|
+
.option("--skip-interactive", "do not wait for scan to complete")
|
|
24
|
+
.option("--quiet", "suppress status messages")
|
|
25
|
+
.action(async (opts) => {
|
|
26
|
+
await runRemoteScan(opts);
|
|
27
|
+
});
|
|
28
|
+
// Root scan group — default action is remote backend scan
|
|
29
|
+
const scanGroup = new Command("scan")
|
|
30
|
+
.description("Scan for security issues. Default: remote backend scan. Use 'scan local' for local secret scanning.")
|
|
31
|
+
.enablePositionalOptions()
|
|
32
|
+
.option("-r, --repo <repo>", "org/repo (default: current)")
|
|
33
|
+
.option("-b, --branch <branch>", "branch (default: current else main)")
|
|
34
|
+
.option("-k, --api-key <key>", "API key or RAFTER_API_KEY env var")
|
|
35
|
+
.option("-f, --format <format>", "json | md", "md")
|
|
36
|
+
.option("--skip-interactive", "do not wait for scan to complete")
|
|
37
|
+
.option("--quiet", "suppress status messages");
|
|
38
|
+
scanGroup.addCommand(localCmd);
|
|
39
|
+
scanGroup.addCommand(remoteCmd);
|
|
40
|
+
// When invoked with no subcommand, run remote backend scan
|
|
41
|
+
scanGroup.action(async (opts) => {
|
|
42
|
+
await runRemoteScan(opts);
|
|
43
|
+
});
|
|
44
|
+
return scanGroup;
|
|
45
|
+
}
|
|
@@ -1,8 +1,104 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import dns from "dns/promises";
|
|
1
3
|
import fs from "fs";
|
|
4
|
+
import net from "net";
|
|
2
5
|
import path from "path";
|
|
3
6
|
import { getAuditLogPath } from "./config-defaults.js";
|
|
4
7
|
import { ConfigManager } from "./config-manager.js";
|
|
5
8
|
import { assessCommandRisk } from "./risk-rules.js";
|
|
9
|
+
/**
|
|
10
|
+
* Validate a webhook URL to prevent SSRF attacks.
|
|
11
|
+
* Rejects non-HTTP(S) schemes and URLs that resolve to private/internal IPs.
|
|
12
|
+
*/
|
|
13
|
+
export async function validateWebhookUrl(rawUrl) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(rawUrl);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error(`Invalid webhook URL: ${rawUrl}`);
|
|
20
|
+
}
|
|
21
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
22
|
+
throw new Error(`Webhook URL must use http or https, got ${parsed.protocol}`);
|
|
23
|
+
}
|
|
24
|
+
// URL.hostname keeps brackets for IPv6 (e.g. "[::1]") — strip them
|
|
25
|
+
let hostname = parsed.hostname;
|
|
26
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
27
|
+
hostname = hostname.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
// If the hostname is already an IP, check it directly
|
|
30
|
+
if (net.isIP(hostname)) {
|
|
31
|
+
if (isPrivateIp(hostname)) {
|
|
32
|
+
throw new Error(`Webhook URL must not point to a private/internal address: ${hostname}`);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Resolve hostname and check all resulting IPs
|
|
37
|
+
let addresses;
|
|
38
|
+
try {
|
|
39
|
+
const results = await dns.resolve(hostname);
|
|
40
|
+
addresses = results;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Could not resolve webhook hostname: ${hostname}`);
|
|
44
|
+
}
|
|
45
|
+
for (const addr of addresses) {
|
|
46
|
+
if (isPrivateIp(addr)) {
|
|
47
|
+
throw new Error(`Webhook URL must not point to a private/internal address: ${hostname} resolved to ${addr}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Check if an IP address belongs to a private, loopback, link-local, or
|
|
53
|
+
* cloud-metadata range.
|
|
54
|
+
*/
|
|
55
|
+
function isPrivateIp(ip) {
|
|
56
|
+
// IPv4 checks
|
|
57
|
+
if (net.isIPv4(ip)) {
|
|
58
|
+
const parts = ip.split(".").map(Number);
|
|
59
|
+
const [a, b] = parts;
|
|
60
|
+
// 127.0.0.0/8 — loopback
|
|
61
|
+
if (a === 127)
|
|
62
|
+
return true;
|
|
63
|
+
// 10.0.0.0/8 — private
|
|
64
|
+
if (a === 10)
|
|
65
|
+
return true;
|
|
66
|
+
// 172.16.0.0/12 — private
|
|
67
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
68
|
+
return true;
|
|
69
|
+
// 192.168.0.0/16 — private
|
|
70
|
+
if (a === 192 && b === 168)
|
|
71
|
+
return true;
|
|
72
|
+
// 169.254.0.0/16 — link-local / cloud metadata
|
|
73
|
+
if (a === 169 && b === 254)
|
|
74
|
+
return true;
|
|
75
|
+
// 0.0.0.0
|
|
76
|
+
if (a === 0)
|
|
77
|
+
return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// IPv6 checks
|
|
81
|
+
const lower = ip.toLowerCase();
|
|
82
|
+
// ::1 — loopback
|
|
83
|
+
if (lower === "::1")
|
|
84
|
+
return true;
|
|
85
|
+
// :: — unspecified
|
|
86
|
+
if (lower === "::")
|
|
87
|
+
return true;
|
|
88
|
+
// fe80::/10 — link-local
|
|
89
|
+
if (lower.startsWith("fe80:"))
|
|
90
|
+
return true;
|
|
91
|
+
// fc00::/7 — unique local (ULA)
|
|
92
|
+
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
|
93
|
+
return true;
|
|
94
|
+
// ::ffff:127.0.0.1 etc — IPv4-mapped IPv6
|
|
95
|
+
if (lower.startsWith("::ffff:")) {
|
|
96
|
+
const mapped = lower.slice(7);
|
|
97
|
+
if (net.isIPv4(mapped))
|
|
98
|
+
return isPrivateIp(mapped);
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
6
102
|
export const RISK_SEVERITY = {
|
|
7
103
|
low: 0,
|
|
8
104
|
medium: 1,
|
|
@@ -17,7 +113,7 @@ export class AuditLogger {
|
|
|
17
113
|
// Ensure log directory exists
|
|
18
114
|
const dir = path.dirname(this.logPath);
|
|
19
115
|
if (!fs.existsSync(dir)) {
|
|
20
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
117
|
}
|
|
22
118
|
}
|
|
23
119
|
/**
|
|
@@ -36,7 +132,7 @@ export class AuditLogger {
|
|
|
36
132
|
};
|
|
37
133
|
// Append to log file
|
|
38
134
|
const line = JSON.stringify(fullEntry) + "\n";
|
|
39
|
-
fs.appendFileSync(this.logPath, line, "utf-8");
|
|
135
|
+
fs.appendFileSync(this.logPath, line, { encoding: "utf-8", mode: 0o600 });
|
|
40
136
|
// Send webhook notification if configured and risk meets threshold
|
|
41
137
|
this.sendNotification(fullEntry, config);
|
|
42
138
|
}
|
|
@@ -64,13 +160,16 @@ export class AuditLogger {
|
|
|
64
160
|
content: `[rafter] ${eventRisk}-risk event: ${entry.eventType}${entry.action?.command ? ` — ${entry.action.command}` : ""}`,
|
|
65
161
|
};
|
|
66
162
|
// Fire-and-forget POST — never block audit logging
|
|
67
|
-
|
|
163
|
+
// Validate URL to prevent SSRF before making the request
|
|
164
|
+
validateWebhookUrl(webhookUrl)
|
|
165
|
+
.then(() => fetch(webhookUrl, {
|
|
68
166
|
method: "POST",
|
|
69
167
|
headers: { "Content-Type": "application/json" },
|
|
70
168
|
body: JSON.stringify(payload),
|
|
71
169
|
signal: AbortSignal.timeout(5000),
|
|
72
|
-
})
|
|
73
|
-
|
|
170
|
+
}))
|
|
171
|
+
.catch(() => {
|
|
172
|
+
// Silently ignore webhook failures (including validation rejections)
|
|
74
173
|
});
|
|
75
174
|
}
|
|
76
175
|
/**
|
|
@@ -196,13 +295,13 @@ export class AuditLogger {
|
|
|
196
295
|
const filtered = entries.filter(e => new Date(e.timestamp) >= cutoffDate);
|
|
197
296
|
// Rewrite log file with only retained entries
|
|
198
297
|
const content = filtered.map(e => JSON.stringify(e)).join("\n") + "\n";
|
|
199
|
-
fs.writeFileSync(this.logPath, content, "utf-8");
|
|
298
|
+
fs.writeFileSync(this.logPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
200
299
|
}
|
|
201
300
|
/**
|
|
202
301
|
* Generate a unique session ID
|
|
203
302
|
*/
|
|
204
303
|
generateSessionId() {
|
|
205
|
-
return `${Date.now()}-${
|
|
304
|
+
return `${Date.now()}-${randomBytes(8).toString("hex")}`;
|
|
206
305
|
}
|
|
207
306
|
/**
|
|
208
307
|
* Assess risk level of a command
|
|
@@ -19,6 +19,30 @@ export function getDefaultConfig() {
|
|
|
19
19
|
claudeCode: {
|
|
20
20
|
enabled: false,
|
|
21
21
|
mcpPath: path.join(os.homedir(), ".claude", "mcp", "rafter-security.json")
|
|
22
|
+
},
|
|
23
|
+
codex: {
|
|
24
|
+
enabled: false,
|
|
25
|
+
skillsDir: path.join(os.homedir(), ".agents", "skills")
|
|
26
|
+
},
|
|
27
|
+
gemini: {
|
|
28
|
+
enabled: false,
|
|
29
|
+
configPath: path.join(os.homedir(), ".gemini", "settings.json")
|
|
30
|
+
},
|
|
31
|
+
aider: {
|
|
32
|
+
enabled: false,
|
|
33
|
+
configPath: path.join(os.homedir(), ".aider.conf.yml")
|
|
34
|
+
},
|
|
35
|
+
cursor: {
|
|
36
|
+
enabled: false,
|
|
37
|
+
mcpPath: path.join(os.homedir(), ".cursor", "mcp.json")
|
|
38
|
+
},
|
|
39
|
+
windsurf: {
|
|
40
|
+
enabled: false,
|
|
41
|
+
mcpPath: path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json")
|
|
42
|
+
},
|
|
43
|
+
continueDev: {
|
|
44
|
+
enabled: false,
|
|
45
|
+
configPath: path.join(os.homedir(), ".continue", "config.json")
|
|
22
46
|
}
|
|
23
47
|
},
|
|
24
48
|
skills: {
|
|
@@ -2,6 +2,101 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { getDefaultConfig, getConfigPath, getRafterDir, CONFIG_VERSION } from "./config-defaults.js";
|
|
4
4
|
import { loadPolicy } from "./policy-loader.js";
|
|
5
|
+
const VALID_RISK_LEVELS = new Set(["minimal", "moderate", "aggressive"]);
|
|
6
|
+
const VALID_COMMAND_MODES = new Set(["allow-all", "approve-dangerous", "deny-list"]);
|
|
7
|
+
const VALID_LOG_LEVELS = new Set(["debug", "info", "warn", "error"]);
|
|
8
|
+
/**
|
|
9
|
+
* Validate a parsed config JSON object, warning and falling back to defaults for invalid fields.
|
|
10
|
+
*/
|
|
11
|
+
function validateConfig(raw) {
|
|
12
|
+
if (!raw || typeof raw !== "object") {
|
|
13
|
+
console.error("Warning: config file is not a JSON object — using defaults.");
|
|
14
|
+
return getDefaultConfig();
|
|
15
|
+
}
|
|
16
|
+
const defaults = getDefaultConfig();
|
|
17
|
+
// Top-level scalars
|
|
18
|
+
if (raw.version !== undefined && typeof raw.version !== "string") {
|
|
19
|
+
console.error('Warning: config "version" must be a string — using default.');
|
|
20
|
+
raw.version = defaults.version;
|
|
21
|
+
}
|
|
22
|
+
if (raw.initialized !== undefined && typeof raw.initialized !== "string") {
|
|
23
|
+
console.error('Warning: config "initialized" must be a string — using default.');
|
|
24
|
+
raw.initialized = defaults.initialized;
|
|
25
|
+
}
|
|
26
|
+
const agent = raw.agent;
|
|
27
|
+
if (agent && typeof agent === "object") {
|
|
28
|
+
// riskLevel
|
|
29
|
+
if (agent.riskLevel !== undefined && !VALID_RISK_LEVELS.has(agent.riskLevel)) {
|
|
30
|
+
console.error(`Warning: config "agent.riskLevel" must be one of: minimal, moderate, aggressive — using default.`);
|
|
31
|
+
agent.riskLevel = defaults.agent.riskLevel;
|
|
32
|
+
}
|
|
33
|
+
// commandPolicy
|
|
34
|
+
const cp = agent.commandPolicy;
|
|
35
|
+
if (cp && typeof cp === "object") {
|
|
36
|
+
if (cp.mode !== undefined && !VALID_COMMAND_MODES.has(cp.mode)) {
|
|
37
|
+
console.error(`Warning: config "agent.commandPolicy.mode" must be one of: allow-all, approve-dangerous, deny-list — using default.`);
|
|
38
|
+
cp.mode = defaults.agent.commandPolicy.mode;
|
|
39
|
+
}
|
|
40
|
+
if (cp.blockedPatterns !== undefined && (!Array.isArray(cp.blockedPatterns) || !cp.blockedPatterns.every((v) => typeof v === "string"))) {
|
|
41
|
+
console.error('Warning: config "agent.commandPolicy.blockedPatterns" must be an array of strings — using default.');
|
|
42
|
+
cp.blockedPatterns = [...defaults.agent.commandPolicy.blockedPatterns];
|
|
43
|
+
}
|
|
44
|
+
if (cp.requireApproval !== undefined && (!Array.isArray(cp.requireApproval) || !cp.requireApproval.every((v) => typeof v === "string"))) {
|
|
45
|
+
console.error('Warning: config "agent.commandPolicy.requireApproval" must be an array of strings — using default.');
|
|
46
|
+
cp.requireApproval = [...defaults.agent.commandPolicy.requireApproval];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// audit
|
|
50
|
+
const audit = agent.audit;
|
|
51
|
+
if (audit && typeof audit === "object") {
|
|
52
|
+
if (audit.retentionDays !== undefined && (typeof audit.retentionDays !== "number" || isNaN(audit.retentionDays))) {
|
|
53
|
+
console.error('Warning: config "agent.audit.retentionDays" must be a number — using default.');
|
|
54
|
+
audit.retentionDays = defaults.agent.audit.retentionDays;
|
|
55
|
+
}
|
|
56
|
+
if (audit.logLevel !== undefined && !VALID_LOG_LEVELS.has(audit.logLevel)) {
|
|
57
|
+
console.error(`Warning: config "agent.audit.logLevel" must be one of: debug, info, warn, error — using default.`);
|
|
58
|
+
audit.logLevel = defaults.agent.audit.logLevel;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// outputFiltering
|
|
62
|
+
const of = agent.outputFiltering;
|
|
63
|
+
if (of && typeof of === "object") {
|
|
64
|
+
if (of.redactSecrets !== undefined && typeof of.redactSecrets !== "boolean") {
|
|
65
|
+
console.error('Warning: config "agent.outputFiltering.redactSecrets" must be a boolean — using default.');
|
|
66
|
+
of.redactSecrets = defaults.agent.outputFiltering.redactSecrets;
|
|
67
|
+
}
|
|
68
|
+
if (of.blockPatterns !== undefined && typeof of.blockPatterns !== "boolean") {
|
|
69
|
+
console.error('Warning: config "agent.outputFiltering.blockPatterns" must be a boolean — using default.');
|
|
70
|
+
of.blockPatterns = defaults.agent.outputFiltering.blockPatterns;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// scan.customPatterns — validate regex compilation
|
|
74
|
+
const scan = agent.scan;
|
|
75
|
+
if (scan && typeof scan === "object") {
|
|
76
|
+
if (scan.excludePaths !== undefined && (!Array.isArray(scan.excludePaths) || !scan.excludePaths.every((v) => typeof v === "string"))) {
|
|
77
|
+
console.error('Warning: config "agent.scan.excludePaths" must be an array of strings — using default.');
|
|
78
|
+
delete scan.excludePaths;
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(scan.customPatterns)) {
|
|
81
|
+
scan.customPatterns = scan.customPatterns.filter((p) => {
|
|
82
|
+
if (!p || typeof p !== "object" || typeof p.name !== "string" || !p.name || typeof p.regex !== "string" || !p.regex) {
|
|
83
|
+
console.error(`Warning: skipping malformed scan.customPatterns entry — must have name and regex.`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
new RegExp(p.regex);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
console.error(`Warning: skipping custom pattern "${p.name}" — invalid regex.`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
5
100
|
export class ConfigManager {
|
|
6
101
|
constructor(configPath) {
|
|
7
102
|
this.configPath = configPath || getConfigPath();
|
|
@@ -15,7 +110,8 @@ export class ConfigManager {
|
|
|
15
110
|
}
|
|
16
111
|
try {
|
|
17
112
|
const content = fs.readFileSync(this.configPath, "utf-8");
|
|
18
|
-
const
|
|
113
|
+
const parsed = JSON.parse(content);
|
|
114
|
+
const config = validateConfig(parsed);
|
|
19
115
|
// Migrate config if needed
|
|
20
116
|
return this.migrate(config);
|
|
21
117
|
}
|
|
@@ -179,10 +275,27 @@ export class ConfigManager {
|
|
|
179
275
|
* Migrate config to latest version
|
|
180
276
|
*/
|
|
181
277
|
migrate(config) {
|
|
182
|
-
|
|
183
|
-
// In future, handle version-specific migrations
|
|
278
|
+
let dirty = false;
|
|
184
279
|
if (config.version !== CONFIG_VERSION) {
|
|
185
280
|
config.version = CONFIG_VERSION;
|
|
281
|
+
dirty = true;
|
|
282
|
+
}
|
|
283
|
+
// Fix overly broad curl/wget pipe-to-shell patterns.
|
|
284
|
+
// Old pattern: "curl.*\|.*sh" — matches any command containing "sh" after a pipe
|
|
285
|
+
// (e.g. `grep "curl\|sh"` or `git push`). Replace with word-bounded shell names.
|
|
286
|
+
const badToGood = {
|
|
287
|
+
"curl.*\\|.*sh": "curl.*\\|\\s*(bash|sh|zsh|dash)\\b",
|
|
288
|
+
"wget.*\\|.*sh": "wget.*\\|\\s*(bash|sh|zsh|dash)\\b",
|
|
289
|
+
};
|
|
290
|
+
const approval = config.agent?.commandPolicy?.requireApproval;
|
|
291
|
+
if (Array.isArray(approval)) {
|
|
292
|
+
const fixed = approval.map(p => badToGood[p] ?? p);
|
|
293
|
+
if (fixed.some((p, i) => p !== approval[i])) {
|
|
294
|
+
config.agent.commandPolicy.requireApproval = fixed;
|
|
295
|
+
dirty = true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (dirty) {
|
|
186
299
|
this.save(config);
|
|
187
300
|
}
|
|
188
301
|
return config;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import path from "path";
|
|
7
|
+
import { minimatch } from "minimatch";
|
|
7
8
|
import { getRafterDir } from "./config-defaults.js";
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Custom pattern loading
|
|
@@ -69,12 +70,24 @@ function loadJsonPatterns(file) {
|
|
|
69
70
|
return [];
|
|
70
71
|
const patterns = [];
|
|
71
72
|
for (const entry of data) {
|
|
72
|
-
if (typeof entry.pattern !== "string")
|
|
73
|
+
if (typeof entry.pattern !== "string" || !entry.pattern)
|
|
73
74
|
continue;
|
|
75
|
+
try {
|
|
76
|
+
new RegExp(entry.pattern);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
console.error(`Warning: skipping custom pattern in ${path.basename(file)} — invalid regex: ${entry.pattern}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const severity = entry.severity ?? "high";
|
|
83
|
+
if (!["low", "medium", "high", "critical"].includes(severity)) {
|
|
84
|
+
console.error(`Warning: skipping custom pattern in ${path.basename(file)} — invalid severity: ${severity}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
74
87
|
patterns.push({
|
|
75
88
|
name: entry.name ?? `Custom (${path.basename(file, ".json")})`,
|
|
76
89
|
regex: entry.pattern,
|
|
77
|
-
severity:
|
|
90
|
+
severity: severity,
|
|
78
91
|
description: entry.description,
|
|
79
92
|
});
|
|
80
93
|
}
|
|
@@ -135,23 +148,13 @@ export function isSuppressed(filePath, patternName, suppressions) {
|
|
|
135
148
|
return false;
|
|
136
149
|
}
|
|
137
150
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
151
|
+
* Match a file path against a glob pattern using minimatch.
|
|
152
|
+
*
|
|
153
|
+
* Uses `matchBase` so bare patterns like "*.env" match against the basename
|
|
154
|
+
* (e.g. "config/.env"), and `dot` so dotfiles are included.
|
|
140
155
|
*/
|
|
141
156
|
function matchGlob(glob, filePath) {
|
|
142
|
-
// Normalise separators
|
|
143
157
|
const g = glob.replace(/\\/g, "/");
|
|
144
158
|
const f = filePath.replace(/\\/g, "/");
|
|
145
|
-
|
|
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
|
-
}
|
|
159
|
+
return minimatch(f, g, { dot: true, matchBase: true });
|
|
157
160
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
const GENERIC_PATTERN_NAMES = new Set(["Generic API Key", "Generic Secret"]);
|
|
2
|
+
const VARIABLE_NAME_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/;
|
|
3
|
+
const LOWERCASE_IDENT_RE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
|
|
4
|
+
const QUOTED_VALUE_RE = /['"]([^'"]+)['"]/;
|
|
1
5
|
export class PatternEngine {
|
|
2
6
|
constructor(patterns) {
|
|
3
7
|
this.patterns = patterns;
|
|
@@ -11,6 +15,8 @@ export class PatternEngine {
|
|
|
11
15
|
const regex = this.createRegex(pattern.regex);
|
|
12
16
|
let match;
|
|
13
17
|
while ((match = regex.exec(text)) !== null) {
|
|
18
|
+
if (this.isFalsePositive(pattern, match[0]))
|
|
19
|
+
continue;
|
|
14
20
|
matches.push({
|
|
15
21
|
pattern,
|
|
16
22
|
match: match[0],
|
|
@@ -32,6 +38,8 @@ export class PatternEngine {
|
|
|
32
38
|
const regex = this.createRegex(pattern.regex);
|
|
33
39
|
let match;
|
|
34
40
|
while ((match = regex.exec(line)) !== null) {
|
|
41
|
+
if (this.isFalsePositive(pattern, match[0]))
|
|
42
|
+
continue;
|
|
35
43
|
matches.push({
|
|
36
44
|
pattern,
|
|
37
45
|
match: match[0],
|
|
@@ -51,7 +59,7 @@ export class PatternEngine {
|
|
|
51
59
|
let redacted = text;
|
|
52
60
|
for (const pattern of this.patterns) {
|
|
53
61
|
const regex = this.createRegex(pattern.regex);
|
|
54
|
-
redacted = redacted.replace(regex, (match) => this.redact(match));
|
|
62
|
+
redacted = redacted.replace(regex, (match) => this.isFalsePositive(pattern, match) ? match : this.redact(match));
|
|
55
63
|
}
|
|
56
64
|
return redacted;
|
|
57
65
|
}
|
|
@@ -67,6 +75,23 @@ export class PatternEngine {
|
|
|
67
75
|
getPatternsBySeverity(severity) {
|
|
68
76
|
return this.patterns.filter(p => p.severity === severity);
|
|
69
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if a match from a generic pattern looks like a variable name
|
|
80
|
+
* rather than an actual secret value.
|
|
81
|
+
*/
|
|
82
|
+
isFalsePositive(pattern, matchText) {
|
|
83
|
+
if (!GENERIC_PATTERN_NAMES.has(pattern.name))
|
|
84
|
+
return false;
|
|
85
|
+
const m = QUOTED_VALUE_RE.exec(matchText);
|
|
86
|
+
if (!m)
|
|
87
|
+
return false;
|
|
88
|
+
const value = m[1];
|
|
89
|
+
if (VARIABLE_NAME_RE.test(value))
|
|
90
|
+
return true;
|
|
91
|
+
if (LOWERCASE_IDENT_RE.test(value))
|
|
92
|
+
return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
70
95
|
/**
|
|
71
96
|
* Create RegExp from pattern string, extracting inline flags
|
|
72
97
|
*/
|