@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.
@@ -4,37 +4,65 @@ 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")
12
37
  .argument("[path]", "File or directory to scan", ".")
13
38
  .option("-q, --quiet", "Only output if secrets found")
14
39
  .option("--json", "Output as JSON")
40
+ .option("--format <format>", "Output format: text, json, sarif", "text")
15
41
  .option("--staged", "Scan only git staged files")
16
42
  .option("--diff <ref>", "Scan files changed since a git ref")
17
43
  .option("--engine <engine>", "Scan engine: gitleaks or patterns", "auto")
44
+ .option("--baseline", "Filter findings present in the saved baseline")
18
45
  .action(async (scanPath, opts) => {
19
46
  // Load policy-merged config for excludePaths/customPatterns
20
47
  const manager = new ConfigManager();
21
48
  const cfg = manager.loadWithPolicy();
22
49
  const scanCfg = cfg.agent?.scan;
50
+ const baselineEntries = opts.baseline ? loadBaselineEntries() : [];
23
51
  // Handle --diff flag
24
52
  if (opts.diff) {
25
- await scanDiffFiles(opts.diff, opts, scanCfg);
53
+ await scanDiffFiles(opts.diff, opts, scanCfg, baselineEntries);
26
54
  return;
27
55
  }
28
56
  // Handle --staged flag
29
57
  if (opts.staged) {
30
- await scanStagedFiles(opts, scanCfg);
58
+ await scanStagedFiles(opts, scanCfg, baselineEntries);
31
59
  return;
32
60
  }
33
61
  const resolvedPath = path.resolve(scanPath);
34
62
  // Check if path exists
35
63
  if (!fs.existsSync(resolvedPath)) {
36
64
  console.error(`Error: Path not found: ${resolvedPath}`);
37
- process.exit(1);
65
+ process.exit(2);
38
66
  }
39
67
  // Determine scan engine
40
68
  const engine = await selectEngine(opts.engine || "auto", opts.quiet || false);
@@ -53,15 +81,84 @@ export function createScanCommand() {
53
81
  }
54
82
  results = await scanFile(resolvedPath, engine, scanCfg);
55
83
  }
56
- outputScanResults(results, opts);
84
+ outputScanResults(applyBaseline(results, baselineEntries), opts);
57
85
  });
58
86
  }
87
+ /**
88
+ * Emit SARIF 2.1.0 JSON for GitHub/GitLab security tab integration
89
+ */
90
+ function outputSarif(results) {
91
+ const rules = new Map();
92
+ const sarifResults = [];
93
+ for (const r of results) {
94
+ for (const m of r.matches) {
95
+ const ruleId = m.pattern.name.toLowerCase().replace(/\s+/g, "-");
96
+ if (!rules.has(ruleId)) {
97
+ rules.set(ruleId, {
98
+ id: ruleId,
99
+ name: m.pattern.name,
100
+ shortDescription: m.pattern.description || m.pattern.name,
101
+ });
102
+ }
103
+ sarifResults.push({
104
+ ruleId,
105
+ level: m.pattern.severity === "critical" || m.pattern.severity === "high" ? "error" : "warning",
106
+ message: { text: `${m.pattern.name} detected` },
107
+ locations: [
108
+ {
109
+ physicalLocation: {
110
+ artifactLocation: { uri: r.file.replace(/\\/g, "/"), uriBaseId: "%SRCROOT%" },
111
+ region: m.line ? { startLine: m.line, startColumn: m.column ?? 1 } : undefined,
112
+ },
113
+ },
114
+ ],
115
+ });
116
+ }
117
+ }
118
+ const sarif = {
119
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
120
+ version: "2.1.0",
121
+ runs: [
122
+ {
123
+ tool: {
124
+ driver: {
125
+ name: "rafter",
126
+ version: "0.5.5",
127
+ informationUri: "https://rafter.so",
128
+ rules: Array.from(rules.values()),
129
+ },
130
+ },
131
+ results: sarifResults,
132
+ },
133
+ ],
134
+ };
135
+ console.log(JSON.stringify(sarif, null, 2));
136
+ process.exit(results.length > 0 ? 1 : 0);
137
+ }
59
138
  /**
60
139
  * Shared output logic for scan results
61
140
  */
62
141
  function outputScanResults(results, opts, context) {
63
- if (opts.json) {
64
- console.log(JSON.stringify(results, null, 2));
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
+ }
147
+ if (format === "sarif") {
148
+ outputSarif(results);
149
+ return;
150
+ }
151
+ if (format === "json" || opts.json) {
152
+ const out = results.map((r) => ({
153
+ file: r.file,
154
+ matches: r.matches.map((m) => ({
155
+ pattern: { name: m.pattern.name, severity: m.pattern.severity, description: m.pattern.description || "" },
156
+ line: m.line ?? null,
157
+ column: m.column ?? null,
158
+ redacted: m.redacted || "",
159
+ })),
160
+ }));
161
+ console.log(JSON.stringify(out, null, 2));
65
162
  process.exit(results.length > 0 ? 1 : 0);
66
163
  }
67
164
  if (results.length === 0) {
@@ -98,7 +195,7 @@ function outputScanResults(results, opts, context) {
98
195
  /**
99
196
  * Scan files changed since a git ref
100
197
  */
101
- async function scanDiffFiles(ref, opts, scanCfg) {
198
+ async function scanDiffFiles(ref, opts, scanCfg, baselineEntries = []) {
102
199
  try {
103
200
  const diffOutput = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACM", ref], {
104
201
  encoding: "utf-8",
@@ -126,12 +223,12 @@ async function scanDiffFiles(ref, opts, scanCfg) {
126
223
  const results = await scanFile(filePath, engine, scanCfg);
127
224
  allResults.push(...results);
128
225
  }
129
- outputScanResults(allResults, opts, `files changed since ${ref}`);
226
+ outputScanResults(applyBaseline(allResults, baselineEntries), opts, `files changed since ${ref}`);
130
227
  }
131
228
  catch (error) {
132
229
  if (error.status === 128) {
133
230
  console.error("Error: Not in a git repository or invalid ref");
134
- process.exit(1);
231
+ process.exit(2);
135
232
  }
136
233
  throw error;
137
234
  }
@@ -139,7 +236,7 @@ async function scanDiffFiles(ref, opts, scanCfg) {
139
236
  /**
140
237
  * Scan git staged files for secrets
141
238
  */
142
- async function scanStagedFiles(opts, scanCfg) {
239
+ async function scanStagedFiles(opts, scanCfg, baselineEntries = []) {
143
240
  try {
144
241
  const stagedFilesOutput = execSync("git diff --cached --name-only --diff-filter=ACM", {
145
242
  encoding: "utf-8",
@@ -167,12 +264,12 @@ async function scanStagedFiles(opts, scanCfg) {
167
264
  const results = await scanFile(filePath, engine, scanCfg);
168
265
  allResults.push(...results);
169
266
  }
170
- outputScanResults(allResults, opts, "staged files");
267
+ outputScanResults(applyBaseline(allResults, baselineEntries), opts, "staged files");
171
268
  }
172
269
  catch (error) {
173
270
  if (error.status === 128) {
174
271
  console.error("Error: Not in a git repository");
175
- process.exit(1);
272
+ process.exit(2);
176
273
  }
177
274
  throw error;
178
275
  }
@@ -0,0 +1,115 @@
1
+ import { Command } from "commander";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { execSync } from "child_process";
6
+ import { getRafterDir, getAuditLogPath, getBinDir } from "../../core/config-defaults.js";
7
+ import { AuditLogger } from "../../core/audit-logger.js";
8
+ import { ConfigManager } from "../../core/config-manager.js";
9
+ export function createStatusCommand() {
10
+ return new Command("status")
11
+ .description("Show agent security status dashboard")
12
+ .action(async () => {
13
+ const rafterDir = getRafterDir();
14
+ const auditPath = getAuditLogPath();
15
+ const home = os.homedir();
16
+ console.log("Rafter Agent Status");
17
+ console.log("=".repeat(50));
18
+ // --- Config ---
19
+ const configPath = path.join(rafterDir, "config.json");
20
+ if (fs.existsSync(configPath)) {
21
+ try {
22
+ const cfg = new ConfigManager().load();
23
+ console.log(`\nConfig: ${configPath}`);
24
+ console.log(`Risk level: ${cfg.agent?.riskLevel ?? "moderate"}`);
25
+ }
26
+ catch {
27
+ console.log(`\nConfig: ${configPath} (parse error)`);
28
+ }
29
+ }
30
+ else {
31
+ console.log(`\nConfig: not found — run: rafter agent init`);
32
+ }
33
+ // --- Gitleaks ---
34
+ const localGitleaks = path.join(getBinDir(), "gitleaks");
35
+ let gitleaksStatus = "not found — run: rafter agent init";
36
+ try {
37
+ const ver = execSync("gitleaks version", { timeout: 5000, encoding: "utf-8" }).trim();
38
+ gitleaksStatus = `${ver} (PATH)`;
39
+ }
40
+ catch {
41
+ if (fs.existsSync(localGitleaks)) {
42
+ try {
43
+ const ver = execSync(`"${localGitleaks}" version`, { timeout: 5000, encoding: "utf-8" }).trim();
44
+ gitleaksStatus = `${ver} (local)`;
45
+ }
46
+ catch {
47
+ gitleaksStatus = `${localGitleaks} (binary error)`;
48
+ }
49
+ }
50
+ }
51
+ console.log(`Gitleaks: ${gitleaksStatus}`);
52
+ // --- Claude Code hooks ---
53
+ const settingsPath = path.join(home, ".claude", "settings.json");
54
+ let pretoolOk = false;
55
+ let posttoolOk = false;
56
+ if (fs.existsSync(settingsPath)) {
57
+ try {
58
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
59
+ const hooks = settings.hooks ?? {};
60
+ for (const entry of hooks.PreToolUse ?? []) {
61
+ for (const h of entry.hooks ?? []) {
62
+ if (String(h.command ?? "").includes("rafter hook pretool"))
63
+ pretoolOk = true;
64
+ }
65
+ }
66
+ for (const entry of hooks.PostToolUse ?? []) {
67
+ for (const h of entry.hooks ?? []) {
68
+ if (String(h.command ?? "").includes("rafter hook posttool"))
69
+ posttoolOk = true;
70
+ }
71
+ }
72
+ }
73
+ catch {
74
+ // unreadable settings
75
+ }
76
+ }
77
+ console.log(`PreToolUse: ${pretoolOk ? "installed" : "not installed — run: rafter agent init"}`);
78
+ console.log(`PostToolUse: ${posttoolOk ? "installed" : "not installed — run: rafter agent init"}`);
79
+ // --- OpenClaw skill ---
80
+ const skillPath = path.join(home, ".openclaw", "skills", "rafter-security.md");
81
+ const openclawDir = path.join(home, ".openclaw");
82
+ if (fs.existsSync(skillPath)) {
83
+ console.log(`OpenClaw: skill installed (${skillPath})`);
84
+ }
85
+ else if (fs.existsSync(openclawDir)) {
86
+ console.log("OpenClaw: detected but skill missing — run: rafter agent init");
87
+ }
88
+ else {
89
+ console.log("OpenClaw: not detected (optional)");
90
+ }
91
+ // --- Audit log summary ---
92
+ console.log(`\nAudit log: ${auditPath}`);
93
+ if (fs.existsSync(auditPath)) {
94
+ const logger = new AuditLogger();
95
+ const allEntries = logger.read();
96
+ const total = allEntries.length;
97
+ const secrets = allEntries.filter((e) => e.eventType === "secret_detected").length;
98
+ const blocked = allEntries.filter((e) => e.eventType === "command_intercepted" && e.resolution?.actionTaken === "blocked").length;
99
+ console.log(`Total events: ${total} | Secrets detected: ${secrets} | Commands blocked: ${blocked}`);
100
+ const recent = logger.read({ limit: 5 });
101
+ if (recent.length > 0) {
102
+ console.log("\nRecent events:");
103
+ for (const e of [...recent].reverse()) {
104
+ const ts = (e.timestamp ?? "").slice(0, 19).replace("T", " ");
105
+ const action = e.resolution?.actionTaken ?? "";
106
+ console.log(` ${ts} ${e.eventType} [${action}]`);
107
+ }
108
+ }
109
+ }
110
+ else {
111
+ console.log("No events logged yet.");
112
+ }
113
+ console.log();
114
+ });
115
+ }
@@ -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
+ }
@@ -0,0 +1,117 @@
1
+ import { Command } from "commander";
2
+ import { BinaryManager } from "../../utils/binary-manager.js";
3
+ import { SkillManager } from "../../utils/skill-manager.js";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import os from "os";
7
+ import { fmt } from "../../utils/formatter.js";
8
+ async function checkGitleaks() {
9
+ const binaryManager = new BinaryManager();
10
+ const name = "Gitleaks";
11
+ // Check PATH first (e.g. Homebrew), then fall back to ~/.rafter/bin
12
+ const pathBinary = binaryManager.findGitleaksOnPath();
13
+ const hasBinary = pathBinary !== null || binaryManager.isGitleaksInstalled();
14
+ if (!hasBinary) {
15
+ return { name, passed: false, detail: `Not found on PATH or at ${binaryManager.getGitleaksPath()}` };
16
+ }
17
+ const binaryPath = pathBinary ?? binaryManager.getGitleaksPath();
18
+ const { ok, stdout, stderr } = await binaryManager.verifyGitleaksVerbose(binaryPath);
19
+ if (!ok) {
20
+ const diag = await binaryManager.collectBinaryDiagnostics(binaryPath);
21
+ return { name, passed: false, detail: `Binary found at ${binaryPath} but failed to execute\n${stdout ? ` stdout: ${stdout}\n` : ""}${stderr ? ` stderr: ${stderr}\n` : ""}${diag}` };
22
+ }
23
+ return { name, passed: true, detail: `${stdout} (${binaryPath})` };
24
+ }
25
+ function checkConfig() {
26
+ const name = "Config";
27
+ const configPath = path.join(os.homedir(), ".rafter", "config.json");
28
+ if (!fs.existsSync(configPath)) {
29
+ return { name, passed: false, detail: `Not found: ${configPath}` };
30
+ }
31
+ try {
32
+ const content = fs.readFileSync(configPath, "utf-8");
33
+ JSON.parse(content);
34
+ return { name, passed: true, detail: configPath };
35
+ }
36
+ catch (e) {
37
+ return { name, passed: false, detail: `Invalid JSON: ${configPath} — ${e}` };
38
+ }
39
+ }
40
+ function checkClaudeCode() {
41
+ const name = "Claude Code";
42
+ const homeDir = os.homedir();
43
+ // optional: warn if absent but don't fail exit code
44
+ const claudeDir = path.join(homeDir, ".claude");
45
+ if (!fs.existsSync(claudeDir)) {
46
+ return { name, passed: false, optional: true, detail: `Not detected — run 'rafter agent init --claude-code' to enable` };
47
+ }
48
+ const settingsPath = path.join(claudeDir, "settings.json");
49
+ if (!fs.existsSync(settingsPath)) {
50
+ return { name, passed: false, optional: true, detail: `Settings file not found: ${settingsPath}` };
51
+ }
52
+ try {
53
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
54
+ const hooks = settings?.hooks?.PreToolUse || [];
55
+ const hasRafterHook = hooks.some((entry) => (entry.hooks || []).some((h) => h.command === "rafter hook pretool"));
56
+ if (!hasRafterHook) {
57
+ return { name, passed: false, optional: true, detail: "Rafter hooks not installed — run 'rafter agent init --claude-code'" };
58
+ }
59
+ return { name, passed: true, detail: "Hooks installed" };
60
+ }
61
+ catch (e) {
62
+ return { name, passed: false, optional: true, detail: `Cannot read settings: ${e}` };
63
+ }
64
+ }
65
+ function checkOpenClaw() {
66
+ const name = "OpenClaw";
67
+ const skillManager = new SkillManager();
68
+ if (!skillManager.isOpenClawInstalled()) {
69
+ return { name, passed: false, optional: true, detail: `Not detected — run 'rafter agent init' to enable` };
70
+ }
71
+ if (!skillManager.isRafterSkillInstalled()) {
72
+ return { name, passed: false, optional: true, detail: `Rafter skill not installed — run 'rafter agent init'` };
73
+ }
74
+ const version = skillManager.getInstalledVersion();
75
+ return { name, passed: true, detail: `Rafter skill installed${version ? ` (v${version})` : ""}` };
76
+ }
77
+ export function createVerifyCommand() {
78
+ return new Command("verify")
79
+ .description("Check agent security integration status")
80
+ .action(async () => {
81
+ console.log(fmt.header("Rafter Agent Verify"));
82
+ console.log(fmt.divider());
83
+ console.log();
84
+ const results = [
85
+ checkConfig(),
86
+ await checkGitleaks(),
87
+ checkClaudeCode(),
88
+ checkOpenClaw(),
89
+ ];
90
+ for (const r of results) {
91
+ if (r.passed) {
92
+ console.log(fmt.success(`${r.name}: ${r.detail}`));
93
+ }
94
+ else if (r.optional) {
95
+ console.log(fmt.warning(`${r.name}: ${r.detail}`));
96
+ }
97
+ else {
98
+ console.log(fmt.error(`${r.name}: FAIL — ${r.detail}`));
99
+ }
100
+ }
101
+ console.log();
102
+ const hardFailed = results.filter((r) => !r.passed && !r.optional);
103
+ const warned = results.filter((r) => !r.passed && r.optional);
104
+ const passed = results.filter((r) => r.passed);
105
+ if (hardFailed.length === 0) {
106
+ const warnNote = warned.length > 0 ? ` (${warned.length} optional check${warned.length > 1 ? "s" : ""} not configured)` : "";
107
+ console.log(fmt.success(`${passed.length}/${results.length} core checks passed${warnNote}`));
108
+ }
109
+ else {
110
+ console.log(fmt.error(`${hardFailed.length} check${hardFailed.length > 1 ? "s" : ""} failed`));
111
+ }
112
+ console.log();
113
+ if (hardFailed.length > 0) {
114
+ process.exit(1);
115
+ }
116
+ });
117
+ }