diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2fddc957..0469b168 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,7 +13,7 @@ Rafter is a security CLI for AI coding agents. It ships as two feature-identical - `node/src/commands/` — CLI commands (commander.js) - `node/src/core/` — Command interceptor, audit logger, config manager -- `node/src/scanners/` — Gitleaks integration + regex-based secret scanner +- `node/src/scanners/` — Betterleaks integration + regex-based secret scanner - `node/src/commands/agent/init.ts` — Per-platform installation logic (8 platforms) - `python/rafter_cli/` — Mirrors the Node structure with typer - `shared-docs/CLI_SPEC.md` — Canonical output contracts and exit codes @@ -29,7 +29,7 @@ Rafter is a security CLI for AI coding agents. It ships as two feature-identical ## Key Patterns - Commands export a `createXCommand()` factory (Node) or use `@app.command()` decorators (Python) -- Scanners use dual-engine: Gitleaks binary first, regex fallback. Patterns defined in `secret-patterns.ts` / `secret_patterns.py` +- Scanners use dual-engine: Betterleaks binary first, regex fallback. Patterns defined in `secret-patterns.ts` / `secret_patterns.py` - Risk classification: critical > high > medium > low - Audit log: JSONL format, append-only, documented schema in CLI_SPEC.md - MCP server: 4 tools + 2 resources over stdio transport diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 39bc3018..51daac22 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,6 +1,6 @@ - id: rafter-scan name: Rafter Secret Scanner - description: Scan staged files for secrets (21+ patterns, Gitleaks integration) + description: Scan staged files for secrets (21+ patterns, Betterleaks integration) entry: rafter scan local --staged --quiet language: system stages: [pre-commit] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3968f1ae..8866043b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- **Secret-scanning engine migrated from gitleaks to betterleaks** (Node + Python, rc-ksy / rc-963). [Betterleaks](https://github.com/betterleaks/betterleaks) v1.1.2 is the gitleaks successor maintained by the same authors. JSON report shape is unchanged; what changed is the binary, the CLI subcommand (`detect --no-git -s` → `dir `), the release URL, and the checksum filename. + - **Breaking:** the legacy CLI surface has been removed entirely. `--with-gitleaks`, `--engine gitleaks`, and `rafter agent update-gitleaks` now error out (unknown option / invalid engine / unknown command). Use `--with-betterleaks`, `--engine betterleaks`, and `rafter agent update-betterleaks`. + - **Soft landing for existing installs:** `rafter agent verify` and `rafter agent status` continue to detect a leftover `~/.rafter/bin/gitleaks` (or `gitleaks` on PATH) and emit "legacy gitleaks at X — run: rafter agent update-betterleaks" instead of a confusing "not found". Verify exits 0 in this case (was a hard fail before this fix). + - **Supply-chain hardening:** SHA256 hashes for the bundled `BETTERLEAKS_VERSION` are pinned in source, so the default install no longer trusts the release-page `checksums.txt` to authenticate itself. Tar/zip extraction now rejects symlink/hardlink/device entries (mitigates a malicious-release symlink-redirect that the subsequent `chmod +x` would have followed). Downloads refuse non-https URLs. The optional `--version` flag is validated against `^[A-Za-z0-9._-]+$` to neutralize URL injection. Targets passed to betterleaks are preceded by `--` so a path beginning with `-` isn't parsed as a flag. + - Internal renames: `GitleaksScanner` → `BetterleaksScanner`, `*_gitleaks` methods → `*_betterleaks`, `GITLEAKS_VERSION` → `BETTERLEAKS_VERSION`. New tests cover pinned-hash table completeness, `--version` validation, non-https refusal, and the alias-removal contract. + - **OpenClaw integration rebuilt as a ClawHub-shaped skill** (Node + Python, rf-zgwj). Previously rafter wrote a single markdown file at `~/.openclaw/skills/rafter-security.md` — a path OpenClaw never read at runtime. ClawHub auto-discovers skills from `/skills//SKILL.md`. The new install: - Writes `~/.openclaw/workspace/skills/rafter-security/SKILL.md` (the canonical ClawHub path). - Adds the ClawHub-required top-level frontmatter (`name`, `description`, `version`) alongside the existing `openclaw:` runtime block. Now passes ClawHub's metadata schema check. diff --git a/CLAUDE.md b/CLAUDE.md index d98563d9..d52bd9c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ cd python && poetry install && pytest │ │ │ ├── audit-logger.ts # JSONL audit trail │ │ │ └── config-manager.ts # .rafter.yml + global config │ │ └── scanners/ -│ │ ├── gitleaks.ts # Gitleaks binary integration +│ │ ├── betterleaks.ts # Betterleaks binary integration │ │ ├── secret-patterns.ts # DEFAULT_SECRET_PATTERNS array (21+ patterns) │ │ └── regex-scanner.ts # RegexScanner class (imports secret-patterns) │ └── tests/ # Vitest test files @@ -40,7 +40,7 @@ cd python && poetry install && pytest │ ├── rafter_cli/ │ │ ├── commands/ # CLI commands (typer) │ │ ├── core/ # Mirrors node/src/core/ -│ │ └── scanners/ # secret_patterns.py + regex_scanner.py + gitleaks.py +│ │ └── scanners/ # secret_patterns.py + regex_scanner.py + betterleaks.py │ └── tests/ # pytest test files ├── shared-docs/ # Canonical specs (both implementations follow these) │ └── CLI_SPEC.md # Output contracts, exit codes, JSON schemas @@ -58,7 +58,7 @@ cd python && poetry install && pytest **Risk classification**: Commands are classified into 4 tiers (critical/high/medium/low) by pattern matching in `command-interceptor.ts`. Policy files (`.rafter.yml`) can override defaults. -**Secret scanning**: Dual-engine — tries Gitleaks binary first (higher accuracy), falls back to built-in regex patterns (21+ patterns, zero dependencies). Deterministic for a given version. +**Secret scanning**: Dual-engine — tries Betterleaks binary first (higher accuracy), falls back to built-in regex patterns (21+ patterns, zero dependencies). Deterministic for a given version. Betterleaks is the gitleaks successor maintained by the original gitleaks authors. Existing installs with a leftover `~/.rafter/bin/gitleaks` are detected by `agent verify`/`status` so users get an upgrade hint, but the legacy CLI flags (`--with-gitleaks`, `--engine gitleaks`, `update-gitleaks`) have been removed. **MCP server**: `rafter mcp serve` exposes 4 tools (`scan_secrets`, `evaluate_command`, `read_audit_log`, `get_config`) and 2 resources (`rafter://config`, `rafter://policy`) over stdio. diff --git a/Dockerfile b/Dockerfile index 63c6f875..a46b7f2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ FROM node:22-alpine RUN apk add --no-cache git \ && npm install -g @rafter-security/cli \ - && rafter agent init --with-gitleaks 2>/dev/null || true + && rafter agent init --with-betterleaks WORKDIR /workspace diff --git a/README.md b/README.md index 9bc18ce8..cc20bfbd 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ rafter secrets . ```sh rafter agent init --all # → Installs all detected integrations -# → Downloads Gitleaks (or falls back to built-in scanner) +# → Downloads Betterleaks (or falls back to built-in scanner) ``` **3. Try to commit—hook blocks it** @@ -184,7 +184,7 @@ This command: - Creates `~/.rafter/` config and audit log (or `./.rafter/` with `--local` for ephemeral / containerized / benchmark setups) - Auto-detects Claude Code, Codex CLI, OpenClaw, Gemini, Cursor, Windsurf, Continue.dev, and Aider - With `--with-*` or `--all`: installs Rafter skills/extensions to opted-in agents -- With `--with-gitleaks` or `--all`: downloads [Gitleaks](https://github.com/gitleaks/gitleaks) for enhanced secret scanning (falls back to built-in 21-pattern regex scanner) +- With `--with-betterleaks` or `--all`: downloads [Betterleaks](https://github.com/betterleaks/betterleaks) (the gitleaks successor maintained by the original gitleaks authors) for enhanced secret scanning. Falls back to built-in 21-pattern regex scanner. Use `rafter agent list/enable/disable` for granular per-component control after the initial install — toggle any platform on or off without re-running `init`. @@ -197,7 +197,7 @@ rafter secrets . # scan directory rafter secrets ./config.js # scan specific file rafter secrets --staged # scan git staged files only rafter secrets --diff HEAD~1 # scan files changed since a git ref -rafter secrets --history # scan full git history (requires gitleaks engine) +rafter secrets --history # scan full git history (requires betterleaks engine) rafter secrets --json # structured output rafter secrets --quiet # silent unless secrets found (CI-friendly) ``` @@ -223,7 +223,7 @@ Exit code 1 if secrets found, 0 if clean. Raw secret values are never included in output. Pipe to `jq`, feed to CI gates, or hand to any tool that reads JSON. -**Engine selection:** Uses Gitleaks when available (more patterns), falls back to built-in regex. Override with `--engine gitleaks|patterns|auto`. +**Engine selection:** Uses Betterleaks when available (more patterns), falls back to built-in regex. Override with `--engine betterleaks|patterns|auto`. ### Pre-Commit Hook @@ -524,7 +524,7 @@ Exit codes are part of Rafter's output contract — CI pipelines and orchestrato ~/.rafter/ ├── config.json # Configuration ├── audit.jsonl # Security event log (JSON lines) -├── bin/gitleaks # Gitleaks binary +├── bin/betterleaks # Betterleaks binary ├── patterns/ # Custom patterns (reserved) └── git-hooks/ # Global pre-commit hook (if --global) ``` diff --git a/SKILL.md b/SKILL.md index 2700a7a1..c7d6da9f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -32,8 +32,8 @@ rafter agent init --with-continue # MCP server rafter agent init --with-aider # MCP server rafter agent init --with-openclaw # Skills -# Also download Gitleaks for enhanced scanning (optional, falls back to built-in 21-pattern regex) -rafter agent init --with-claude-code --with-gitleaks +# Also download Betterleaks for enhanced scanning (optional, falls back to built-in 21-pattern regex) +rafter agent init --with-claude-code --with-betterleaks ``` **What init does per platform:** diff --git a/action.yml b/action.yml index 63ef712a..68c813a8 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ # Rafter Security — GitHub Action # -# Deterministic secret scanning for CI. 21+ credential patterns via Gitleaks, +# Deterministic secret scanning for CI. 21+ credential patterns via Betterleaks, # stable exit codes (0 = clean, 1 = findings, 2 = error), structured JSON output. # No API key required. No code leaves the runner. # diff --git a/drafts/show-hn/faq.md b/drafts/show-hn/faq.md index ff4ea588..645296e1 100644 --- a/drafts/show-hn/faq.md +++ b/drafts/show-hn/faq.md @@ -4,11 +4,11 @@ Responses written in founder voice. Adapt as needed based on the actual question --- -## "How is this different from gitleaks / trufflehog?" +## "How is this different from betterleaks (or gitleaks) / trufflehog?" -Rafter actually wraps gitleaks when it's available -- if the binary is on your PATH, we use it as the primary scanner because it's excellent. Our built-in regex engine (21+ patterns) is the fallback for zero-dependency environments. +Rafter actually wraps betterleaks (the gitleaks successor) when it's available -- if the binary is on your PATH, we use it as the primary scanner because it's excellent. Our built-in regex engine (21+ patterns) is the fallback for zero-dependency environments. -The difference is everything around the scan. Gitleaks and trufflehog are standalone secret scanners. Rafter adds command interception (blocking `curl | bash` before your agent runs it), audit logging of agent sessions, MCP integration so the agent itself can check for secrets, pre-commit hooks, and one-command setup for 8 different AI platforms. If you're just scanning repos for secrets, gitleaks is great and you don't need us. If you're running AI coding agents and want guardrails around the whole session, that's what Rafter is for. +The difference is everything around the scan. Betterleaks and trufflehog are standalone secret scanners. Rafter adds command interception (blocking `curl | bash` before your agent runs it), audit logging of agent sessions, MCP integration so the agent itself can check for secrets, pre-commit hooks, and one-command setup for 8 different AI platforms. If you're just scanning repos for secrets, betterleaks is great and you don't need us. If you're running AI coding agents and want guardrails around the whole session, that's what Rafter is for. --- diff --git a/drafts/show-hn/post.md b/drafts/show-hn/post.md index 30154c95..1447ba35 100644 --- a/drafts/show-hn/post.md +++ b/drafts/show-hn/post.md @@ -4,7 +4,7 @@ If you use Claude Code, Codex CLI, Cursor, Gemini CLI, or similar tools, your AI **What it does:** -- **Secret scanning**: 21+ built-in regex patterns plus optional Gitleaks integration. Deterministic for a given version. Stable exit codes for CI. +- **Secret scanning**: 21+ built-in regex patterns plus optional Betterleaks integration (the gitleaks successor). Deterministic for a given version. Stable exit codes for CI. - **Command interception**: Classifies commands into risk tiers (critical/high/medium/low) and enforces approval policies before execution. - **Audit logging**: JSONL trail of every command your agent runs and every secret scan result. - **MCP server**: 4 tools (`scan_secrets`, `evaluate_command`, `read_audit_log`, `get_config`) so AI agents can query security status natively. diff --git a/fixtures/vulnerable-repo/README.md b/fixtures/vulnerable-repo/README.md index 231f1fb4..16cafc2c 100644 --- a/fixtures/vulnerable-repo/README.md +++ b/fixtures/vulnerable-repo/README.md @@ -11,8 +11,8 @@ validating Rafter's secret scanning and command interception. # Scan with Rafter regex scanner rafter scan local ./fixtures/vulnerable-repo -# Scan with Gitleaks engine -rafter scan local ./fixtures/vulnerable-repo --engine gitleaks +# Scan with Betterleaks engine +rafter scan local ./fixtures/vulnerable-repo --engine betterleaks # Scan with built-in patterns engine (tests all 21+ patterns) rafter scan local ./fixtures/vulnerable-repo --engine patterns diff --git a/llms.txt b/llms.txt index 21a1320b..a9b7af7d 100644 --- a/llms.txt +++ b/llms.txt @@ -38,7 +38,7 @@ rafter secrets --diff main # only files changed since a ref rafter secrets --json . # JSON output for piping to jq / orchestrators # Install Rafter into every detected agent platform on this machine -rafter agent init --all # also downloads gitleaks binary +rafter agent init --all # also downloads betterleaks binary rafter agent init --interactive # prompted setup rafter agent init --with-claude-code # one specific platform @@ -118,7 +118,7 @@ Full schema: [docs.rafter.so/policy](https://docs.rafter.so/policy) (or see `sha | Continue.dev | `--with-continue` | MCP server config | | Aider | `--with-aider` | MCP server config | -Plus `--with-gitleaks` to install the upstream Gitleaks binary for higher-recall secret detection (Rafter falls back to 21+ built-in regex patterns if absent). +Plus `--with-betterleaks` to install the upstream Betterleaks binary (the gitleaks successor) for higher-recall secret detection (Rafter falls back to 21+ built-in regex patterns if absent). ## MCP Server diff --git a/node/.claude/skills/rafter/docs/cli-reference.md b/node/.claude/skills/rafter/docs/cli-reference.md index bcb655e0..e150b749 100644 --- a/node/.claude/skills/rafter/docs/cli-reference.md +++ b/node/.claude/skills/rafter/docs/cli-reference.md @@ -32,11 +32,11 @@ Example: `rafter run --repo myorg/api --branch feature/auth --mode plus --format ### `rafter scan local [path]` -Local secret scan. Deterministic, offline, no API key. Dual-engine: Gitleaks binary if present, built-in regex fallback (21+ patterns). +Local secret scan. Deterministic, offline, no API key. Dual-engine: Betterleaks binary if present, built-in regex fallback (21+ patterns). When: pre-commit, pre-push, fast first pass before remote scan, air-gapped envs. -Useful flags: `--history` (scan git history with Gitleaks), `--format json`, `--quiet`. +Useful flags: `--history` (scan git history with Betterleaks), `--format json`, `--quiet`. Example: `rafter scan local . --format json` @@ -82,7 +82,7 @@ Alias for `rafter scan local` kept for back-compat. Prefer `rafter scan local`. ### `rafter agent status` · `rafter agent verify` -`status`: dump config, hook state, gitleaks availability, audit log location. +`status`: dump config, hook state, betterleaks availability, audit log location. `verify`: sanity-check installation; exit non-zero if anything is broken. ### `rafter agent init [--with-]` @@ -109,9 +109,9 @@ Snapshot current findings so only *new* ones fail future scans. Emit a ready-to-paste instruction block for an agent's system prompt. -### `rafter agent update-gitleaks` +### `rafter agent update-betterleaks` -Download / upgrade the Gitleaks binary Rafter uses for local scans. +Download / upgrade the Betterleaks binary Rafter uses for local scans. --- diff --git a/node/resources/agents/rafter.md b/node/resources/agents/rafter.md index 34c7a1c2..01676b4c 100644 --- a/node/resources/agents/rafter.md +++ b/node/resources/agents/rafter.md @@ -22,7 +22,7 @@ Rafter ships three CLI tiers **and** four in-repo skills. They are NOT interchan 1. **`rafter run`** (default mode) — remote SAST + SCA + secrets via the Rafter API. Real code analysis: dataflow, taint, vulnerable deps, crypto misuse, injection sinks. Needs `RAFTER_API_KEY`. **This is the default for "is this safe / secure / production worthy?".** 2. **`rafter run --mode plus`** — agentic deep-dive on suspicious patterns. Slower, higher signal. Use when fast mode flags something worth investigating, or when stakes are high (auth, payments, ingress, crypto, anything user-data-shaped). -3. **`rafter secrets [path]`** — local secrets only (regex + gitleaks for hardcoded API keys, tokens, private keys). Fast, offline, no key. **NOT a code security scan.** Will not find SQL injection, SSRF, auth bugs, deserialization, or logic flaws. Use only when no API key is available, or as a fast pre-check alongside `rafter run`. +3. **`rafter secrets [path]`** — local secrets only (regex + betterleaks for hardcoded API keys, tokens, private keys). Fast, offline, no key. **NOT a code security scan.** Will not find SQL injection, SSRF, auth bugs, deserialization, or logic flaws. Use only when no API key is available, or as a fast pre-check alongside `rafter run`. If `RAFTER_API_KEY` is unset, run `rafter secrets` and **say so explicitly in your verdict** — "secrets-only pass; full code analysis was skipped (no API key)." Do not claim the code was "scanned" without that qualification. Never silently downgrade. diff --git a/node/resources/rafter-security-skill.md b/node/resources/rafter-security-skill.md index 066fef62..6b499c0c 100644 --- a/node/resources/rafter-security-skill.md +++ b/node/resources/rafter-security-skill.md @@ -40,7 +40,7 @@ To initialize Rafter, use **opt-in** `--with-*` flags to select integrations. Th ```bash # Install specific integrations (opt-in) rafter agent init --with-openclaw -rafter agent init --with-claude-code --with-gitleaks +rafter agent init --with-claude-code --with-betterleaks # Install everything detected rafter agent init --all diff --git a/node/resources/skills/rafter/SKILL.md b/node/resources/skills/rafter/SKILL.md index 5385aedb..aed187f3 100644 --- a/node/resources/skills/rafter/SKILL.md +++ b/node/resources/skills/rafter/SKILL.md @@ -11,7 +11,7 @@ allowed-tools: [Bash, Read] Rafter ships three tiers. **They are not interchangeable.** The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. -1. **Local (`rafter secrets`)** — secrets only. Regex + gitleaks for hardcoded API keys, tokens, private keys. Fast, offline, no key. **This is NOT a code security scan.** It will not find SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. If an agent's entire rafter interaction was `rafter secrets .` and it exited clean, the agent has done secret-hygiene only — not security review. +1. **Local (`rafter secrets`)** — secrets only. Regex + betterleaks for hardcoded API keys, tokens, private keys. Fast, offline, no key. **This is NOT a code security scan.** It will not find SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. If an agent's entire rafter interaction was `rafter secrets .` and it exited clean, the agent has done secret-hygiene only — not security review. 2. **Remote fast (`rafter run`, default mode)** — SAST + SCA + secrets via the Rafter API. This is the real code-analysis pass: dataflow, taint, known-vulnerable dependencies, crypto misuse, injection sinks. Needs `RAFTER_API_KEY`. 3. **Remote plus (`rafter run --mode plus`)** — agentic deep-dive: LLM-guided investigation of suspicious patterns the rules engine flags. Slower, higher signal. Code is deleted server-side after the run. diff --git a/node/resources/skills/rafter/docs/cli-reference.md b/node/resources/skills/rafter/docs/cli-reference.md index 6330e513..23d33a68 100644 --- a/node/resources/skills/rafter/docs/cli-reference.md +++ b/node/resources/skills/rafter/docs/cli-reference.md @@ -32,11 +32,11 @@ Example: `rafter run --repo myorg/api --branch feature/auth --mode plus --format ### `rafter secrets [path]` -Local secret scan. Deterministic, offline, no API key. Dual-engine: Gitleaks binary if present, built-in regex fallback (21+ patterns). +Local secret scan. Deterministic, offline, no API key. Dual-engine: Betterleaks binary if present, built-in regex fallback (21+ patterns). When: pre-commit, pre-push, fast first pass before remote scan, air-gapped envs. -Useful flags: `--history` (scan git history with Gitleaks), `--format json`, `--quiet`. +Useful flags: `--history` (scan git history with Betterleaks), `--format json`, `--quiet`. Example: `rafter secrets . --format json` @@ -80,7 +80,7 @@ Audit a single skill file (SKILL.md). Flags prompt-injection, unbounded tool use ### `rafter agent status` · `rafter agent verify` -`status`: dump config, hook state, gitleaks availability, audit log location. +`status`: dump config, hook state, betterleaks availability, audit log location. `verify`: sanity-check installation; exit non-zero if anything is broken. ### `rafter agent init [--with-]` @@ -107,9 +107,9 @@ Snapshot current findings so only *new* ones fail future scans. Emit a ready-to-paste instruction block for an agent's system prompt. -### `rafter agent update-gitleaks` +### `rafter agent update-betterleaks` -Download / upgrade the Gitleaks binary Rafter uses for local scans. +Download / upgrade the Betterleaks binary Rafter uses for local scans. --- diff --git a/node/src/commands/agent/baseline.ts b/node/src/commands/agent/baseline.ts index 9fe9f1ba..26776fe5 100644 --- a/node/src/commands/agent/baseline.ts +++ b/node/src/commands/agent/baseline.ts @@ -4,7 +4,7 @@ import os from "os"; import path from "path"; import { fmt } from "../../utils/formatter.js"; import { RegexScanner } from "../../scanners/regex-scanner.js"; -import { GitleaksScanner } from "../../scanners/gitleaks.js"; +import { BetterleaksScanner } from "../../scanners/betterleaks.js"; import { ConfigManager } from "../../core/config-manager.js"; const BASELINE_PATH = path.join(os.homedir(), ".rafter", "baseline.json"); @@ -58,9 +58,9 @@ function createBaselineCreateCommand(): Command { return new Command("create") .description("Scan and save all current findings as the baseline") .argument("[path]", "Path to scan", ".") - .option("--engine ", "Scan engine: gitleaks or patterns", "auto") + .option("--engine ", "Scan engine: betterleaks or patterns", "auto") .action(async (scanPath: string, opts: { engine?: string }) => { - const validEngines = ["auto", "gitleaks", "patterns"]; + const validEngines = ["auto", "betterleaks", "patterns"]; const engineValue = opts.engine || "auto"; if (!validEngines.includes(engineValue)) { console.error(`Invalid engine: ${engineValue}. Valid values: ${validEngines.join(", ")}`); @@ -204,28 +204,28 @@ function createBaselineAddCommand(): Command { // ── helpers ───────────────────────────────────────────────────────── -async function selectEngine(preference: string): Promise<"gitleaks" | "patterns"> { +async function selectEngine(preference: string): Promise<"betterleaks" | "patterns"> { if (preference === "patterns") return "patterns"; - if (preference === "gitleaks") { - const g = new GitleaksScanner(); - return (await g.isAvailable()) ? "gitleaks" : "patterns"; + if (preference === "betterleaks") { + const b = new BetterleaksScanner(); + return (await b.isAvailable()) ? "betterleaks" : "patterns"; } if (preference !== "auto") { - console.error(`Invalid engine: ${preference}. Valid values: auto, gitleaks, patterns`); + console.error(`Invalid engine: ${preference}. Valid values: auto, betterleaks, patterns`); process.exit(2); } - const g = new GitleaksScanner(); - return (await g.isAvailable()) ? "gitleaks" : "patterns"; + const b = new BetterleaksScanner(); + return (await b.isAvailable()) ? "betterleaks" : "patterns"; } async function scanFile( filePath: string, - engine: "gitleaks" | "patterns", + engine: "betterleaks" | "patterns", ) { - if (engine === "gitleaks") { + if (engine === "betterleaks") { try { - const g = new GitleaksScanner(); - const r = await g.scanFile(filePath); + const b = new BetterleaksScanner(); + const r = await b.scanFile(filePath); return r.matches.length > 0 ? [r] : []; } catch { const s = new RegexScanner(); @@ -240,13 +240,13 @@ async function scanFile( async function scanDirectory( dirPath: string, - engine: "gitleaks" | "patterns", + engine: "betterleaks" | "patterns", scanCfg?: { excludePaths?: string[] }, ) { - if (engine === "gitleaks") { + if (engine === "betterleaks") { try { - const g = new GitleaksScanner(); - return await g.scanDirectory(dirPath); + const b = new BetterleaksScanner(); + return await b.scanDirectory(dirPath); } catch { const s = new RegexScanner(); return s.scanDirectory(dirPath, { excludePaths: scanCfg?.excludePaths }); diff --git a/node/src/commands/agent/index.ts b/node/src/commands/agent/index.ts index 5e816d1d..8e540116 100644 --- a/node/src/commands/agent/index.ts +++ b/node/src/commands/agent/index.ts @@ -9,7 +9,7 @@ import { createAuditSkillCommand } from "./audit-skill.js"; import { createInstallHookCommand } from "./install-hook.js"; import { createVerifyCommand } from "./verify.js"; import { createStatusCommand } from "./status.js"; -import { createUpdateGitleaksCommand } from "./update-gitleaks.js"; +import { createUpdateBetterleaksCommand } from "./update-betterleaks.js"; import { createBaselineCommand } from "./baseline.js"; import { createListCommand } from "./list.js"; import { createEnableCommand } from "./enable.js"; @@ -30,7 +30,7 @@ export function createAgentCommand(): Command { agent.addCommand(createInstallHookCommand()); agent.addCommand(createVerifyCommand()); agent.addCommand(createStatusCommand()); - agent.addCommand(createUpdateGitleaksCommand()); + agent.addCommand(createUpdateBetterleaksCommand()); agent.addCommand(createBaselineCommand()); agent.addCommand(createListCommand()); agent.addCommand(createEnableCommand()); diff --git a/node/src/commands/agent/init.ts b/node/src/commands/agent/init.ts index 2546e4a9..a8c3d0fb 100644 --- a/node/src/commands/agent/init.ts +++ b/node/src/commands/agent/init.ts @@ -892,10 +892,10 @@ export function createInitCommand(): Command { .option("--with-cursor", "Install Cursor integration") .option("--with-windsurf", "Install Windsurf integration") .option("--with-continue", "Install Continue.dev integration") - .option("--with-gitleaks", "Download and install Gitleaks binary") - .option("--all", "Install all detected integrations and download Gitleaks") + .option("--with-betterleaks", "Download and install Betterleaks binary") + .option("--all", "Install all detected integrations and download Betterleaks") .option("-i, --interactive", "Guided setup — prompts for each detected integration") - .option("--update", "Re-download gitleaks and reinstall integrations without resetting config") + .option("--update", "Re-download betterleaks and reinstall integrations without resetting config") .option( "--local", "Install integration configs project-locally (in CWD) instead of user-globally. " + @@ -950,7 +950,7 @@ export function createInitCommand(): Command { // Aider can install at --local scope (writes RAFTER.md + .aider.conf.yml // in cwd) since rf-du2o. let wantAider = opts.withAider || opts.all; - let wantGitleaks = opts.withGitleaks || (opts.all && !opts.local); + let wantBetterleaks = opts.withBetterleaks || (opts.all && !opts.local); // Interactive mode: prompt for each detected integration if (opts.interactive && !opts.all) { @@ -965,7 +965,7 @@ export function createInitCommand(): Command { if (hasWindsurf && !wantWindsurf) wantWindsurf = await askYesNo("Install Windsurf MCP + hooks?"); if (hasContinueDev && !wantContinue) wantContinue = await askYesNo("Install Continue.dev MCP server?"); if (hasAider && !wantAider) wantAider = await askYesNo("Install Aider MCP server?"); - if (!wantGitleaks) wantGitleaks = await askYesNo("Download Gitleaks binary (enhanced scanning)?"); + if (!wantBetterleaks) wantBetterleaks = await askYesNo("Download Betterleaks binary (enhanced scanning)?"); console.log(); } @@ -1019,8 +1019,8 @@ export function createInitCommand(): Command { manager.set("agent.riskLevel", opts.riskLevel); console.log(fmt.success(`Set risk level: ${opts.riskLevel}`)); - // Check / download Gitleaks binary (opt-in via --with-gitleaks or --all) - if (wantGitleaks) { + // Check / download Betterleaks binary (opt-in via --with-betterleaks or --all) + if (wantBetterleaks) { const binaryManager = new BinaryManager(); const platformInfo = binaryManager.getPlatformInfo(); @@ -1034,51 +1034,51 @@ export function createInitCommand(): Command { console.log(fmt.info("Diagnostics:")); console.log(diag); } - 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'.")); + console.log(fmt.info("To fix: install betterleaks (https://github.com/betterleaks/betterleaks/releases) and ensure it is on PATH, then re-run 'rafter agent init'.")); console.log(); }; - if (!opts.update && binaryManager.isGitleaksInstalled()) { + if (!opts.update && binaryManager.isBetterleaksInstalled()) { // Local binary exists — verify it actually works - const verResult = await binaryManager.verifyGitleaksVerbose(); + const verResult = await binaryManager.verifyBetterleaksVerbose(); if (verResult.ok) { - console.log(fmt.success(`Gitleaks already installed (${verResult.stdout})`)); + console.log(fmt.success(`Betterleaks already installed (${verResult.stdout})`)); } else { - console.log(fmt.warning("Gitleaks binary found locally but failed to execute.")); - console.log(fmt.info(` Binary: ${binaryManager.getGitleaksPath()}`)); - await showDiagnostics(binaryManager.getGitleaksPath(), verResult); + console.log(fmt.warning("Betterleaks binary found locally but failed to execute.")); + console.log(fmt.info(` Binary: ${binaryManager.getBetterleaksPath()}`)); + await showDiagnostics(binaryManager.getBetterleaksPath(), verResult); } } else { // Not installed locally (or --update forcing re-download) — check PATH first // unless --update was passed (in that case force a fresh managed install) - const pathBinary = opts.update ? null : binaryManager.findGitleaksOnPath(); + const pathBinary = opts.update ? null : binaryManager.findBetterleaksOnPath(); if (pathBinary) { - const verResult = await binaryManager.verifyGitleaksVerbose(pathBinary); + const verResult = await binaryManager.verifyBetterleaksVerbose(pathBinary); if (verResult.ok) { - console.log(fmt.success(`Gitleaks available on PATH (${verResult.stdout})`)); + console.log(fmt.success(`Betterleaks available on PATH (${verResult.stdout})`)); } else { - console.log(fmt.warning("Gitleaks found on PATH but failed to execute.")); + console.log(fmt.warning("Betterleaks found on PATH but failed to execute.")); console.log(fmt.info(` Binary: ${pathBinary}`)); await showDiagnostics(pathBinary, verResult); } } else if (!platformInfo.supported) { - console.log(fmt.info(`Gitleaks not available for ${platformInfo.platform}/${platformInfo.arch}`)); + console.log(fmt.info(`Betterleaks not available for ${platformInfo.platform}/${platformInfo.arch}`)); console.log(fmt.success("Using pattern-based scanning (21 patterns)")); } else { // Not on PATH, not installed locally — download console.log(); - console.log(fmt.info("Downloading Gitleaks (enhanced secret detection)...")); + console.log(fmt.info("Downloading Betterleaks (enhanced secret detection)...")); try { - await binaryManager.downloadGitleaks((msg) => { + await binaryManager.downloadBetterleaks((msg) => { console.log(` ${msg}`); }); console.log(); } catch (e) { console.log(); - console.log(fmt.error(`Gitleaks setup failed — pattern-based scanning will be used instead.`)); + console.log(fmt.error(`Betterleaks setup failed — pattern-based scanning will be used instead.`)); console.log(fmt.warning(String(e))); console.log(); - console.log(fmt.info("To fix: install gitleaks manually (https://github.com/gitleaks/gitleaks/releases) and ensure it is on PATH, then re-run 'rafter agent init'.")); + console.log(fmt.info("To fix: install betterleaks manually (https://github.com/betterleaks/betterleaks/releases) and ensure it is on PATH, then re-run 'rafter agent init'.")); console.log(); } } diff --git a/node/src/commands/agent/scan.ts b/node/src/commands/agent/scan.ts index cb342e8c..f0e4c42d 100644 --- a/node/src/commands/agent/scan.ts +++ b/node/src/commands/agent/scan.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { RegexScanner, ScanResult } from "../../scanners/regex-scanner.js"; -import { GitleaksScanner } from "../../scanners/gitleaks.js"; +import { BetterleaksScanner } from "../../scanners/betterleaks.js"; import { ConfigManager } from "../../core/config-manager.js"; import { AuditLogger } from "../../core/audit-logger.js"; import { @@ -77,13 +77,13 @@ export function createScanCommand(): Command { .option("--format ", "Output format: text, json, sarif", "text") .option("--staged", "Scan only git staged files") .option("--diff ", "Scan files changed since a git ref") - .option("--engine ", "Scan engine: gitleaks or patterns", "auto") + .option("--engine ", "Scan engine: betterleaks or patterns", "auto") .option("--baseline", "Filter findings present in the saved baseline") .option("--watch", "Watch for file changes and re-scan on change") - .option("--history", "Scan git history for secrets (requires gitleaks engine)") + .option("--history", "Scan git history for secrets (requires betterleaks engine)") .action(async (scanPath, opts: ScanOpts) => { - // Validate flags before doing any work - const validEngines = ["auto", "gitleaks", "patterns"]; + // Validate flags before doing any work. + const validEngines = ["auto", "betterleaks", "patterns"]; const engineValue = opts.engine || "auto"; if (!validEngines.includes(engineValue)) { console.error(`Invalid engine: ${engineValue}. Valid values: ${validEngines.join(", ")}`); @@ -172,7 +172,7 @@ export function createSecretsCommand(): Command { const cmd = createScanCommand(); cmd.name("secrets"); cmd.description( - "Scan files/directories for hardcoded secrets (regex + gitleaks). Secrets only — not a code analysis. For full SAST/SCA, use 'rafter run'.", + "Scan files/directories for hardcoded secrets (regex + betterleaks). Secrets only — not a code analysis. For full SAST/SCA, use 'rafter run'.", ); return cmd; } @@ -459,33 +459,33 @@ async function scanStagedFiles( /** * Select scan engine based on availability and user preference */ -async function selectEngine(preference: string, quiet: boolean): Promise<"gitleaks" | "patterns"> { +async function selectEngine(preference: string, quiet: boolean): Promise<"betterleaks" | "patterns"> { if (preference === "patterns") { return "patterns"; } - if (preference === "gitleaks") { - const gitleaks = new GitleaksScanner(); - const available = await gitleaks.isAvailable(); + if (preference === "betterleaks") { + const bl = new BetterleaksScanner(); + const available = await bl.isAvailable(); if (!available) { if (!quiet) { - console.error(fmt.warning("Gitleaks requested but not available, using patterns")); + console.error(fmt.warning("Betterleaks requested but not available, using patterns")); } return "patterns"; } - return "gitleaks"; + return "betterleaks"; } if (preference !== "auto") { - console.error(`Invalid engine: ${preference}. Valid values: auto, gitleaks, patterns`); + console.error(`Invalid engine: ${preference}. Valid values: auto, betterleaks, patterns`); process.exit(2); } - // Auto mode: try Gitleaks, fall back to patterns - const gitleaks = new GitleaksScanner(); - const available = await gitleaks.isAvailable(); + // Auto mode: try Betterleaks, fall back to patterns + const bl = new BetterleaksScanner(); + const available = await bl.isAvailable(); - return available ? "gitleaks" : "patterns"; + return available ? "betterleaks" : "patterns"; } /** @@ -493,16 +493,16 @@ async function selectEngine(preference: string, quiet: boolean): Promise<"gitlea */ async function scanFile( filePath: string, - engine: "gitleaks" | "patterns", + engine: "betterleaks" | "patterns", scanCfg?: { excludePaths?: string[]; customPatterns?: Array<{ name: string; regex: string; severity: string }> }, ): Promise { - if (engine === "gitleaks") { + if (engine === "betterleaks") { try { - const gitleaks = new GitleaksScanner(); - const result = await gitleaks.scanFile(filePath); + const bl = new BetterleaksScanner(); + const result = await bl.scanFile(filePath); return result.matches.length > 0 ? [result] : []; } catch (e) { - console.error(fmt.warning("Gitleaks scan failed, falling back to patterns")); + console.error(fmt.warning("Betterleaks scan failed, falling back to patterns")); const scanner = new RegexScanner(scanCfg?.customPatterns); const result = scanner.scanFile(filePath); return result.matches.length > 0 ? [result] : []; @@ -519,16 +519,16 @@ async function scanFile( */ async function scanDirectory( dirPath: string, - engine: "gitleaks" | "patterns", + engine: "betterleaks" | "patterns", scanCfg?: { excludePaths?: string[]; customPatterns?: Array<{ name: string; regex: string; severity: string }> }, history?: boolean, ): Promise { - if (engine === "gitleaks") { + if (engine === "betterleaks") { try { - const gitleaks = new GitleaksScanner(); - return await gitleaks.scanDirectory(dirPath, { useGit: history ?? false }); + const bl = new BetterleaksScanner(); + return await bl.scanDirectory(dirPath, { useGit: history ?? false }); } catch (e) { - console.error(fmt.warning("Gitleaks scan failed, falling back to patterns")); + console.error(fmt.warning("Betterleaks scan failed, falling back to patterns")); const scanner = new RegexScanner(scanCfg?.customPatterns); return scanner.scanDirectory(dirPath, { excludePaths: scanCfg?.excludePaths }); } diff --git a/node/src/commands/agent/status.ts b/node/src/commands/agent/status.ts index 9ba31202..e4646cd9 100644 --- a/node/src/commands/agent/status.ts +++ b/node/src/commands/agent/status.ts @@ -6,6 +6,7 @@ import { execSync } from "child_process"; import { getRafterDir, getAuditLogPath, getBinDir } from "../../core/config-defaults.js"; import { AuditLogger } from "../../core/audit-logger.js"; import { ConfigManager } from "../../core/config-manager.js"; +import { BinaryManager } from "../../utils/binary-manager.js"; export function createStatusCommand(): Command { return new Command("status") @@ -32,23 +33,30 @@ export function createStatusCommand(): Command { console.log(`\nConfig: not found — run: rafter agent init`); } - // --- Gitleaks --- - const localGitleaks = path.join(getBinDir(), "gitleaks"); - let gitleaksStatus = "not found — run: rafter agent init --with-gitleaks"; + // --- Betterleaks --- + const exeExt = process.platform === "win32" ? ".exe" : ""; + const localBetterleaks = path.join(getBinDir(), `betterleaks${exeExt}`); + let betterleaksStatus = "not found — run: rafter agent init --with-betterleaks"; try { - const ver = execSync("gitleaks version", { timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); - gitleaksStatus = `${ver} (PATH)`; + const ver = execSync("betterleaks version", { timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); + betterleaksStatus = `${ver} (PATH)`; } catch { - if (fs.existsSync(localGitleaks)) { + if (fs.existsSync(localBetterleaks)) { try { - const ver = execSync(`"${localGitleaks}" version`, { timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); - gitleaksStatus = `${ver} (local)`; + const ver = execSync(`"${localBetterleaks}" version`, { timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); + betterleaksStatus = `${ver} (local)`; } catch { - gitleaksStatus = `${localGitleaks} (binary error)`; + betterleaksStatus = `${localBetterleaks} (binary error)`; + } + } else { + // Legacy install — surface a hint instead of "not found" + const legacy = new BinaryManager().findLegacyGitleaks(); + if (legacy) { + betterleaksStatus = `not found — legacy gitleaks at ${legacy}; run: rafter agent update-betterleaks`; } } } - console.log(`Gitleaks: ${gitleaksStatus}`); + console.log(`Betterleaks: ${betterleaksStatus}`); // --- Claude Code hooks --- const settingsPath = path.join(home, ".claude", "settings.json"); diff --git a/node/src/commands/agent/update-betterleaks.ts b/node/src/commands/agent/update-betterleaks.ts new file mode 100644 index 00000000..8b719a7c --- /dev/null +++ b/node/src/commands/agent/update-betterleaks.ts @@ -0,0 +1,48 @@ +import { Command } from "commander"; +import { BinaryManager, BETTERLEAKS_VERSION } from "../../utils/binary-manager.js"; +import { fmt } from "../../utils/formatter.js"; + +export function createUpdateBetterleaksCommand(): Command { + return new Command("update-betterleaks") + .description("Update (or reinstall) the managed betterleaks binary") + .option( + "--version ", + "Betterleaks version to install", + BETTERLEAKS_VERSION, + ) + .action(async (opts) => { + const bm = new BinaryManager(); + + if (!bm.isPlatformSupported()) { + const { platform, arch } = bm.getPlatformInfo(); + console.error(fmt.error(`Betterleaks not available for ${platform}/${arch}`)); + process.exit(1); + } + + if (bm.isBetterleaksInstalled()) { + const current = await bm.getBetterleaksVersion(); + console.log(fmt.info(`Current: ${current}`)); + } else { + console.log(fmt.info("Betterleaks not currently installed (managed binary)")); + } + + console.log(fmt.info(`Installing betterleaks v${opts.version}...`)); + console.log(); + + try { + await bm.downloadBetterleaks((msg) => console.log(` ${msg}`), opts.version); + console.log(); + const installed = await bm.getBetterleaksVersion(); + console.log(fmt.success(`Betterleaks updated: ${installed}`)); + console.log(fmt.info(` Binary: ${bm.getBetterleaksPath()}`)); + } catch (e) { + console.log(); + console.error(fmt.error(`Update failed: ${e}`)); + console.log(fmt.info( + "To fix: install betterleaks manually (https://github.com/betterleaks/betterleaks/releases) " + + "and ensure it is on PATH." + )); + process.exit(1); + } + }); +} diff --git a/node/src/commands/agent/update-gitleaks.ts b/node/src/commands/agent/update-gitleaks.ts deleted file mode 100644 index de98f611..00000000 --- a/node/src/commands/agent/update-gitleaks.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Command } from "commander"; -import { BinaryManager, GITLEAKS_VERSION } from "../../utils/binary-manager.js"; -import { fmt } from "../../utils/formatter.js"; - -export function createUpdateGitleaksCommand(): Command { - return new Command("update-gitleaks") - .description("Update (or reinstall) the managed gitleaks binary") - .option( - "--version ", - "Gitleaks version to install", - GITLEAKS_VERSION, - ) - .action(async (opts) => { - const bm = new BinaryManager(); - - if (!bm.isPlatformSupported()) { - const { platform, arch } = bm.getPlatformInfo(); - console.error(fmt.error(`Gitleaks not available for ${platform}/${arch}`)); - process.exit(1); - } - - // Show current version if installed - if (bm.isGitleaksInstalled()) { - const current = await bm.getGitleaksVersion(); - console.log(fmt.info(`Current: ${current}`)); - } else { - console.log(fmt.info("Gitleaks not currently installed (managed binary)")); - } - - console.log(fmt.info(`Installing gitleaks v${opts.version}...`)); - console.log(); - - try { - await bm.downloadGitleaks((msg) => console.log(` ${msg}`), opts.version); - console.log(); - const installed = await bm.getGitleaksVersion(); - console.log(fmt.success(`Gitleaks updated: ${installed}`)); - console.log(fmt.info(` Binary: ${bm.getGitleaksPath()}`)); - } catch (e) { - console.log(); - console.error(fmt.error(`Update failed: ${e}`)); - console.log(fmt.info( - "To fix: install gitleaks manually (https://github.com/gitleaks/gitleaks/releases) " + - "and ensure it is on PATH." - )); - process.exit(1); - } - }); -} diff --git a/node/src/commands/agent/verify.ts b/node/src/commands/agent/verify.ts index 1dd593b1..fb3d9b8e 100644 --- a/node/src/commands/agent/verify.ts +++ b/node/src/commands/agent/verify.ts @@ -16,20 +16,31 @@ interface CheckResult { optional?: boolean; // optional checks warn but don't fail exit code } -async function checkGitleaks(): Promise { +async function checkBetterleaks(): Promise { const binaryManager = new BinaryManager(); - const name = "Gitleaks"; + const name = "Betterleaks"; // Check PATH first (e.g. Homebrew), then fall back to ~/.rafter/bin - const pathBinary = binaryManager.findGitleaksOnPath(); - const hasBinary = pathBinary !== null || binaryManager.isGitleaksInstalled(); + const pathBinary = binaryManager.findBetterleaksOnPath(); + const hasBinary = pathBinary !== null || binaryManager.isBetterleaksInstalled(); if (!hasBinary) { - return { name, passed: false, detail: `Not found on PATH or at ${binaryManager.getGitleaksPath()}` }; + // Soft-degrade if a legacy gitleaks install is still present — the user + // upgraded rafter but hasn't rerun `agent init --with-betterleaks` yet. + const legacy = binaryManager.findLegacyGitleaks(); + if (legacy) { + return { + name, + passed: false, + optional: true, + detail: `Not installed; found legacy gitleaks at ${legacy}. Run: rafter agent update-betterleaks`, + }; + } + return { name, passed: false, detail: `Not found on PATH or at ${binaryManager.getBetterleaksPath()}` }; } - const binaryPath = pathBinary ?? binaryManager.getGitleaksPath(); - const { ok, stdout, stderr } = await binaryManager.verifyGitleaksVerbose(binaryPath); + const binaryPath = pathBinary ?? binaryManager.getBetterleaksPath(); + const { ok, stdout, stderr } = await binaryManager.verifyBetterleaksVerbose(binaryPath); if (!ok) { const diag = await binaryManager.collectBinaryDiagnostics(binaryPath); return { name, passed: false, detail: `Binary found at ${binaryPath} but failed to execute\n${stdout ? ` stdout: ${stdout}\n` : ""}${stderr ? ` stderr: ${stderr}\n` : ""}${diag}` }; @@ -386,7 +397,7 @@ export function createVerifyCommand(): Command { const results: CheckResult[] = [ checkConfig(), - await checkGitleaks(), + await checkBetterleaks(), checkClaudeCode(), checkOpenClaw(), checkCodex(), diff --git a/node/src/commands/completion.ts b/node/src/commands/completion.ts index 51881ca6..fc386ff0 100644 --- a/node/src/commands/completion.ts +++ b/node/src/commands/completion.ts @@ -16,7 +16,7 @@ _rafter_completions() { return 0 ;; agent) - COMPREPLY=( $(compgen -W "scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline --help" -- "\${cur}") ) + COMPREPLY=( $(compgen -W "scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline --help" -- "\${cur}") ) return 0 ;; brief) @@ -67,7 +67,7 @@ _rafter_completions() { ;; init) if [[ "\${COMP_WORDS[1]}" == "agent" ]]; then - COMPREPLY=( $(compgen -W "--risk-level --with-openclaw --with-claude-code --with-codex --with-gemini --with-aider --with-cursor --with-windsurf --with-continue --with-gitleaks --all --help" -- "\${cur}") ) + COMPREPLY=( $(compgen -W "--risk-level --with-openclaw --with-claude-code --with-codex --with-gemini --with-aider --with-cursor --with-windsurf --with-continue --with-betterleaks --all --help" -- "\${cur}") ) elif [[ "\${COMP_WORDS[1]}" == "ci" ]]; then COMPREPLY=( $(compgen -W "--platform --output --with-remote --with-backend --help" -- "\${cur}") ) fi @@ -108,7 +108,7 @@ _rafter() { 'install-hook:Install git hook (pre-commit or pre-push)' 'verify:Check integration status' 'status:Show agent status' - 'update-gitleaks:Update gitleaks binary' + 'update-betterleaks:Update betterleaks binary' 'baseline:Manage findings baseline' ) @@ -168,7 +168,7 @@ _rafter() { '--json[Output as JSON]' \\ '--staged[Scan only staged files]' \\ '--diff[Scan files changed since ref]:ref:' \\ - '--engine[Scanner engine]:engine:(gitleaks patterns)' \\ + '--engine[Scanner engine]:engine:(betterleaks patterns)' \\ '1:path:_files' ;; init) @@ -182,7 +182,7 @@ _rafter() { '--with-cursor[Install Cursor integration]' \\ '--with-windsurf[Install Windsurf integration]' \\ '--with-continue[Install Continue.dev integration]' \\ - '--with-gitleaks[Download Gitleaks binary]' \\ + '--with-betterleaks[Download Betterleaks binary]' \\ '--all[Install all detected integrations]' ;; audit) @@ -317,21 +317,21 @@ complete -c rafter -n '__fish_seen_subcommand_from usage' -s k -l api-key -d 'AP complete -c rafter -n '__fish_seen_subcommand_from brief' -a 'security scanning commands setup setup/claude-code setup/codex setup/gemini setup/cursor setup/windsurf setup/aider setup/openclaw setup/continue setup/generic all' -d 'Topic' # agent subcommands -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a scan -d 'Scan files for secrets' -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a init -d 'Initialize agent security' -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a audit -d 'View audit log' -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a config -d 'Manage configuration' -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a exec -d 'Execute with security' -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a audit-skill -d 'Audit a skill file' -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a install-hook -d 'Install pre-commit hook' -complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-gitleaks baseline' -a verify -d 'Check integration status' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a scan -d 'Scan files for secrets' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a init -d 'Initialize agent security' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a audit -d 'View audit log' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a config -d 'Manage configuration' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a exec -d 'Execute with security' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a audit-skill -d 'Audit a skill file' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a install-hook -d 'Install pre-commit hook' +complete -c rafter -n '__fish_seen_subcommand_from agent; and not __fish_seen_subcommand_from scan init audit config exec audit-skill install-hook verify status update-betterleaks baseline' -a verify -d 'Check integration status' # agent scan options complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from scan' -s q -l quiet -d 'Only output if secrets found' complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from scan' -l json -d 'Output as JSON' complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from scan' -l staged -d 'Scan only staged files' complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from scan' -l diff -d 'Scan changed since ref' -r -complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from scan' -l engine -d 'Scanner engine' -ra 'gitleaks patterns' +complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from scan' -l engine -d 'Scanner engine' -ra 'betterleaks patterns' # agent init options complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l risk-level -d 'Risk level' -ra 'minimal moderate aggressive' @@ -343,7 +343,7 @@ complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcom complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-cursor -d 'Install Cursor' complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-windsurf -d 'Install Windsurf' complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-continue -d 'Install Continue.dev' -complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-gitleaks -d 'Install Gitleaks' +complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l with-betterleaks -d 'Install Betterleaks' complete -c rafter -n '__fish_seen_subcommand_from agent; and __fish_seen_subcommand_from init' -l all -d 'Install all detected' # agent audit options diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index 5423b0a1..5139d7c1 100644 --- a/node/src/commands/mcp/server.ts +++ b/node/src/commands/mcp/server.ts @@ -8,7 +8,7 @@ import { ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { RegexScanner } from "../../scanners/regex-scanner.js"; -import { GitleaksScanner } from "../../scanners/gitleaks.js"; +import { BetterleaksScanner } from "../../scanners/betterleaks.js"; import { CommandInterceptor } from "../../core/command-interceptor.js"; import { AuditLogger } from "../../core/audit-logger.js"; import { ConfigManager } from "../../core/config-manager.js"; @@ -67,8 +67,8 @@ export function createServer(): Server { path: { type: "string", description: "File or directory path to scan" }, engine: { type: "string", - enum: ["auto", "gitleaks", "patterns"], - description: "Scan engine: auto (default), gitleaks, or patterns", + enum: ["auto", "betterleaks", "patterns"], + description: "Scan engine: auto (default), betterleaks, or patterns.", }, }, required: ["path"], @@ -146,17 +146,17 @@ export function createServer(): Server { const scanPath = args?.path as string; const engine = (args?.engine as string) || "auto"; - if (engine === "gitleaks" || engine === "auto") { - const gitleaks = new GitleaksScanner(); - if (await gitleaks.isAvailable()) { + if (engine === "betterleaks" || engine === "auto") { + const bl = new BetterleaksScanner(); + if (await bl.isAvailable()) { try { - const results = await gitleaks.scanDirectory(scanPath); + const results = await bl.scanDirectory(scanPath); return textResult(formatScanResults(results)); } catch { - if (engine === "gitleaks") return errorResult("Gitleaks scan failed"); + if (engine === "betterleaks") return errorResult("Betterleaks scan failed"); } - } else if (engine === "gitleaks") { - return errorResult("Gitleaks not installed"); + } else if (engine === "betterleaks") { + return errorResult("Betterleaks not installed"); } } diff --git a/node/src/scanners/gitleaks.ts b/node/src/scanners/betterleaks.ts similarity index 57% rename from node/src/scanners/gitleaks.ts rename to node/src/scanners/betterleaks.ts index 41056901..ba134db0 100644 --- a/node/src/scanners/gitleaks.ts +++ b/node/src/scanners/betterleaks.ts @@ -9,7 +9,12 @@ import path from "path"; const execFileAsync = promisify(execFile); -interface GitleaksResult { +/** + * Subset of betterleaks JSON report fields we actually consume. + * Git-history-only fields (Commit, Author, Email, Date, Message) are absent + * when scanning with the `dir` subcommand, so they're typed optional. + */ +interface BetterleaksResult { Description: string; StartLine: number; EndLine: number; @@ -18,75 +23,93 @@ interface GitleaksResult { Match: string; Secret: string; File: string; - SymlinkFile: string; - Commit: string; - Entropy: number; - Author: string; - Email: string; - Date: string; - Message: string; + SymlinkFile?: string; + Commit?: string; + Entropy?: number; + Author?: string; + Email?: string; + Date?: string; + Message?: string; Tags: string[]; RuleID: string; - Fingerprint: string; + Fingerprint?: string; } -export interface GitleaksScanResult { +export interface BetterleaksScanResult { file: string; matches: PatternMatch[]; } -export class GitleaksScanner { +export class BetterleaksScanner { private binaryManager: BinaryManager; + private resolvedPath: string | null = null; constructor() { this.binaryManager = new BinaryManager(); } /** - * Check if Gitleaks is available + * Resolve the betterleaks binary to use. Prefer the rafter-managed binary at + * ~/.rafter/bin/betterleaks; otherwise fall back to one on PATH (e.g. Homebrew). + * Cached after first lookup. */ - async isAvailable(): Promise { - if (!this.binaryManager.isGitleaksInstalled()) { - return false; + private async resolveBinary(): Promise { + if (this.resolvedPath !== null) return this.resolvedPath || null; + if (this.binaryManager.isBetterleaksInstalled()) { + const managed = this.binaryManager.getBetterleaksPath(); + if (await this.binaryManager.verifyBetterleaks()) { + this.resolvedPath = managed; + return managed; + } + } + const onPath = this.binaryManager.findBetterleaksOnPath(); + if (onPath) { + const ok = await this.binaryManager.verifyBetterleaksVerbose(onPath); + if (ok.ok) { + this.resolvedPath = onPath; + return onPath; + } } - return await this.binaryManager.verifyGitleaks(); + this.resolvedPath = ""; + return null; + } + + async isAvailable(): Promise { + return (await this.resolveBinary()) !== null; } /** - * Scan a file with Gitleaks + * Scan a single file with Betterleaks (uses `dir` subcommand on a single path). */ - async scanFile(filePath: string): Promise { - if (!await this.isAvailable()) { - throw new Error("Gitleaks not available"); + async scanFile(filePath: string): Promise { + const blPath = await this.resolveBinary(); + if (!blPath) { + throw new Error("Betterleaks not available"); } - const gitleaksPath = this.binaryManager.getGitleaksPath(); - const tmpReport = path.join(os.tmpdir(), `gitleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`); + const tmpReport = path.join(os.tmpdir(), `betterleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`); try { - // Run gitleaks detect on file + // `--` ensures a target path beginning with `-` isn't parsed as a flag by betterleaks. await execFileAsync( - gitleaksPath, ["detect", "--no-git", "-f", "json", "-r", tmpReport, "-s", filePath], - { timeout: 30000 } + blPath, ["dir", "-f", "json", "--report-path", tmpReport, "--", filePath], + { timeout: 60000 } ); - // If no leaks found, gitleaks exits 0 with empty report if (!fs.existsSync(tmpReport)) { return { file: filePath, matches: [] }; } const results = this.parseResults(tmpReport); - // Clean up report fs.unlinkSync(tmpReport); - // Convert to our format return { file: filePath, matches: results.map(r => this.convertToPatternMatch(r)) }; } catch (e: any) { - // Gitleaks exits with code 1 when leaks found — read report before cleanup + // Betterleaks exits with --exit-code (default 1) when leaks found — read report before cleanup if (e.code === 1 && fs.existsSync(tmpReport)) { const results = this.parseResults(tmpReport); fs.unlinkSync(tmpReport); @@ -97,20 +120,16 @@ export class GitleaksScanner { }; } - // Clean up report for non-leak errors if (fs.existsSync(tmpReport)) { fs.unlinkSync(tmpReport); } - throw new Error(`Gitleaks scan failed: ${e.message}`); + throw new Error(`Betterleaks scan failed: ${e.message}`); } } - /** - * Scan multiple files - */ - async scanFiles(filePaths: string[]): Promise { - const results: GitleaksScanResult[] = []; + async scanFiles(filePaths: string[]): Promise { + const results: BetterleaksScanResult[] = []; for (const filePath of filePaths) { try { @@ -127,28 +146,25 @@ export class GitleaksScanner { } /** - * Scan a directory + * Scan a directory. With useGit=true, scans git history (`betterleaks git`); + * otherwise scans the filesystem (`betterleaks dir`). */ - async scanDirectory(dirPath: string, opts?: { useGit?: boolean }): Promise { - if (!await this.isAvailable()) { - throw new Error("Gitleaks not available"); + async scanDirectory(dirPath: string, opts?: { useGit?: boolean }): Promise { + const blPath = await this.resolveBinary(); + if (!blPath) { + throw new Error("Betterleaks not available"); } - const gitleaksPath = this.binaryManager.getGitleaksPath(); - const tmpReport = path.join(os.tmpdir(), `gitleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`); + const tmpReport = path.join(os.tmpdir(), `betterleaks-${Date.now()}-${randomBytes(6).toString("hex")}.json`); + const subcommand = opts?.useGit ? "git" : "dir"; try { - const args = ["detect", "-f", "json", "-r", tmpReport, "-s", dirPath]; - if (!opts?.useGit) { - args.splice(1, 0, "--no-git"); - } - // Run gitleaks detect on directory + // `--` ensures a target path beginning with `-` isn't parsed as a flag by betterleaks. await execFileAsync( - gitleaksPath, args, + blPath, [subcommand, "-f", "json", "--report-path", tmpReport, "--", dirPath], { timeout: 60000 } ); - // No leaks found if (!fs.existsSync(tmpReport)) { return []; } @@ -156,28 +172,22 @@ export class GitleaksScanner { const results = this.parseResults(tmpReport); fs.unlinkSync(tmpReport); - // Group by file return this.groupByFile(results); } catch (e: any) { - // Clean up report if (fs.existsSync(tmpReport)) { const results = this.parseResults(tmpReport); fs.unlinkSync(tmpReport); - // Gitleaks exits 1 when leaks found if (e.code === 1) { return this.groupByFile(results); } } - throw new Error(`Gitleaks scan failed: ${e.message}`); + throw new Error(`Betterleaks scan failed: ${e.message}`); } } - /** - * Parse Gitleaks JSON report - */ - private parseResults(reportPath: string): GitleaksResult[] { + private parseResults(reportPath: string): BetterleaksResult[] { try { const content = fs.readFileSync(reportPath, "utf-8"); if (!content.trim()) { @@ -185,27 +195,23 @@ export class GitleaksScanner { } const parsed = JSON.parse(content); if (!Array.isArray(parsed)) { - console.error("[rafter] Warning: Gitleaks output is not an array — possible version mismatch"); + console.error("[rafter] Warning: Betterleaks output is not an array — possible version mismatch"); return []; } return parsed; } catch (e) { - console.error(`[rafter] Warning: Failed to parse Gitleaks report: ${e instanceof Error ? e.message : e}`); + console.error(`[rafter] Warning: Failed to parse Betterleaks report: ${e instanceof Error ? e.message : e}`); return []; } } - /** - * Convert Gitleaks result to our PatternMatch format - */ - private convertToPatternMatch(result: GitleaksResult): PatternMatch { - // Map Gitleaks severity to our levels + private convertToPatternMatch(result: BetterleaksResult): PatternMatch { const severity = this.getSeverity(result.RuleID, result.Tags); return { pattern: { name: result.RuleID || result.Description, - regex: "", // Gitleaks doesn't expose the regex + regex: "", severity, description: result.Description }, @@ -216,13 +222,9 @@ export class GitleaksScanner { }; } - /** - * Determine severity from Gitleaks rule ID and tags - */ private getSeverity(ruleID: string, tags: string[]): "low" | "medium" | "high" | "critical" { const lowerID = ruleID.toLowerCase(); - // Critical: Private keys, passwords, database credentials, access tokens if (lowerID.includes("private-key") || lowerID.includes("password") || lowerID.includes("database") || @@ -233,7 +235,6 @@ export class GitleaksScanner { return "critical"; } - // High: API keys, generic tokens if (lowerID.includes("api-key") || lowerID.includes("-token") || lowerID.startsWith("token-") || @@ -241,19 +242,14 @@ export class GitleaksScanner { return "high"; } - // Medium: Generic secrets if (lowerID.includes("generic") || tags.includes("generic")) { return "medium"; } - // Default to high for safety return "high"; } - /** - * Redact a secret - */ private redact(match: string): string { if (match.length <= 8) { return "*".repeat(match.length); @@ -265,10 +261,7 @@ export class GitleaksScanner { return start + middle + end; } - /** - * Group results by file - */ - private groupByFile(results: GitleaksResult[]): GitleaksScanResult[] { + private groupByFile(results: BetterleaksResult[]): BetterleaksScanResult[] { const grouped = new Map(); for (const result of results) { diff --git a/node/src/scanners/secret-patterns.ts b/node/src/scanners/secret-patterns.ts index 9f550052..489daa79 100644 --- a/node/src/scanners/secret-patterns.ts +++ b/node/src/scanners/secret-patterns.ts @@ -2,7 +2,7 @@ import { Pattern } from "../core/pattern-engine.js"; /** * Default secret detection patterns - * Based on common secret formats and Gitleaks rules + * Based on common secret formats and Betterleaks rules */ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ // AWS diff --git a/node/src/utils/binary-manager.ts b/node/src/utils/binary-manager.ts index b01f3e04..e9eda0fc 100644 --- a/node/src/utils/binary-manager.ts +++ b/node/src/utils/binary-manager.ts @@ -10,7 +10,28 @@ import * as tar from "tar"; const execAsync = promisify(exec); -export const GITLEAKS_VERSION = "8.18.2"; +export const BETTERLEAKS_VERSION = "1.1.2"; + +/** + * Pinned SHA256 hashes for the bundled BETTERLEAKS_VERSION release artifacts. + * Pulled from the upstream `checksums.txt` at the time of vendoring; checked into + * source so we don't trust the release-page `checksums.txt` to authenticate + * itself when installing the version we ship by default. + * + * Whenever you bump BETTERLEAKS_VERSION, refresh these by downloading the new + * release's `checksums.txt`. + */ +const BETTERLEAKS_PINNED_HASHES: Record = { + "betterleaks_1.1.2_darwin_arm64.tar.gz": "19cc2298463d7abf0aee9a03208a49834ab2e6f8411781c4cf1360827b3ded36", + "betterleaks_1.1.2_darwin_x64.tar.gz": "d51904879ed77fabad157ec67cb8dd3f5548e975fc32082e6abc30a026e1bec1", + "betterleaks_1.1.2_linux_arm64.tar.gz": "4d73dcbfe38c38878ee69e82b5aaa539398be8331f62b5640eb214ac04d890b0", + "betterleaks_1.1.2_linux_x64.tar.gz": "648c20617178065072ff1791d383192a62c911d9b4427f0426a8c504a6d9ddad", + "betterleaks_1.1.2_windows_arm64.zip": "8cc28068e8c7846027bc9b14f1c200cce64ff4198f90be5730510631c59f23ce", + "betterleaks_1.1.2_windows_x64.zip": "e149c86d00fb99cce8d87def2cd1ff046c6889a0e912007d44668df5980cea3a", +}; + +/** Allowed shape for the optional `--version` flag (prevents URL injection). */ +const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; export class BinaryManager { private binDir: string; @@ -26,13 +47,13 @@ export class BinaryManager { const platform = process.platform; const arch = process.arch; - // Supported platforms const supported = [ "darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64", - "win32-x64" + "win32-x64", + "win32-arm64", ]; return supported.includes(`${platform}-${arch}`); @@ -50,27 +71,43 @@ export class BinaryManager { } /** - * Get Gitleaks binary path + * Get Betterleaks binary path */ - getGitleaksPath(): string { + getBetterleaksPath(): string { const platform = process.platform; const ext = platform === "win32" ? ".exe" : ""; - return path.join(this.binDir, `gitleaks${ext}`); + return path.join(this.binDir, `betterleaks${ext}`); } /** - * Check if Gitleaks is installed + * Check if Betterleaks is installed */ - isGitleaksInstalled(): boolean { - const gitleaksPath = this.getGitleaksPath(); - return fs.existsSync(gitleaksPath); + isBetterleaksInstalled(): boolean { + return fs.existsSync(this.getBetterleaksPath()); } /** - * Find gitleaks on system PATH (like Python's shutil.which) + * Find a leftover legacy gitleaks binary so verify/status can surface + * an upgrade hint instead of a confusing "not found". PATH first + * (Homebrew etc.), then ~/.rafter/bin/gitleaks. Read-only — never executes. */ - findGitleaksOnPath(): string | null { + findLegacyGitleaks(): string | null { const cmd = process.platform === "win32" ? "where gitleaks" : "which gitleaks"; + try { + const result = execSync(cmd, { timeout: 5000, encoding: "utf-8" }); + const found = result.trim().split("\n")[0].trim(); + if (found) return found; + } catch { /* not on PATH */ } + const ext = process.platform === "win32" ? ".exe" : ""; + const local = path.join(this.binDir, `gitleaks${ext}`); + return fs.existsSync(local) ? local : null; + } + + /** + * Find betterleaks on system PATH + */ + findBetterleaksOnPath(): string | null { + const cmd = process.platform === "win32" ? "where betterleaks" : "which betterleaks"; try { const result = execSync(cmd, { timeout: 5000, encoding: "utf-8" }); const found = result.trim().split("\n")[0].trim(); @@ -81,17 +118,15 @@ export class BinaryManager { } /** - * Verify Gitleaks binary works + * Verify Betterleaks binary works */ - async verifyGitleaks(): Promise { - if (!this.isGitleaksInstalled()) { + async verifyBetterleaks(): Promise { + if (!this.isBetterleaksInstalled()) { return false; } try { - // execAsync rejects on non-zero exit, so reaching here means exit code 0. - // Accept any successful exit — don't require specific stdout content. - await execAsync(`"${this.getGitleaksPath()}" version`, { timeout: 5000 }); + await execAsync(`"${this.getBetterleaksPath()}" version`, { timeout: 5000 }); return true; } catch { return false; @@ -99,14 +134,12 @@ export class BinaryManager { } /** - * Run gitleaks version and return {ok, stdout, stderr} + * Run betterleaks version and return {ok, stdout, stderr} */ - async verifyGitleaksVerbose(binaryPath?: string): Promise<{ ok: boolean; stdout: string; stderr: string }> { - const gitleaksPath = binaryPath ?? this.getGitleaksPath(); + async verifyBetterleaksVerbose(binaryPath?: string): Promise<{ ok: boolean; stdout: string; stderr: string }> { + const blPath = binaryPath ?? this.getBetterleaksPath(); try { - const { stdout, stderr } = await execAsync(`"${gitleaksPath}" version`, { timeout: 5000 }); - // execAsync rejects on non-zero exit, so reaching here means exit code 0. - // Accept any successful exit — don't require specific stdout content. + const { stdout, stderr } = await execAsync(`"${blPath}" version`, { timeout: 5000 }); return { ok: true, stdout: stdout.trim(), stderr: stderr.trim() }; } catch (e: unknown) { const err = e as { stdout?: string; stderr?: string }; @@ -122,11 +155,11 @@ export class BinaryManager { * Collect diagnostic context for a failed binary (file type, uname, glibc/musl) */ async collectBinaryDiagnostics(binaryPath?: string): Promise { - const gitleaksPath = binaryPath ?? this.getGitleaksPath(); + const blPath = binaryPath ?? this.getBetterleaksPath(); const lines: string[] = []; try { - const { stdout: fileOut } = await execAsync(`file "${gitleaksPath}"`, { timeout: 5000 }); + const { stdout: fileOut } = await execAsync(`file "${blPath}"`, { timeout: 5000 }); lines.push(` file: ${fileOut.trim()}`); } catch { lines.push(` file: (unavailable)`); @@ -141,12 +174,11 @@ export class BinaryManager { lines.push(` node arch: ${process.arch}, platform: ${process.platform}`); - // Detect glibc vs musl on Linux if (process.platform === "linux") { try { const { stdout: ldd } = await execAsync("ldd --version 2>&1 || true", { timeout: 5000 }); if (ldd.includes("musl")) { - lines.push(" libc: musl (gitleaks linux builds target glibc; musl systems need a musl build or static binary)"); + lines.push(" libc: musl (betterleaks linux builds target glibc; musl systems need a musl build or static binary)"); } else if (ldd.includes("GLIBC") || ldd.includes("GNU")) { const match = ldd.match(/(\d+\.\d+)/); lines.push(` libc: glibc ${match ? match[1] : "(version unknown)"}`); @@ -162,19 +194,19 @@ export class BinaryManager { } /** - * Download and install Gitleaks. - * @param onProgress Optional progress callback. - * @param version Gitleaks version to install (defaults to GITLEAKS_VERSION). + * Download and install Betterleaks. */ - async downloadGitleaks(onProgress?: (message: string) => void, version: string = GITLEAKS_VERSION): Promise { + async downloadBetterleaks(onProgress?: (message: string) => void, version: string = BETTERLEAKS_VERSION): Promise { const log = onProgress || (() => {}); - // Check platform support + if (!VERSION_PATTERN.test(version)) { + throw new Error(`Invalid betterleaks version: ${version} (expected /^[A-Za-z0-9._-]+$/)`); + } + if (!this.isPlatformSupported()) { - throw new Error(`Gitleaks not available for ${process.platform}/${process.arch}`); + throw new Error(`Betterleaks not available for ${process.platform}/${process.arch}`); } - // Ensure bin directory exists if (!fs.existsSync(this.binDir)) { fs.mkdirSync(this.binDir, { recursive: true }); } @@ -183,25 +215,21 @@ export class BinaryManager { const arch = this.getArchString(); const url = this.getDownloadUrl(platform, arch, version); - log(`Downloading Gitleaks v${version} for ${platform}/${arch}...`); + log(`Downloading Betterleaks v${version} for ${platform}/${arch}...`); log(` URL: ${url}`); - const archivePath = path.join(this.binDir, platform === "windows" ? "gitleaks.zip" : "gitleaks.tar.gz"); + const archivePath = path.join(this.binDir, platform === "windows" ? "betterleaks.zip" : "betterleaks.tar.gz"); try { - // Download archive await this.downloadFile(url, archivePath, log); - // Log downloaded file size as basic integrity signal const stats = fs.statSync(archivePath); log(` Downloaded: ${(stats.size / 1024).toFixed(1)} KB`); - // Verify SHA256 checksum against official checksums file log("Verifying checksum..."); await this.verifyChecksum(archivePath, platform, arch, version, log); log(" ✓ Checksum verified"); - // Extract binary log("Extracting binary..."); if (platform === "windows") { await this.extractZip(archivePath); @@ -209,59 +237,55 @@ export class BinaryManager { await this.extractTarball(archivePath); } - // Make executable (Unix systems) if (process.platform !== "win32") { - await execAsync(`chmod +x "${this.getGitleaksPath()}"`); + await execAsync(`chmod +x "${this.getBetterleaksPath()}"`); log(" chmod +x applied"); } - // Verify it works — capture output for diagnostics - const { ok, stdout: verOut, stderr: verErr } = await this.verifyGitleaksVerbose(); + const { ok, stdout: verOut, stderr: verErr } = await this.verifyBetterleaksVerbose(); if (!ok) { const diag = await this.collectBinaryDiagnostics(); - const binaryPath = this.getGitleaksPath(); + const binaryPath = this.getBetterleaksPath(); throw new Error( - `Gitleaks binary failed to execute.\n` + + `Betterleaks binary failed to execute.\n` + ` Binary: ${binaryPath}\n` + ` URL: ${url}\n` + - (verOut ? ` gitleaks version stdout: ${verOut}\n` : "") + - (verErr ? ` gitleaks version stderr: ${verErr}\n` : "") + + (verOut ? ` betterleaks version stdout: ${verOut}\n` : "") + + (verErr ? ` betterleaks version stderr: ${verErr}\n` : "") + `Diagnostics:\n${diag}\n` + - `Fix: ensure the binary matches your OS/arch, or install gitleaks manually and ensure it is on PATH.` + `Fix: ensure the binary matches your OS/arch, or install betterleaks manually and ensure it is on PATH.` ); } log(` Verified: ${verOut}`); - // Clean up archive if (fs.existsSync(archivePath)) { fs.unlinkSync(archivePath); } - log("✓ Gitleaks installed successfully"); + log("✓ Betterleaks installed successfully"); } catch (e) { - // Clean up on failure if (fs.existsSync(archivePath)) { fs.unlinkSync(archivePath); } - const gitleaksPath = this.getGitleaksPath(); - if (fs.existsSync(gitleaksPath)) { - fs.unlinkSync(gitleaksPath); + const blPath = this.getBetterleaksPath(); + if (fs.existsSync(blPath)) { + fs.unlinkSync(blPath); } throw e; } } /** - * Get Gitleaks version + * Get Betterleaks version */ - async getGitleaksVersion(): Promise { - if (!this.isGitleaksInstalled()) { + async getBetterleaksVersion(): Promise { + if (!this.isBetterleaksInstalled()) { return "not installed"; } try { - const { stdout } = await execAsync(`"${this.getGitleaksPath()}" version`); + const { stdout } = await execAsync(`"${this.getBetterleaksPath()}" version`); return stdout.trim(); } catch { return "unknown"; @@ -292,34 +316,66 @@ export class BinaryManager { /** * Get download URL for platform/arch/version */ - private getDownloadUrl(platform: string, arch: string, version: string = GITLEAKS_VERSION): string { - const baseUrl = `https://github.com/gitleaks/gitleaks/releases/download/v${version}`; + private getDownloadUrl(platform: string, arch: string, version: string = BETTERLEAKS_VERSION): string { + const baseUrl = `https://github.com/betterleaks/betterleaks/releases/download/v${version}`; if (platform === "windows") { - return `${baseUrl}/gitleaks_${version}_windows_${arch}.zip`; + return `${baseUrl}/betterleaks_${version}_windows_${arch}.zip`; } else { - return `${baseUrl}/gitleaks_${version}_${platform}_${arch}.tar.gz`; + return `${baseUrl}/betterleaks_${version}_${platform}_${arch}.tar.gz`; } } /** - * Download file from URL + * Download file from URL. + * + * Defenses: + * - HTTPS-only (initial URL and every redirect; non-https rejected) + * - Redirect cap (max 10 hops; bounds recursion if a CDN misbehaves) + * - Socket timeout (60s; bounds slow-loris hangs that never close) + * - Body-size cap (200 MB; bounds DoS-via-large-body if a mirror serves + * an arbitrarily large stream — betterleaks releases are ~12 MB, so + * 200 MB has a generous margin while still preventing disk fill) */ - private downloadFile(url: string, dest: string, onProgress: (msg: string) => void): Promise { + private downloadFile( + url: string, + dest: string, + onProgress: (msg: string) => void, + redirectCount = 0, + ): Promise { + const MAX_REDIRECTS = 10; + const MAX_BYTES = 200 * 1024 * 1024; + const REQUEST_TIMEOUT_MS = 60_000; + return new Promise((resolve, reject) => { const file = fs.createWriteStream(dest); - https.get(url, (response) => { - // Follow redirects + const req = https.get(url, (response) => { + // Follow redirects (HTTPS only — never follow into http://, mailto:, etc.) if (response.statusCode === 302 || response.statusCode === 301) { + if (redirectCount >= MAX_REDIRECTS) { + reject(new Error(`Too many redirects (>${MAX_REDIRECTS}) following ${url}`)); + return; + } const redirectUrl = response.headers.location; if (!redirectUrl) { reject(new Error("Redirect without location")); return; } + let resolved: URL; + try { + resolved = new URL(redirectUrl, url); + } catch { + reject(new Error(`Invalid redirect URL: ${redirectUrl}`)); + return; + } + if (resolved.protocol !== "https:") { + reject(new Error(`Refusing redirect to non-https URL: ${resolved.toString()}`)); + return; + } file.close(); fs.unlinkSync(dest); - this.downloadFile(redirectUrl, dest, onProgress).then(resolve).catch(reject); + this.downloadFile(resolved.toString(), dest, onProgress, redirectCount + 1).then(resolve).catch(reject); return; } @@ -329,11 +385,28 @@ export class BinaryManager { } const totalBytes = parseInt(response.headers["content-length"] || "0", 10); + if (totalBytes > MAX_BYTES) { + reject(new Error( + `Download refused: Content-Length ${totalBytes} exceeds cap ${MAX_BYTES} (betterleaks releases are ~12 MB).` + )); + response.destroy(); + return; + } + let downloadedBytes = 0; let lastPercent = 0; response.on("data", (chunk) => { downloadedBytes += chunk.length; + if (downloadedBytes > MAX_BYTES) { + response.destroy(); + file.close(); + try { fs.unlinkSync(dest); } catch { /* already gone */ } + reject(new Error( + `Download aborted: stream exceeded ${MAX_BYTES} bytes (betterleaks releases are ~12 MB).` + )); + return; + } if (totalBytes > 0) { const percent = Math.round((downloadedBytes / totalBytes) * 100); if (percent > lastPercent && percent % 10 === 0) { @@ -354,9 +427,15 @@ export class BinaryManager { fs.unlinkSync(dest); reject(err); }); - }).on("error", (err) => { + }); + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error(`Download timeout after ${REQUEST_TIMEOUT_MS}ms: ${url}`)); + }); + + req.on("error", (err) => { if (fs.existsSync(dest)) { - fs.unlinkSync(dest); + try { fs.unlinkSync(dest); } catch { /* race */ } } reject(err); }); @@ -364,44 +443,59 @@ export class BinaryManager { } /** - * Verify downloaded archive checksum against official gitleaks checksums file. + * Verify downloaded archive checksum. + * + * For BETTERLEAKS_VERSION (the version we vendor), use the SHA256 pinned in + * source — this prevents a release-page compromise from re-signing both the + * tarball and `checksums.txt`. For an explicit `--version `, fall back + * to the upstream `checksums.txt` (TOFU at that moment). */ private async verifyChecksum( archivePath: string, platform: string, arch: string, version: string, - onProgress: (msg: string) => void + _onProgress: (msg: string) => void ): Promise { - const checksumsUrl = `https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_checksums.txt`; - const checksumsPath = path.join(this.binDir, "checksums.txt"); - - try { - await this.downloadFile(checksumsUrl, checksumsPath, () => {}); - const checksumsContent = fs.readFileSync(checksumsPath, "utf-8"); + const archiveFilename = platform === "windows" + ? `betterleaks_${version}_windows_${arch}.zip` + : `betterleaks_${version}_${platform}_${arch}.tar.gz`; - const archiveFilename = platform === "windows" - ? `gitleaks_${version}_windows_${arch}.zip` - : `gitleaks_${version}_${platform}_${arch}.tar.gz`; + let expectedHash: string | null = null; + let source = ""; - const expectedHash = this.parseChecksumFile(checksumsContent, archiveFilename); - if (!expectedHash) { - throw new Error(`Checksum not found for ${archiveFilename} in checksums file`); + if (version === BETTERLEAKS_VERSION && BETTERLEAKS_PINNED_HASHES[archiveFilename]) { + expectedHash = BETTERLEAKS_PINNED_HASHES[archiveFilename]; + source = "pinned in source"; + } else { + // Fetch the release's `checksums.txt`. This is TOFU — use the pinned table + // for the bundled default to avoid trusting the release page on every install. + const checksumsUrl = `https://github.com/betterleaks/betterleaks/releases/download/v${version}/checksums.txt`; + const checksumsPath = path.join(this.binDir, "checksums.txt"); + try { + await this.downloadFile(checksumsUrl, checksumsPath, () => {}); + const checksumsContent = fs.readFileSync(checksumsPath, "utf-8"); + expectedHash = this.parseChecksumFile(checksumsContent, archiveFilename); + } finally { + if (fs.existsSync(checksumsPath)) { + fs.unlinkSync(checksumsPath); + } } + source = "release checksums.txt"; + } - const actualHash = await this.computeSHA256(archivePath); - if (actualHash !== expectedHash) { - throw new Error( - `Checksum mismatch for ${archiveFilename}:\n` + - ` Expected: ${expectedHash}\n` + - ` Actual: ${actualHash}\n` + - `The downloaded file may be corrupted or tampered with.` - ); - } - } finally { - if (fs.existsSync(checksumsPath)) { - fs.unlinkSync(checksumsPath); - } + if (!expectedHash) { + throw new Error(`Checksum not found for ${archiveFilename} (${source})`); + } + + const actualHash = await this.computeSHA256(archivePath); + if (actualHash !== expectedHash) { + throw new Error( + `Checksum mismatch for ${archiveFilename} (${source}):\n` + + ` Expected: ${expectedHash}\n` + + ` Actual: ${actualHash}\n` + + `The downloaded file may be corrupted or tampered with.` + ); } } @@ -412,7 +506,6 @@ export class BinaryManager { for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; - // Format: " " (two spaces between hash and filename) const parts = trimmed.split(/\s+/); if (parts.length >= 2 && parts[1] === filename) { return parts[0].toLowerCase(); @@ -436,13 +529,11 @@ export class BinaryManager { /** * Extract zip (Windows) — uses PowerShell's Expand-Archive, then copies - * only the gitleaks.exe binary to binDir. Cleans up the temp extract dir. + * only the betterleaks.exe binary to binDir. Cleans up the temp extract dir. */ private async extractZip(zipPath: string): Promise { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-gitleaks-")); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-betterleaks-")); try { - // PowerShell 5+ ships on all supported Windows versions - // Escape single quotes to prevent shell injection ('' is the PS escape for ') const safeZipPath = zipPath.replace(/'/g, "''"); const safeTempDir = tempDir.replace(/'/g, "''"); await execAsync( @@ -450,11 +541,10 @@ export class BinaryManager { { timeout: 30000 } ); - // Find gitleaks.exe — may be at root or inside a subdirectory const findBinary = (dir: string): string | null => { for (const entry of fs.readdirSync(dir)) { const full = path.join(dir, entry); - if (entry === "gitleaks.exe") return full; + if (entry === "betterleaks.exe") return full; if (fs.statSync(full).isDirectory()) { const found = findBinary(full); if (found) return found; @@ -464,8 +554,8 @@ export class BinaryManager { }; const found = findBinary(tempDir); - if (!found) throw new Error("gitleaks.exe not found in archive"); - fs.copyFileSync(found, path.join(this.binDir, "gitleaks.exe")); + if (!found) throw new Error("betterleaks.exe not found in archive"); + fs.copyFileSync(found, path.join(this.binDir, "betterleaks.exe")); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -474,20 +564,31 @@ export class BinaryManager { /** * Extract tarball — binary only, strip packaging extras (LICENSE, README.md). * - * The gitleaks release tarball has all files at the archive root (no top-level - * directory), so strip: 0 (the default). With strip: 1, node-tar reduces the - * single-component paths to empty strings; the filter never matches "gitleaks" - * and nothing is extracted. The filter alone is sufficient. + * Rejects symlinks, hardlinks, and absolute / `..` paths defensively. node-tar + * blocks `..`/absolute by default, but symlinks/hardlinks would otherwise pass + * the basename filter and let a malicious release point `betterleaks` at e.g. + * `~/.ssh/authorized_keys`, which the subsequent `chmod +x` would then mode-flip. */ private async extractTarball(tarballPath: string): Promise { await tar.extract({ file: tarballPath, cwd: this.binDir, - filter: (p: string) => { + filter: (p: string, entry: any) => { const base = path.basename(p); - return base === "gitleaks" || base === "gitleaks.exe"; + if (base !== "betterleaks" && base !== "betterleaks.exe") return false; + if (entry?.type && entry.type !== "File") return false; + return true; }, }); + + // Post-extract belt-and-suspenders: ensure what landed is a regular file. + const installedPath = this.getBetterleaksPath(); + if (fs.existsSync(installedPath)) { + const st = fs.lstatSync(installedPath); + if (!st.isFile() || st.isSymbolicLink()) { + fs.unlinkSync(installedPath); + throw new Error("Extracted betterleaks is not a regular file (symlink or special); aborting."); + } + } } } - diff --git a/node/tests/agent-commands.test.ts b/node/tests/agent-commands.test.ts index 76f4a190..df31e8ac 100644 --- a/node/tests/agent-commands.test.ts +++ b/node/tests/agent-commands.test.ts @@ -204,6 +204,12 @@ describe("agent init", () => { expect(cfg.agent.riskLevel).toBe("moderate"); }); + it("rejects --with-gitleaks (no longer a valid option)", () => { + const r = runCli("agent init --with-gitleaks", home); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/unknown option/i); + }); + it("creates bin and patterns directories", () => { runCli("agent init", home); expect(fs.existsSync(path.join(home, ".rafter", "bin"))).toBe(true); @@ -394,6 +400,14 @@ describe("agent scan", () => { const parsed = JSON.parse(r.stdout); expect(parsed.length).toBeGreaterThan(0); }); + + it("rejects --engine gitleaks (no longer a valid engine)", () => { + const f = path.join(tmpDir, "clean.txt"); + fs.writeFileSync(f, "nothing\n"); + const r = runCli(`agent scan ${f} --engine gitleaks --quiet`, home); + expect(r.exitCode).toBe(2); + expect(r.stderr).toMatch(/Invalid engine/i); + }); }); // ─── agent exec ────────────────────────────────────────────────────────────── @@ -570,9 +584,9 @@ describe("agent status", () => { expect(r.stdout).toContain("minimal"); }); - it("shows Gitleaks status", () => { + it("shows Betterleaks status", () => { const r = runCli("agent status", home); - expect(r.stdout).toContain("Gitleaks:"); + expect(r.stdout).toContain("Betterleaks:"); }); it("shows Claude Code hook status", () => { @@ -608,6 +622,17 @@ describe("agent status", () => { const r = runCli("agent status", home); expect(r.stdout).toContain("Total events:"); }); + + it("surfaces a legacy gitleaks install with an upgrade hint", () => { + runCli("agent init", home); + // Drop a fake legacy gitleaks binary in ~/.rafter/bin/gitleaks. + const binDir = path.join(home, ".rafter", "bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(binDir, "gitleaks"), "#!/bin/sh\necho fake\n", { mode: 0o755 }); + const r = runCli("agent status", home); + expect(r.stdout).toMatch(/legacy gitleaks/i); + expect(r.stdout).toMatch(/update-betterleaks/i); + }); }); // ─── agent verify ──────────────────────────────────────────────────────────── @@ -639,7 +664,7 @@ describe("agent verify", () => { it("config check passes after init", () => { runCli("agent init", home); const r = runCli("agent verify", home); - // Config should pass now (gitleaks may still fail) + // Config should pass now (betterleaks may still fail) expect(r.stdout).toContain("Config:"); }); diff --git a/node/tests/gitleaks-severity.test.ts b/node/tests/betterleaks-severity.test.ts similarity index 94% rename from node/tests/gitleaks-severity.test.ts rename to node/tests/betterleaks-severity.test.ts index 48635795..883290e0 100644 --- a/node/tests/gitleaks-severity.test.ts +++ b/node/tests/betterleaks-severity.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from "vitest"; -import { GitleaksScanner } from "../src/scanners/gitleaks.js"; +import { BetterleaksScanner } from "../src/scanners/betterleaks.js"; // getSeverity is private, so we test it through the convertToPatternMatch flow // by calling the static-accessible prototype method via a workaround. -const scanner = new GitleaksScanner(); +const scanner = new BetterleaksScanner(); const getSeverity = (scanner as any).getSeverity.bind(scanner); -describe("GitleaksScanner.getSeverity", () => { +describe("BetterleaksScanner.getSeverity", () => { // ── Critical tier ─────────────────────────────────────────────── describe("critical", () => { it.each([ diff --git a/node/tests/binary-manager.test.ts b/node/tests/binary-manager.test.ts index c351c7d1..0163e4b4 100644 --- a/node/tests/binary-manager.test.ts +++ b/node/tests/binary-manager.test.ts @@ -3,9 +3,9 @@ import fs from "fs"; import path from "path"; import os from "os"; import * as tar from "tar"; -import { BinaryManager, GITLEAKS_VERSION } from "../src/utils/binary-manager.js"; +import { BinaryManager, BETTERLEAKS_VERSION } from "../src/utils/binary-manager.js"; -// ── Tarball extraction (existing tests) ───────────────────────────── +// ── Tarball extraction ────────────────────────────────────────────── describe("BinaryManager extractTarball", () => { let tmpDir: string; @@ -17,17 +17,17 @@ describe("BinaryManager extractTarball", () => { binDir = path.join(tmpDir, "bin"); fs.mkdirSync(binDir, { recursive: true }); - // Create a tarball mimicking gitleaks release: binary + LICENSE + README.md + // Create a tarball mimicking betterleaks release: binary + LICENSE + README.md const stageDir = path.join(tmpDir, "stage"); fs.mkdirSync(stageDir); - fs.writeFileSync(path.join(stageDir, "gitleaks"), "#!/bin/sh\necho fake", { mode: 0o755 }); + fs.writeFileSync(path.join(stageDir, "betterleaks"), "#!/bin/sh\necho fake", { mode: 0o755 }); fs.writeFileSync(path.join(stageDir, "LICENSE"), "MIT License"); - fs.writeFileSync(path.join(stageDir, "README.md"), "# Gitleaks"); + fs.writeFileSync(path.join(stageDir, "README.md"), "# Betterleaks"); - tarballPath = path.join(tmpDir, "gitleaks.tar.gz"); + tarballPath = path.join(tmpDir, "betterleaks.tar.gz"); tar.create( { gzip: true, file: tarballPath, cwd: stageDir, sync: true }, - ["gitleaks", "LICENSE", "README.md"] + ["betterleaks", "LICENSE", "README.md"] ); }); @@ -35,8 +35,8 @@ describe("BinaryManager extractTarball", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it("should extract only the gitleaks binary, not LICENSE or README", async () => { - const binaryName = process.platform === "win32" ? "gitleaks.exe" : "gitleaks"; + it("should extract only the betterleaks binary, not LICENSE or README", async () => { + const binaryName = process.platform === "win32" ? "betterleaks.exe" : "betterleaks"; await tar.extract({ file: tarballPath, @@ -46,7 +46,7 @@ describe("BinaryManager extractTarball", () => { }); const files = fs.readdirSync(binDir); - expect(files).toContain("gitleaks"); + expect(files).toContain("betterleaks"); expect(files).not.toContain("LICENSE"); expect(files).not.toContain("README.md"); expect(files).toHaveLength(1); @@ -60,7 +60,7 @@ describe("BinaryManager extractTarball", () => { }); const files = fs.readdirSync(binDir); - expect(files).toContain("gitleaks"); + expect(files).toContain("betterleaks"); expect(files).toContain("LICENSE"); expect(files).toContain("README.md"); expect(files).toHaveLength(3); @@ -101,46 +101,46 @@ describe("BinaryManager platform detection", () => { }); }); -// ── Gitleaks path ─────────────────────────────────────────────────── +// ── Betterleaks path ──────────────────────────────────────────────── -describe("BinaryManager getGitleaksPath", () => { +describe("BinaryManager getBetterleaksPath", () => { let bm: BinaryManager; beforeEach(() => { bm = new BinaryManager(); }); - it("returns a path ending in 'gitleaks' (or 'gitleaks.exe' on Windows)", () => { - const p = bm.getGitleaksPath(); + it("returns a path ending in 'betterleaks' (or 'betterleaks.exe' on Windows)", () => { + const p = bm.getBetterleaksPath(); const basename = path.basename(p); if (process.platform === "win32") { - expect(basename).toBe("gitleaks.exe"); + expect(basename).toBe("betterleaks.exe"); } else { - expect(basename).toBe("gitleaks"); + expect(basename).toBe("betterleaks"); } }); it("path is inside ~/.rafter/bin", () => { - const p = bm.getGitleaksPath(); + const p = bm.getBetterleaksPath(); expect(p).toContain(path.join(".rafter", "bin")); }); }); -// ── isGitleaksInstalled ───────────────────────────────────────────── +// ── isBetterleaksInstalled ────────────────────────────────────────── -describe("BinaryManager isGitleaksInstalled", () => { +describe("BinaryManager isBetterleaksInstalled", () => { it("returns a boolean", () => { const bm = new BinaryManager(); - expect(typeof bm.isGitleaksInstalled()).toBe("boolean"); + expect(typeof bm.isBetterleaksInstalled()).toBe("boolean"); }); }); -// ── findGitleaksOnPath ────────────────────────────────────────────── +// ── findBetterleaksOnPath ─────────────────────────────────────────── -describe("BinaryManager findGitleaksOnPath", () => { +describe("BinaryManager findBetterleaksOnPath", () => { it("returns string or null", () => { const bm = new BinaryManager(); - const result = bm.findGitleaksOnPath(); + const result = bm.findBetterleaksOnPath(); expect(result === null || typeof result === "string").toBe(true); }); }); @@ -148,25 +148,24 @@ describe("BinaryManager findGitleaksOnPath", () => { // ── Version detection ─────────────────────────────────────────────── describe("BinaryManager version detection", () => { - it("GITLEAKS_VERSION is a semver string", () => { - expect(GITLEAKS_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + it("BETTERLEAKS_VERSION is a semver string", () => { + expect(BETTERLEAKS_VERSION).toMatch(/^\d+\.\d+\.\d+$/); }); - it("getGitleaksVersion returns a string", async () => { + it("getBetterleaksVersion returns a string", async () => { const bm = new BinaryManager(); - const version = await bm.getGitleaksVersion(); + const version = await bm.getBetterleaksVersion(); expect(typeof version).toBe("string"); - // Either a version string or "not installed" / "unknown" expect(version.length).toBeGreaterThan(0); }); }); -// ── verifyGitleaksVerbose ─────────────────────────────────────────── +// ── verifyBetterleaksVerbose ──────────────────────────────────────── -describe("BinaryManager verifyGitleaksVerbose", () => { +describe("BinaryManager verifyBetterleaksVerbose", () => { it("returns {ok, stdout, stderr} structure", async () => { const bm = new BinaryManager(); - const result = await bm.verifyGitleaksVerbose(); + const result = await bm.verifyBetterleaksVerbose(); expect(result).toHaveProperty("ok"); expect(result).toHaveProperty("stdout"); expect(result).toHaveProperty("stderr"); @@ -177,7 +176,7 @@ describe("BinaryManager verifyGitleaksVerbose", () => { it("returns ok=false for a non-existent binary", async () => { const bm = new BinaryManager(); - const result = await bm.verifyGitleaksVerbose("/tmp/nonexistent-gitleaks-binary-xyz"); + const result = await bm.verifyBetterleaksVerbose("/tmp/nonexistent-betterleaks-binary-xyz"); expect(result.ok).toBe(false); }); }); @@ -211,7 +210,6 @@ describe("BinaryManager download URL construction", () => { bm = new BinaryManager(); }); - // Access private method for testing URL generation const getDownloadUrl = (bm: BinaryManager, platform: string, arch: string, version?: string) => { return (bm as any).getDownloadUrl(platform, arch, version); }; @@ -219,28 +217,28 @@ describe("BinaryManager download URL construction", () => { it("generates correct linux x64 URL", () => { const url = getDownloadUrl(bm, "linux", "x64"); expect(url).toBe( - `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz` + `https://github.com/betterleaks/betterleaks/releases/download/v${BETTERLEAKS_VERSION}/betterleaks_${BETTERLEAKS_VERSION}_linux_x64.tar.gz` ); }); it("generates correct darwin arm64 URL", () => { const url = getDownloadUrl(bm, "darwin", "arm64"); expect(url).toBe( - `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_darwin_arm64.tar.gz` + `https://github.com/betterleaks/betterleaks/releases/download/v${BETTERLEAKS_VERSION}/betterleaks_${BETTERLEAKS_VERSION}_darwin_arm64.tar.gz` ); }); it("generates correct windows x64 URL (zip)", () => { const url = getDownloadUrl(bm, "windows", "x64"); expect(url).toBe( - `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_windows_x64.zip` + `https://github.com/betterleaks/betterleaks/releases/download/v${BETTERLEAKS_VERSION}/betterleaks_${BETTERLEAKS_VERSION}_windows_x64.zip` ); }); it("uses custom version when provided", () => { - const url = getDownloadUrl(bm, "linux", "x64", "8.20.0"); - expect(url).toContain("v8.20.0"); - expect(url).toContain("gitleaks_8.20.0_linux_x64.tar.gz"); + const url = getDownloadUrl(bm, "linux", "x64", "1.2.0"); + expect(url).toContain("v1.2.0"); + expect(url).toContain("betterleaks_1.2.0_linux_x64.tar.gz"); }); }); @@ -278,53 +276,52 @@ describe("BinaryManager checksum parsing", () => { }; const sampleChecksums = [ - "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 gitleaks_8.18.2_linux_x64.tar.gz", - "1111111111111111111111111111111111111111111111111111111111111111 gitleaks_8.18.2_darwin_arm64.tar.gz", - "2222222222222222222222222222222222222222222222222222222222222222 gitleaks_8.18.2_windows_x64.zip", + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 betterleaks_1.1.2_linux_x64.tar.gz", + "1111111111111111111111111111111111111111111111111111111111111111 betterleaks_1.1.2_darwin_arm64.tar.gz", + "2222222222222222222222222222222222222222222222222222222222222222 betterleaks_1.1.2_windows_x64.zip", ].join("\n"); it("finds the correct hash for a known filename", () => { - const hash = parseChecksumFile(bm, sampleChecksums, "gitleaks_8.18.2_linux_x64.tar.gz"); + const hash = parseChecksumFile(bm, sampleChecksums, "betterleaks_1.1.2_linux_x64.tar.gz"); expect(hash).toBe("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"); }); it("finds the correct hash for windows zip", () => { - const hash = parseChecksumFile(bm, sampleChecksums, "gitleaks_8.18.2_windows_x64.zip"); + const hash = parseChecksumFile(bm, sampleChecksums, "betterleaks_1.1.2_windows_x64.zip"); expect(hash).toBe("2222222222222222222222222222222222222222222222222222222222222222"); }); it("returns null for unknown filename", () => { - const hash = parseChecksumFile(bm, sampleChecksums, "gitleaks_8.18.2_freebsd_x64.tar.gz"); + const hash = parseChecksumFile(bm, sampleChecksums, "betterleaks_1.1.2_freebsd_x64.tar.gz"); expect(hash).toBeNull(); }); it("handles empty content", () => { - const hash = parseChecksumFile(bm, "", "gitleaks_8.18.2_linux_x64.tar.gz"); + const hash = parseChecksumFile(bm, "", "betterleaks_1.1.2_linux_x64.tar.gz"); expect(hash).toBeNull(); }); it("handles content with blank lines", () => { const content = "\n\n" + sampleChecksums + "\n\n"; - const hash = parseChecksumFile(bm, content, "gitleaks_8.18.2_darwin_arm64.tar.gz"); + const hash = parseChecksumFile(bm, content, "betterleaks_1.1.2_darwin_arm64.tar.gz"); expect(hash).toBe("1111111111111111111111111111111111111111111111111111111111111111"); }); it("lowercases hash values", () => { - const content = "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890 gitleaks_8.18.2_linux_x64.tar.gz"; - const hash = parseChecksumFile(bm, content, "gitleaks_8.18.2_linux_x64.tar.gz"); + const content = "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890 betterleaks_1.1.2_linux_x64.tar.gz"; + const hash = parseChecksumFile(bm, content, "betterleaks_1.1.2_linux_x64.tar.gz"); expect(hash).toBe("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"); }); }); -// ── downloadGitleaks error handling ───────────────────────────────── +// ── downloadBetterleaks error handling ────────────────────────────── -describe("BinaryManager downloadGitleaks error handling", () => { +describe("BinaryManager downloadBetterleaks error handling", () => { it("rejects on unsupported platform", async () => { const bm = new BinaryManager(); - // Mock isPlatformSupported to return false vi.spyOn(bm, "isPlatformSupported").mockReturnValue(false); - await expect(bm.downloadGitleaks()).rejects.toThrow(/not available for/); + await expect(bm.downloadBetterleaks()).rejects.toThrow(/not available for/); vi.restoreAllMocks(); }); @@ -335,7 +332,7 @@ describe("BinaryManager downloadGitleaks error handling", () => { const messages: string[] = []; await expect( - bm.downloadGitleaks((msg) => messages.push(msg)) + bm.downloadBetterleaks((msg) => messages.push(msg)) ).rejects.toThrow(); vi.restoreAllMocks(); @@ -346,11 +343,25 @@ describe("BinaryManager downloadGitleaks error handling", () => { vi.spyOn(bm, "isPlatformSupported").mockReturnValue(false); await expect( - bm.downloadGitleaks(undefined, "9.99.99") + bm.downloadBetterleaks(undefined, "9.99.99") ).rejects.toThrow(); vi.restoreAllMocks(); }); + + it("rejects malformed --version (URL injection guard)", async () => { + const bm = new BinaryManager(); + // Should reject before any platform check / network call. + await expect( + bm.downloadBetterleaks(undefined, "1.1.2/../evil") + ).rejects.toThrow(/Invalid betterleaks version/); + await expect( + bm.downloadBetterleaks(undefined, "../etc/passwd") + ).rejects.toThrow(/Invalid betterleaks version/); + await expect( + bm.downloadBetterleaks(undefined, "1.1.2 && rm -rf /") + ).rejects.toThrow(/Invalid betterleaks version/); + }); }); // ── SHA256 computation ────────────────────────────────────────────── diff --git a/node/tests/error-handling-gauntlet.test.ts b/node/tests/error-handling-gauntlet.test.ts index 39c4e352..8d41a01c 100644 --- a/node/tests/error-handling-gauntlet.test.ts +++ b/node/tests/error-handling-gauntlet.test.ts @@ -701,20 +701,20 @@ describe("AuditLogger — Error Paths", () => { }); // --------------------------------------------------------------------------- -// 6. GitleaksScanner — availability and fallback +// 6. BetterleaksScanner — availability and fallback // --------------------------------------------------------------------------- -import { GitleaksScanner } from "../src/scanners/gitleaks.js"; +import { BetterleaksScanner } from "../src/scanners/betterleaks.js"; -describe("GitleaksScanner — Error Paths", () => { +describe("BetterleaksScanner — Error Paths", () => { it("isAvailable returns boolean without throwing", async () => { - const scanner = new GitleaksScanner(); + const scanner = new BetterleaksScanner(); const result = await scanner.isAvailable(); expect(typeof result).toBe("boolean"); }); - it("scanFile throws when gitleaks is not installed", async () => { - const scanner = new GitleaksScanner("/nonexistent/gitleaks-binary"); + it("scanFile throws when betterleaks is not installed", async () => { + const scanner = new BetterleaksScanner(); try { await scanner.scanFile("/tmp/test.txt"); // If it doesn't throw, that's also acceptable (empty results) diff --git a/node/tests/mcp-server-integration.test.ts b/node/tests/mcp-server-integration.test.ts index 6a82dee7..5c96b9b7 100644 --- a/node/tests/mcp-server-integration.test.ts +++ b/node/tests/mcp-server-integration.test.ts @@ -5,8 +5,8 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; // ── Mocks ──────────────────────────────────────────────────────────────────── -vi.mock("../src/scanners/gitleaks.js", () => ({ - GitleaksScanner: vi.fn().mockImplementation(function () { +vi.mock("../src/scanners/betterleaks.js", () => ({ + BetterleaksScanner: vi.fn().mockImplementation(function () { return { isAvailable: vi.fn().mockResolvedValue(false), scanDirectory: vi.fn().mockResolvedValue([]), @@ -96,7 +96,7 @@ vi.mock("../src/core/config-manager.js", () => ({ import { createServer } from "../src/commands/mcp/server.js"; import { RegexScanner } from "../src/scanners/regex-scanner.js"; -import { GitleaksScanner } from "../src/scanners/gitleaks.js"; +import { BetterleaksScanner } from "../src/scanners/betterleaks.js"; import { AuditLogger } from "../src/core/audit-logger.js"; // ── Test harness ───────────────────────────────────────────────────────────── @@ -289,10 +289,10 @@ describe("MCP Server — tool execution end-to-end", () => { expect(parsed[0].matches[0].pattern).toBe("aws-access-key"); }); - it("scan_secrets with gitleaks available uses gitleaks", async () => { - const glInstance = new GitleaksScanner() as any; - glInstance.isAvailable.mockResolvedValue(true); - glInstance.scanDirectory.mockResolvedValue([ + it("scan_secrets with betterleaks available uses betterleaks", async () => { + const blInstance = new BetterleaksScanner() as any; + blInstance.isAvailable.mockResolvedValue(true); + blInstance.scanDirectory.mockResolvedValue([ { file: "/tmp/leak.py", matches: [ @@ -305,13 +305,13 @@ describe("MCP Server — tool execution end-to-end", () => { ], }, ]); - (GitleaksScanner as any).mockImplementation(function () { return glInstance; }); + (BetterleaksScanner as any).mockImplementation(function () { return blInstance; }); const result = await client.callTool({ name: "scan_secrets", arguments: { path: "/tmp", engine: "auto" } }); const parsed = JSON.parse((result.content as any)[0].text); expect(parsed[0].matches[0].pattern).toBe("github-token"); - expect(glInstance.scanDirectory).toHaveBeenCalledWith("/tmp"); + expect(blInstance.scanDirectory).toHaveBeenCalledWith("/tmp"); }); it("evaluate_command allows safe commands", async () => { diff --git a/node/tests/mcp-server.test.ts b/node/tests/mcp-server.test.ts index 31bdc83a..837db670 100644 --- a/node/tests/mcp-server.test.ts +++ b/node/tests/mcp-server.test.ts @@ -3,8 +3,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // ── Mocks ──────────────────────────────────────────────────────────────────── // Must be declared before imports so vi.mock hoists correctly. -vi.mock("../src/scanners/gitleaks.js", () => ({ - GitleaksScanner: vi.fn().mockImplementation(function () { +vi.mock("../src/scanners/betterleaks.js", () => ({ + BetterleaksScanner: vi.fn().mockImplementation(function () { return { isAvailable: vi.fn().mockResolvedValue(false), scanDirectory: vi.fn().mockResolvedValue([]), @@ -70,7 +70,7 @@ vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ StdioServerTransport: vi.fn(), })); -import { GitleaksScanner } from "../src/scanners/gitleaks.js"; +import { BetterleaksScanner } from "../src/scanners/betterleaks.js"; import { RegexScanner } from "../src/scanners/regex-scanner.js"; import { CommandInterceptor } from "../src/core/command-interceptor.js"; import { AuditLogger } from "../src/core/audit-logger.js"; @@ -88,17 +88,17 @@ import { ConfigManager } from "../src/core/config-manager.js"; * Simulate the scan_secrets tool handler from server.ts */ async function handleScanSecrets(scanPath: string, engine: string = "auto") { - if (engine === "gitleaks" || engine === "auto") { - const gitleaks = new GitleaksScanner(); - if (await gitleaks.isAvailable()) { + if (engine === "betterleaks" || engine === "auto") { + const bl = new BetterleaksScanner(); + if (await bl.isAvailable()) { try { - const results = await gitleaks.scanDirectory(scanPath); + const results = await bl.scanDirectory(scanPath); return formatScanResults(results); } catch { - if (engine === "gitleaks") throw new Error("Gitleaks scan failed"); + if (engine === "betterleaks") throw new Error("Betterleaks scan failed"); } - } else if (engine === "gitleaks") { - throw new Error("Gitleaks not installed"); + } else if (engine === "betterleaks") { + throw new Error("Betterleaks not installed"); } } @@ -228,10 +228,10 @@ describe("MCP Server — scan_secrets", () => { expect(results[0].matches).toEqual([]); }); - it("should fall back to regex when gitleaks unavailable in auto mode", async () => { - const glInstance = new GitleaksScanner() as any; - glInstance.isAvailable.mockResolvedValue(false); - (GitleaksScanner as any).mockImplementation(function () { return glInstance; }); + it("should fall back to regex when betterleaks unavailable in auto mode", async () => { + const blInstance = new BetterleaksScanner() as any; + blInstance.isAvailable.mockResolvedValue(false); + (BetterleaksScanner as any).mockImplementation(function () { return blInstance; }); const scannerInstance = new RegexScanner() as any; scannerInstance.scanDirectory.mockImplementation(() => { @@ -249,20 +249,20 @@ describe("MCP Server — scan_secrets", () => { expect(scannerInstance.scanFile).toHaveBeenCalled(); }); - it("should throw when gitleaks explicitly requested but unavailable", async () => { - const glInstance = new GitleaksScanner() as any; - glInstance.isAvailable.mockResolvedValue(false); - (GitleaksScanner as any).mockImplementation(function () { return glInstance; }); + it("should throw when betterleaks explicitly requested but unavailable", async () => { + const blInstance = new BetterleaksScanner() as any; + blInstance.isAvailable.mockResolvedValue(false); + (BetterleaksScanner as any).mockImplementation(function () { return blInstance; }); - await expect(handleScanSecrets("/tmp", "gitleaks")).rejects.toThrow( - "Gitleaks not installed" + await expect(handleScanSecrets("/tmp", "betterleaks")).rejects.toThrow( + "Betterleaks not installed" ); }); - it("should use gitleaks when available in auto mode", async () => { - const glInstance = new GitleaksScanner() as any; - glInstance.isAvailable.mockResolvedValue(true); - glInstance.scanDirectory.mockResolvedValue([ + it("should use betterleaks when available in auto mode", async () => { + const blInstance = new BetterleaksScanner() as any; + blInstance.isAvailable.mockResolvedValue(true); + blInstance.scanDirectory.mockResolvedValue([ { file: "/tmp/secret.env", matches: [ @@ -275,11 +275,11 @@ describe("MCP Server — scan_secrets", () => { ], }, ]); - (GitleaksScanner as any).mockImplementation(function () { return glInstance; }); + (BetterleaksScanner as any).mockImplementation(function () { return blInstance; }); const results = await handleScanSecrets("/tmp", "auto"); - expect(glInstance.scanDirectory).toHaveBeenCalledWith("/tmp"); + expect(blInstance.scanDirectory).toHaveBeenCalledWith("/tmp"); expect(results).toHaveLength(1); expect(results[0].matches[0].pattern).toBe("generic-api-key"); }); diff --git a/node/tests/platform-integration.test.ts b/node/tests/platform-integration.test.ts index 67262617..6b2b3d40 100644 --- a/node/tests/platform-integration.test.ts +++ b/node/tests/platform-integration.test.ts @@ -936,7 +936,7 @@ describe("Platform Integration — MCP Installs via CLI", () => { "# config\n" ); - // --all also triggers gitleaks download, so allow extra time + // --all also triggers betterleaks download, so allow extra time const result = runCli("agent init --all", testHomeDir, 90_000); expect(result.exitCode).toBe(0); diff --git a/python/README.md b/python/README.md index 52928477..38206a31 100644 --- a/python/README.md +++ b/python/README.md @@ -2,7 +2,7 @@ Python CLI for [Rafter](https://rafter.so) — the security toolkit for developers. Full feature parity with the Node.js package. -**Local security toolkit** — Fast, deterministic secret scanning (21+ patterns, Gitleaks), policy enforcement with risk-tiered rules, pre-commit hooks, pretool hooks, extension auditing, custom rule authoring, and full audit logging. Works with Claude Code, Codex CLI, OpenClaw, and 5 more platforms. No API key required. No data leaves your machine. +**Local security toolkit** — Fast, deterministic secret scanning (21+ patterns, Betterleaks), policy enforcement with risk-tiered rules, pre-commit hooks, pretool hooks, extension auditing, custom rule authoring, and full audit logging. Works with Claude Code, Codex CLI, OpenClaw, and 5 more platforms. No API key required. No data leaves your machine. **Remote code analysis** — Deep security audits that combine agentic analysis with a full SAST/SCA toolchain. The engine examines your codebase the way a professional cybersecurity auditor would — tracing data flows, reasoning about business logic, and surfacing vulnerabilities that static rules alone miss — then cross-references findings with industry-standard static analysis and dependency scanning. Structured JSON reports with documented exit codes. Your code is deleted immediately after analysis completes. @@ -42,7 +42,7 @@ rafter agent list # show detected integrations + status rafter agent enable claude-code # toggle a single platform on/off rafter agent scan . # scan for secrets rafter agent scan --diff HEAD~1 # scan changed files -rafter agent scan --history # scan full git history (gitleaks engine) +rafter agent scan --history # scan full git history (betterleaks engine) rafter agent exec "git commit" # execute with risk assessment rafter agent audit # view security logs rafter agent audit --verify # verify tamper-evident hash chain diff --git a/python/rafter_cli/commands/agent.py b/python/rafter_cli/commands/agent.py index 0ec72807..d746ccb8 100644 --- a/python/rafter_cli/commands/agent.py +++ b/python/rafter_cli/commands/agent.py @@ -27,7 +27,7 @@ from ..core.command_interceptor import CommandInterceptor from ..core.config_manager import ConfigManager from ..core.pattern_engine import PatternEngine -from ..scanners.gitleaks import GitleaksScanner +from ..scanners.betterleaks import BetterleaksScanner from ..scanners.regex_scanner import RegexScanner, ScanResult from ..scanners.secret_patterns import DEFAULT_SECRET_PATTERNS from ..utils.formatter import fmt, is_agent_mode, print_stderr @@ -858,7 +858,7 @@ def _install_aider_read(root: Path) -> bool: @agent_app.command() def init( risk_level: str = typer.Option("moderate", "--risk-level", help="minimal, moderate, or aggressive"), - with_gitleaks: bool = typer.Option(False, "--with-gitleaks", help="Download and install Gitleaks binary"), + with_betterleaks: bool = typer.Option(False, "--with-betterleaks", help="Download and install Betterleaks binary"), with_openclaw: bool = typer.Option(False, "--with-openclaw", help="Install OpenClaw integration"), with_claude_code: bool = typer.Option(False, "--with-claude-code", help="Install Claude Code integration"), with_codex: bool = typer.Option(False, "--with-codex", help="Install Codex CLI integration"), @@ -867,8 +867,8 @@ def init( with_cursor: bool = typer.Option(False, "--with-cursor", help="Install Cursor integration"), with_windsurf: bool = typer.Option(False, "--with-windsurf", help="Install Windsurf integration"), with_continue: bool = typer.Option(False, "--with-continue", help="Install Continue.dev integration"), - all_integrations: bool = typer.Option(False, "--all", help="Install all detected integrations and download Gitleaks"), - update: bool = typer.Option(False, "--update", help="Re-download gitleaks and reinstall integrations without resetting config"), + all_integrations: bool = typer.Option(False, "--all", help="Install all detected integrations and download Betterleaks"), + update: bool = typer.Option(False, "--update", help="Re-download betterleaks and reinstall integrations without resetting config"), local: bool = typer.Option( False, "--local", @@ -920,7 +920,7 @@ def init( # Aider can install at --local scope (writes RAFTER.md + .aider.conf.yml # in cwd) since rf-du2o. want_aider = with_aider or all_integrations - want_gitleaks = with_gitleaks or (all_integrations and not local) + want_betterleaks = with_betterleaks or (all_integrations and not local) # Show detected environments detected = [] @@ -980,38 +980,39 @@ def init( manager.set("agent.risk_level", risk_level) rprint(fmt.success(f"Set risk level: {risk_level}")) - # Gitleaks check (opt-in via --with-gitleaks or --all) - if want_gitleaks: - _gitleaks_on_path = None if update else shutil.which("gitleaks") - _rafter_bin = Path.home() / ".rafter" / "bin" / "gitleaks" - if _gitleaks_on_path: - rprint(fmt.success(f"Gitleaks available on PATH ({_gitleaks_on_path})")) + # Betterleaks check (opt-in via --with-betterleaks or --all) + if want_betterleaks: + _bl_on_path = None if update else shutil.which("betterleaks") + _bin_name = "betterleaks.exe" if sys.platform == "win32" else "betterleaks" + _rafter_bin = Path.home() / ".rafter" / "bin" / _bin_name + if _bl_on_path: + rprint(fmt.success(f"Betterleaks available on PATH ({_bl_on_path})")) elif not update and _rafter_bin.exists(): - rprint(fmt.success(f"Gitleaks available at {_rafter_bin}")) + rprint(fmt.success(f"Betterleaks available at {_rafter_bin}")) else: if update: - rprint(fmt.info("Updating gitleaks binary...")) + rprint(fmt.info("Updating betterleaks binary...")) else: - rprint(fmt.info("Gitleaks not found — attempting auto-download...")) + rprint(fmt.info("Betterleaks not found — attempting auto-download...")) _bm = BinaryManager() if _bm.is_platform_supported(): try: - _bm.download_gitleaks(on_progress=typer.echo) - rprint(fmt.success("Gitleaks downloaded and verified.")) + _bm.download_betterleaks(on_progress=typer.echo) + rprint(fmt.success("Betterleaks downloaded and verified.")) except Exception as _dl_err: rprint(fmt.warning(f"Auto-download failed: {_dl_err}")) rprint(fmt.info( - "To fix: install gitleaks manually " - "(https://github.com/gitleaks/gitleaks/releases) " + "To fix: install betterleaks manually " + "(https://github.com/betterleaks/betterleaks/releases) " "and ensure it is on PATH, then re-run 'rafter agent init'." )) else: rprint(fmt.warning( - "Gitleaks not available for this platform — " + "Betterleaks not available for this platform — " "pattern-based scanning will be used instead." )) rprint(fmt.info( - "To fix: install gitleaks (https://github.com/gitleaks/gitleaks/releases) " + "To fix: install betterleaks (https://github.com/betterleaks/betterleaks/releases) " "and ensure it is on PATH, then re-run 'rafter agent init'." )) @@ -1227,8 +1228,8 @@ def _local_unsupported(label: str) -> None: def _select_engine(preference: str, quiet: bool) -> str: - """Return 'gitleaks' or 'patterns'.""" - valid_engines = ("auto", "gitleaks", "patterns") + """Return 'betterleaks' or 'patterns'.""" + valid_engines = ("auto", "betterleaks", "patterns") if preference not in valid_engines: print(f"Invalid engine: {preference}. Valid values: {', '.join(valid_engines)}", file=sys.stderr) raise typer.Exit(code=2) @@ -1236,25 +1237,25 @@ def _select_engine(preference: str, quiet: bool) -> str: if preference == "patterns": return "patterns" - scanner = GitleaksScanner() + scanner = BetterleaksScanner() available = scanner.is_available() - if preference == "gitleaks": + if preference == "betterleaks": if not available: if not quiet: - print_stderr(fmt.warning("Gitleaks requested but not available, using patterns")) + print_stderr(fmt.warning("Betterleaks requested but not available, using patterns")) return "patterns" - return "gitleaks" + return "betterleaks" # auto - return "gitleaks" if available else "patterns" + return "betterleaks" if available else "patterns" def _scan_file(file_path: str, engine: str, custom_patterns=None) -> list[ScanResult]: - if engine == "gitleaks": + if engine == "betterleaks": try: - gl = GitleaksScanner() - result = gl.scan_file(file_path) + bl = BetterleaksScanner() + result = bl.scan_file(file_path) return [ScanResult(file=result.file, matches=result.matches)] if result.matches else [] except Exception: scanner = RegexScanner(custom_patterns) @@ -1273,10 +1274,10 @@ def _scan_directory(dir_path: str, engine: str, scan_cfg=None, *, history: bool custom = [{"name": p.name, "regex": p.regex, "severity": p.severity} for p in scan_cfg.custom_patterns] if scan_cfg.custom_patterns else None exclude = scan_cfg.exclude_paths or None - if engine == "gitleaks": + if engine == "betterleaks": try: - gl = GitleaksScanner() - results = gl.scan_directory(dir_path, use_git=history) + bl = BetterleaksScanner() + results = bl.scan_directory(dir_path, use_git=history) return [ScanResult(file=r.file, matches=r.matches) for r in results] except Exception: scanner = RegexScanner(custom) @@ -1512,10 +1513,10 @@ def scan( format: str = typer.Option("text", "--format", help="Output format: text, json, sarif"), staged: bool = typer.Option(False, "--staged", help="Scan only git staged files"), diff: str = typer.Option(None, "--diff", help="Scan files changed since a git ref"), - engine: str = typer.Option("auto", "--engine", help="gitleaks or patterns"), + engine: str = typer.Option("auto", "--engine", help="betterleaks or patterns"), baseline: bool = typer.Option(False, "--baseline", help="Filter findings present in the saved baseline"), watch: bool = typer.Option(False, "--watch", help="Watch for file changes and re-scan on change"), - history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires gitleaks engine)"), + history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires betterleaks engine)"), ): """Scan files or directories for secrets. [deprecated: use 'rafter secrets' instead]""" print( @@ -2003,30 +2004,39 @@ class _CheckResult: optional: bool = False # optional checks warn but don't fail exit code -def _check_gitleaks() -> _CheckResult: - """Check if gitleaks is available and executable. Checks PATH first, then ~/.rafter/bin.""" - name = "Gitleaks" +def _check_betterleaks() -> _CheckResult: + """Check if betterleaks is available and executable. Checks PATH first, then ~/.rafter/bin.""" + name = "Betterleaks" + bm = BinaryManager() # Check PATH first (e.g. Homebrew), then fall back to rafter-managed binary - gitleaks_path = shutil.which("gitleaks") - if not gitleaks_path: - rafter_bin = Path.home() / ".rafter" / "bin" / "gitleaks" + bl_path = shutil.which("betterleaks") + if not bl_path: + rafter_bin = bm.get_betterleaks_path() if rafter_bin.exists(): - gitleaks_path = str(rafter_bin) - if not gitleaks_path: - return _CheckResult(name, False, f"Not found on PATH or at {Path.home() / '.rafter' / 'bin' / 'gitleaks'}") + bl_path = str(rafter_bin) + if not bl_path: + # Soft-degrade if a legacy gitleaks install is still present. + legacy = bm.find_legacy_gitleaks() + if legacy: + return _CheckResult( + name, + False, + f"Not installed; found legacy gitleaks at {legacy}. Run: rafter agent update-betterleaks", + optional=True, + ) + return _CheckResult(name, False, f"Not found on PATH or at {bm.get_betterleaks_path()}") # Verify the found binary actually works - bm = BinaryManager() - result = bm.verify_gitleaks_verbose(binary_path=Path(gitleaks_path)) + result = bm.verify_betterleaks_verbose(binary_path=Path(bl_path)) if result["ok"]: - return _CheckResult(name, True, result["stdout"] or gitleaks_path) + return _CheckResult(name, True, result["stdout"] or bl_path) - detail = f"Found at {gitleaks_path} but failed to execute" + detail = f"Found at {bl_path} but failed to execute" if result["stdout"]: detail += f"\n stdout: {result['stdout']}" if result["stderr"]: detail += f"\n stderr: {result['stderr']}" - diag = bm.collect_binary_diagnostics(binary_path=Path(gitleaks_path)) + diag = bm.collect_binary_diagnostics(binary_path=Path(bl_path)) if diag: detail += f"\n{diag}" return _CheckResult(name, False, detail) @@ -2365,7 +2375,7 @@ def verify( results = [ _check_config(), - _check_gitleaks(), + _check_betterleaks(), _check_claude_code(), _check_openclaw(), _check_codex(), @@ -2649,60 +2659,61 @@ def audit_skill( raise typer.Exit(code=1) -# ── update-gitleaks ────────────────────────────────────────────────────── +# ── update-betterleaks ─────────────────────────────────────────────────── -@agent_app.command("update-gitleaks") -def update_gitleaks( +@agent_app.command("update-betterleaks") +def update_betterleaks( version: str = typer.Option( None, "--version", - help="Gitleaks version to install (default: bundled version)", + help="Betterleaks version to install (default: bundled version)", ), ): - """Update (or reinstall) the managed gitleaks binary.""" - from ..utils.binary_manager import GITLEAKS_VERSION + """Update (or reinstall) the managed betterleaks binary.""" + from ..utils.binary_manager import BETTERLEAKS_VERSION - target_version = version or GITLEAKS_VERSION - _bm = BinaryManager() + target_version = version or BETTERLEAKS_VERSION + bm = BinaryManager() - if not _bm.is_platform_supported(): + if not bm.is_platform_supported(): rprint(fmt.error( - f"Gitleaks not available for {_bm._sys_platform()}/{_bm._machine()}" + f"Betterleaks not available for {bm._sys_platform()}/{bm._machine()}" )) raise typer.Exit(code=1) - # Show current version if installed - _rafter_bin = _bm.get_gitleaks_path() - if _rafter_bin.exists(): - result = _bm.verify_gitleaks_verbose() + rafter_bin = bm.get_betterleaks_path() + if rafter_bin.exists(): + result = bm.verify_betterleaks_verbose() if result["ok"]: rprint(fmt.info(f"Current: {result['stdout']}")) else: - rprint(fmt.warning(f"Current binary at {_rafter_bin} is not working")) + rprint(fmt.warning(f"Current binary at {rafter_bin} is not working")) else: - rprint(fmt.info("Gitleaks not currently installed (managed binary)")) + rprint(fmt.info("Betterleaks not currently installed (managed binary)")) - rprint(fmt.info(f"Installing gitleaks v{target_version}...")) + rprint(fmt.info(f"Installing betterleaks v{target_version}...")) rprint() try: - _bm.download_gitleaks(on_progress=typer.echo, version=target_version) + bm.download_betterleaks(on_progress=typer.echo, version=target_version) rprint() - result = _bm.verify_gitleaks_verbose() - rprint(fmt.success(f"Gitleaks updated: {result['stdout']}")) - rprint(fmt.info(f" Binary: {_rafter_bin}")) - except Exception as _err: + result = bm.verify_betterleaks_verbose() + rprint(fmt.success(f"Betterleaks updated: {result['stdout']}")) + rprint(fmt.info(f" Binary: {rafter_bin}")) + except Exception as err: rprint() - rprint(fmt.error(f"Update failed: {_err}")) + rprint(fmt.error(f"Update failed: {err}")) rprint(fmt.info( - "To fix: install gitleaks manually " - "(https://github.com/gitleaks/gitleaks/releases) " + "To fix: install betterleaks manually " + "(https://github.com/betterleaks/betterleaks/releases) " "and ensure it is on PATH." )) raise typer.Exit(code=1) + + # ── agent status ───────────────────────────────────────────────────────── @agent_app.command("status") @@ -2728,18 +2739,23 @@ def status(): else: print(f"\nConfig: not found — run: rafter agent init") - # --- Gitleaks --- - gl_path = shutil.which("gitleaks") or str(rafter_dir / "bin" / "gitleaks") - if shutil.which("gitleaks"): + # --- Betterleaks --- + bm = BinaryManager() + bl_local = bm.get_betterleaks_path() + if shutil.which("betterleaks"): try: - ver = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5) - print(f"Gitleaks: {ver.stdout.strip()} (PATH)") + ver = subprocess.run(["betterleaks", "version"], capture_output=True, text=True, timeout=5) + print(f"Betterleaks: {ver.stdout.strip()} (PATH)") except Exception: - print("Gitleaks: on PATH (version check failed)") - elif Path(gl_path).exists(): - print(f"Gitleaks: {gl_path} (local)") + print("Betterleaks: on PATH (version check failed)") + elif bl_local.exists(): + print(f"Betterleaks: {bl_local} (local)") else: - print("Gitleaks: not found — run: rafter agent init --with-gitleaks") + legacy = bm.find_legacy_gitleaks() + if legacy: + print(f"Betterleaks: not found — legacy gitleaks at {legacy}; run: rafter agent update-betterleaks") + else: + print("Betterleaks: not found — run: rafter agent init --with-betterleaks") # --- Claude Code hooks --- claude_dir = Path.home() / ".claude" @@ -2897,7 +2913,7 @@ def _apply_baseline(results: list[ScanResult], entries: list[dict]) -> list[Scan @baseline_app.command("create") def baseline_create( path: str = typer.Argument(".", help="Path to scan"), - engine: str = typer.Option("auto", "--engine", help="gitleaks or patterns"), + engine: str = typer.Option("auto", "--engine", help="betterleaks or patterns"), ): """Scan and save all current findings as the baseline.""" import datetime diff --git a/python/rafter_cli/commands/mcp_server.py b/python/rafter_cli/commands/mcp_server.py index 1b339409..9666b8fa 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -13,7 +13,7 @@ from ..core.command_interceptor import CommandInterceptor from ..core.config_manager import ConfigManager from ..core.docs_loader import fetch_doc, list_docs, resolve_doc_selector -from ..scanners.gitleaks import GitleaksScanner +from ..scanners.betterleaks import BetterleaksScanner from ..scanners.regex_scanner import RegexScanner mcp_app = typer.Typer( @@ -28,12 +28,12 @@ def handle_scan_secrets(path: str, engine: str = "auto") -> list[dict]: """Scan files or directories for hardcoded secrets.""" - # Try gitleaks if requested or auto - if engine in ("gitleaks", "auto"): - gl = GitleaksScanner() - if gl.is_available(): + # Try betterleaks if requested or auto + if engine in ("betterleaks", "auto"): + bl = BetterleaksScanner() + if bl.is_available(): try: - results = gl.scan_directory(path) + results = bl.scan_directory(path) return [ { "file": r.file, @@ -50,13 +50,13 @@ def handle_scan_secrets(path: str, engine: str = "auto") -> list[dict]: for r in results ] except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError) as exc: - if engine == "gitleaks": + if engine == "betterleaks": raise - print(f"rafter: gitleaks scan failed, falling back to patterns: {exc}", file=sys.stderr) + print(f"rafter: betterleaks scan failed, falling back to patterns: {exc}", file=sys.stderr) # Fall through to patterns on auto - elif engine == "gitleaks": - raise RuntimeError("Gitleaks not installed") + elif engine == "betterleaks": + raise RuntimeError("Betterleaks not installed") # Pattern-based scan scanner = RegexScanner() @@ -192,7 +192,7 @@ def scan_secrets(path: str, engine: str = "auto") -> str: Args: path: File or directory path to scan. - engine: Scan engine — auto (default), gitleaks, or patterns. + engine: Scan engine — auto (default), betterleaks, or patterns. """ return json.dumps(handle_scan_secrets(path, engine)) diff --git a/python/rafter_cli/commands/scan.py b/python/rafter_cli/commands/scan.py index 925f9a68..874cc0ff 100644 --- a/python/rafter_cli/commands/scan.py +++ b/python/rafter_cli/commands/scan.py @@ -92,10 +92,10 @@ def scan_local( format: str = typer.Option("text", "--format", help="Output format: text, json, sarif"), staged: bool = typer.Option(False, "--staged", help="Scan only git staged files"), diff: Optional[str] = typer.Option(None, "--diff", help="Scan files changed since a git ref"), - engine: str = typer.Option("auto", "--engine", help="gitleaks or patterns"), + engine: str = typer.Option("auto", "--engine", help="betterleaks or patterns"), baseline: bool = typer.Option(False, "--baseline", help="Filter findings present in the saved baseline"), watch: bool = typer.Option(False, "--watch", help="Watch for file changes and re-scan on change"), - history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires gitleaks engine)"), + history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires betterleaks engine)"), ): """(deprecated alias for 'rafter secrets').""" from .agent import ( @@ -235,8 +235,8 @@ def scan_local( secrets_app = typer.Typer( name="secrets", help=( - "Scan files/directories for hardcoded secrets (regex + gitleaks). " - "Secrets only — not a code analysis. For full SAST/SCA, use 'rafter run'." + "Secrets only — scan files/directories for hardcoded secrets " + "(regex + betterleaks). Not a code analysis. For full SAST/SCA, use 'rafter run'." ), invoke_without_command=True, no_args_is_help=False, @@ -252,10 +252,10 @@ def secrets( format: str = typer.Option("text", "--format", help="Output format: text, json, sarif"), staged: bool = typer.Option(False, "--staged", help="Scan only git staged files"), diff: Optional[str] = typer.Option(None, "--diff", help="Scan files changed since a git ref"), - engine: str = typer.Option("auto", "--engine", help="gitleaks or patterns"), + engine: str = typer.Option("auto", "--engine", help="betterleaks or patterns"), baseline: bool = typer.Option(False, "--baseline", help="Filter findings present in the saved baseline"), watch: bool = typer.Option(False, "--watch", help="Watch for file changes and re-scan on change"), - history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires gitleaks engine)"), + history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires betterleaks engine)"), ): """Scan files/directories for hardcoded secrets.""" return scan_local( diff --git a/python/rafter_cli/resources/agents/rafter.md b/python/rafter_cli/resources/agents/rafter.md index 34c7a1c2..01676b4c 100644 --- a/python/rafter_cli/resources/agents/rafter.md +++ b/python/rafter_cli/resources/agents/rafter.md @@ -22,7 +22,7 @@ Rafter ships three CLI tiers **and** four in-repo skills. They are NOT interchan 1. **`rafter run`** (default mode) — remote SAST + SCA + secrets via the Rafter API. Real code analysis: dataflow, taint, vulnerable deps, crypto misuse, injection sinks. Needs `RAFTER_API_KEY`. **This is the default for "is this safe / secure / production worthy?".** 2. **`rafter run --mode plus`** — agentic deep-dive on suspicious patterns. Slower, higher signal. Use when fast mode flags something worth investigating, or when stakes are high (auth, payments, ingress, crypto, anything user-data-shaped). -3. **`rafter secrets [path]`** — local secrets only (regex + gitleaks for hardcoded API keys, tokens, private keys). Fast, offline, no key. **NOT a code security scan.** Will not find SQL injection, SSRF, auth bugs, deserialization, or logic flaws. Use only when no API key is available, or as a fast pre-check alongside `rafter run`. +3. **`rafter secrets [path]`** — local secrets only (regex + betterleaks for hardcoded API keys, tokens, private keys). Fast, offline, no key. **NOT a code security scan.** Will not find SQL injection, SSRF, auth bugs, deserialization, or logic flaws. Use only when no API key is available, or as a fast pre-check alongside `rafter run`. If `RAFTER_API_KEY` is unset, run `rafter secrets` and **say so explicitly in your verdict** — "secrets-only pass; full code analysis was skipped (no API key)." Do not claim the code was "scanned" without that qualification. Never silently downgrade. diff --git a/python/rafter_cli/resources/rafter-security-skill.md b/python/rafter_cli/resources/rafter-security-skill.md index 066fef62..6b499c0c 100644 --- a/python/rafter_cli/resources/rafter-security-skill.md +++ b/python/rafter_cli/resources/rafter-security-skill.md @@ -40,7 +40,7 @@ To initialize Rafter, use **opt-in** `--with-*` flags to select integrations. Th ```bash # Install specific integrations (opt-in) rafter agent init --with-openclaw -rafter agent init --with-claude-code --with-gitleaks +rafter agent init --with-claude-code --with-betterleaks # Install everything detected rafter agent init --all diff --git a/python/rafter_cli/resources/skills/rafter/SKILL.md b/python/rafter_cli/resources/skills/rafter/SKILL.md index 5385aedb..aed187f3 100644 --- a/python/rafter_cli/resources/skills/rafter/SKILL.md +++ b/python/rafter_cli/resources/skills/rafter/SKILL.md @@ -11,7 +11,7 @@ allowed-tools: [Bash, Read] Rafter ships three tiers. **They are not interchangeable.** The local tier is narrow; skipping remote analysis is the #1 way agents under-use rafter. -1. **Local (`rafter secrets`)** — secrets only. Regex + gitleaks for hardcoded API keys, tokens, private keys. Fast, offline, no key. **This is NOT a code security scan.** It will not find SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. If an agent's entire rafter interaction was `rafter secrets .` and it exited clean, the agent has done secret-hygiene only — not security review. +1. **Local (`rafter secrets`)** — secrets only. Regex + betterleaks for hardcoded API keys, tokens, private keys. Fast, offline, no key. **This is NOT a code security scan.** It will not find SQL injection, SSRF, auth bugs, insecure deserialization, logic flaws, or dependency vulns. If an agent's entire rafter interaction was `rafter secrets .` and it exited clean, the agent has done secret-hygiene only — not security review. 2. **Remote fast (`rafter run`, default mode)** — SAST + SCA + secrets via the Rafter API. This is the real code-analysis pass: dataflow, taint, known-vulnerable dependencies, crypto misuse, injection sinks. Needs `RAFTER_API_KEY`. 3. **Remote plus (`rafter run --mode plus`)** — agentic deep-dive: LLM-guided investigation of suspicious patterns the rules engine flags. Slower, higher signal. Code is deleted server-side after the run. diff --git a/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md b/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md index 6330e513..23d33a68 100644 --- a/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md +++ b/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md @@ -32,11 +32,11 @@ Example: `rafter run --repo myorg/api --branch feature/auth --mode plus --format ### `rafter secrets [path]` -Local secret scan. Deterministic, offline, no API key. Dual-engine: Gitleaks binary if present, built-in regex fallback (21+ patterns). +Local secret scan. Deterministic, offline, no API key. Dual-engine: Betterleaks binary if present, built-in regex fallback (21+ patterns). When: pre-commit, pre-push, fast first pass before remote scan, air-gapped envs. -Useful flags: `--history` (scan git history with Gitleaks), `--format json`, `--quiet`. +Useful flags: `--history` (scan git history with Betterleaks), `--format json`, `--quiet`. Example: `rafter secrets . --format json` @@ -80,7 +80,7 @@ Audit a single skill file (SKILL.md). Flags prompt-injection, unbounded tool use ### `rafter agent status` · `rafter agent verify` -`status`: dump config, hook state, gitleaks availability, audit log location. +`status`: dump config, hook state, betterleaks availability, audit log location. `verify`: sanity-check installation; exit non-zero if anything is broken. ### `rafter agent init [--with-]` @@ -107,9 +107,9 @@ Snapshot current findings so only *new* ones fail future scans. Emit a ready-to-paste instruction block for an agent's system prompt. -### `rafter agent update-gitleaks` +### `rafter agent update-betterleaks` -Download / upgrade the Gitleaks binary Rafter uses for local scans. +Download / upgrade the Betterleaks binary Rafter uses for local scans. --- diff --git a/python/rafter_cli/scanners/gitleaks.py b/python/rafter_cli/scanners/betterleaks.py similarity index 53% rename from python/rafter_cli/scanners/gitleaks.py rename to python/rafter_cli/scanners/betterleaks.py index a27d3967..2493ba68 100644 --- a/python/rafter_cli/scanners/gitleaks.py +++ b/python/rafter_cli/scanners/betterleaks.py @@ -1,11 +1,11 @@ -"""Gitleaks scanner — wraps system gitleaks binary.""" +"""Betterleaks scanner — wraps system betterleaks binary.""" from __future__ import annotations import json import os import platform -import shutil import subprocess +import sys import tempfile from dataclasses import dataclass, field from typing import NamedTuple @@ -15,36 +15,36 @@ @dataclass -class GitleaksScanResult: +class BetterleaksScanResult: file: str matches: list[PatternMatch] = field(default_factory=list) -class GitleaksCheckResult(NamedTuple): +class BetterleaksCheckResult(NamedTuple): available: bool stdout: str stderr: str error: str # OSError / timeout message, empty on success -class GitleaksScanner: +class BetterleaksScanner: def __init__(self) -> None: self._binary_manager = BinaryManager() # Prefer managed binary, fall back to system PATH - if self._binary_manager.is_gitleaks_installed(): - self._path: str | None = str(self._binary_manager.get_gitleaks_path()) + if self._binary_manager.is_betterleaks_installed(): + self._path: str | None = str(self._binary_manager.get_betterleaks_path()) else: - self._path = self._binary_manager.find_gitleaks_on_path() + self._path = self._binary_manager.find_betterleaks_on_path() def is_available(self) -> bool: return self.check().available - def check(self) -> GitleaksCheckResult: - """Run 'gitleaks version' and return structured result with captured output.""" + def check(self) -> BetterleaksCheckResult: + """Run 'betterleaks version' and return structured result with captured output.""" if not self._path: - return GitleaksCheckResult( + return BetterleaksCheckResult( available=False, stdout="", stderr="", - error="gitleaks not found (not installed via rafter and not on PATH)", + error="betterleaks not found (not installed via rafter and not on PATH)", ) try: result = subprocess.run( @@ -52,16 +52,16 @@ def check(self) -> GitleaksCheckResult: capture_output=True, text=True, timeout=5, ) ok = result.returncode == 0 - return GitleaksCheckResult( + return BetterleaksCheckResult( available=ok, stdout=result.stdout.strip(), stderr=result.stderr.strip(), error="" if ok else f"exit code {result.returncode}", ) except subprocess.TimeoutExpired: - return GitleaksCheckResult(available=False, stdout="", stderr="", error="timed out") + return BetterleaksCheckResult(available=False, stdout="", stderr="", error="timed out") except (OSError, FileNotFoundError) as exc: - return GitleaksCheckResult(available=False, stdout="", stderr="", error=str(exc)) + return BetterleaksCheckResult(available=False, stdout="", stderr="", error=str(exc)) @staticmethod def collect_diagnostics(binary_path: str | None = None) -> str: @@ -88,7 +88,6 @@ def collect_diagnostics(binary_path: str | None = None) -> str: lines.append(f" python arch: {platform.machine()}, system: {platform.system()}") if platform.system() == "Linux": - # Detect glibc vs musl try: result = subprocess.run( "ldd --version 2>&1 || true", shell=True, capture_output=True, text=True, timeout=5 @@ -96,7 +95,7 @@ def collect_diagnostics(binary_path: str | None = None) -> str: ldd_out = result.stdout + result.stderr if "musl" in ldd_out: lines.append( - " libc: musl (gitleaks Linux releases target glibc; " + " libc: musl (betterleaks Linux releases target glibc; " "musl systems need a static/musl build)" ) elif "GLIBC" in ldd_out or "GNU" in ldd_out: @@ -110,51 +109,83 @@ def collect_diagnostics(binary_path: str | None = None) -> str: return "\n".join(lines) - def scan_file(self, file_path: str) -> GitleaksScanResult: + def scan_file(self, file_path: str) -> BetterleaksScanResult: results = self._run_scan(file_path) - return GitleaksScanResult( + return BetterleaksScanResult( file=file_path, matches=[self._convert(r) for r in results], ) - def scan_directory(self, dir_path: str, *, use_git: bool = False) -> list[GitleaksScanResult]: + def scan_directory(self, dir_path: str, *, use_git: bool = False) -> list[BetterleaksScanResult]: results = self._run_scan(dir_path, use_git=use_git) grouped: dict[str, list[PatternMatch]] = {} for r in results: f = r.get("File", "unknown") grouped.setdefault(f, []).append(self._convert(r)) - return [GitleaksScanResult(file=f, matches=m) for f, m in grouped.items()] + return [BetterleaksScanResult(file=f, matches=m) for f, m in grouped.items()] # ------------------------------------------------------------------ def _run_scan(self, target: str, *, use_git: bool = False) -> list[dict]: if not self._path: - raise RuntimeError("Gitleaks not available") + raise RuntimeError("Betterleaks not available") - with tempfile.TemporaryDirectory(prefix="gitleaks-") as tmp_dir: + with tempfile.TemporaryDirectory(prefix="betterleaks-") as tmp_dir: report_path = os.path.join(tmp_dir, "report.json") + # betterleaks uses subcommands: `dir ` for filesystem, + # `git ` for git history; report destination is --report-path. + # `--` ensures a target path beginning with `-` isn't parsed as a flag. + subcommand = "git" if use_git else "dir" + cmd = [self._path, subcommand, "-f", "json", "--report-path", report_path, "--", target] try: - cmd = [self._path, "detect", "-f", "json", "-r", report_path, "-s", target] - if not use_git: - cmd.insert(2, "--no-git") - subprocess.run( - cmd, - capture_output=True, timeout=60, + result = subprocess.run(cmd, capture_output=True, timeout=60) + except subprocess.TimeoutExpired: + print(f"[rafter] Warning: Betterleaks scan of {target} timed out", file=sys.stderr) + return [] + except (OSError, FileNotFoundError) as exc: + raise RuntimeError(f"Betterleaks invocation failed: {exc}") from exc + + # Exit-code semantics (mirror Node): + # 0 = clean (no findings) + # 1 = findings reported (the `--exit-code` default) + # anything else = scanner / runtime error — surface, don't pretend "clean" + if result.returncode not in (0, 1): + stderr_tail = (result.stderr or b"").decode("utf-8", "replace").strip()[-500:] + raise RuntimeError( + f"Betterleaks scan failed (exit {result.returncode}): {stderr_tail or '(no stderr)'}" ) - if not os.path.exists(report_path): - return [] + + if not os.path.exists(report_path): + # Exit 0 with no report = no findings; exit 1 with no report = unexpected. + if result.returncode == 1: + stderr_tail = (result.stderr or b"").decode("utf-8", "replace").strip()[-500:] + raise RuntimeError( + f"Betterleaks reported findings (exit 1) but emitted no report: {stderr_tail or '(no stderr)'}" + ) + return [] + + try: with open(report_path) as f: content = f.read().strip() if not content: return [] - return json.loads(content) - except (subprocess.TimeoutExpired, json.JSONDecodeError): + parsed = json.loads(content) + except json.JSONDecodeError as exc: + print(f"[rafter] Warning: Failed to parse Betterleaks report: {exc}", file=sys.stderr) + return [] + + if not isinstance(parsed, list): + print( + "[rafter] Warning: Betterleaks output is not an array — possible version mismatch", + file=sys.stderr, + ) return [] + return parsed @staticmethod def _convert(result: dict) -> PatternMatch: rule_id = result.get("RuleID", result.get("Description", "unknown")) - severity = GitleaksScanner._get_severity(rule_id, result.get("Tags", [])) + severity = BetterleaksScanner._get_severity(rule_id, result.get("Tags", [])) secret = result.get("Secret", result.get("Match", "")) return PatternMatch( pattern=Pattern( @@ -166,17 +197,30 @@ def _convert(result: dict) -> PatternMatch: match=secret, line=result.get("StartLine"), column=result.get("StartColumn"), - redacted=GitleaksScanner._redact(secret), + redacted=BetterleaksScanner._redact(secret), ) @staticmethod def _get_severity(rule_id: str, tags: list) -> str: lower = rule_id.lower() - if any(k in lower for k in ("private-key", "password", "database", "access-token", "secret-key")) or lower.endswith("-pat"): + tags = tags or [] + # Critical: private keys, passwords, db credentials, access tokens, + # personal access tokens, or anything tagged as both key+secret. + if ( + any(k in lower for k in ("private-key", "password", "database", "access-token", "secret-key")) + or lower.endswith("-pat") + or ("key" in tags and "secret" in tags) + ): return "critical" - if any(k in lower for k in ("api-key", "-token", "token-")): + # High: api keys, generic tokens, or anything tagged 'api'. + if ( + any(k in lower for k in ("api-key", "-token")) + or lower.startswith("token-") + or "api" in tags + ): return "high" - if "generic" in lower: + # Medium: anything advertised as generic. + if "generic" in lower or "generic" in tags: return "medium" return "high" diff --git a/python/rafter_cli/utils/binary_manager.py b/python/rafter_cli/utils/binary_manager.py index d11bc261..606d589e 100644 --- a/python/rafter_cli/utils/binary_manager.py +++ b/python/rafter_cli/utils/binary_manager.py @@ -1,4 +1,4 @@ -"""Binary manager: download, extract, and verify the gitleaks binary.""" +"""Binary manager: download, extract, and verify the betterleaks binary.""" from __future__ import annotations import hashlib @@ -15,7 +15,25 @@ from pathlib import Path from typing import Callable, Optional -GITLEAKS_VERSION = "8.18.2" +BETTERLEAKS_VERSION = "1.1.2" + +# Pinned SHA256 hashes for the bundled BETTERLEAKS_VERSION release artifacts. +# Pulled from upstream `checksums.txt` at vendoring time. Pinning in source +# means we don't rely on the release-page `checksums.txt` to authenticate +# itself when installing the version we ship by default. Refresh whenever +# BETTERLEAKS_VERSION changes. +BETTERLEAKS_PINNED_HASHES: dict[str, str] = { + "betterleaks_1.1.2_darwin_arm64.tar.gz": "19cc2298463d7abf0aee9a03208a49834ab2e6f8411781c4cf1360827b3ded36", + "betterleaks_1.1.2_darwin_x64.tar.gz": "d51904879ed77fabad157ec67cb8dd3f5548e975fc32082e6abc30a026e1bec1", + "betterleaks_1.1.2_linux_arm64.tar.gz": "4d73dcbfe38c38878ee69e82b5aaa539398be8331f62b5640eb214ac04d890b0", + "betterleaks_1.1.2_linux_x64.tar.gz": "648c20617178065072ff1791d383192a62c911d9b4427f0426a8c504a6d9ddad", + "betterleaks_1.1.2_windows_arm64.zip": "8cc28068e8c7846027bc9b14f1c200cce64ff4198f90be5730510631c59f23ce", + "betterleaks_1.1.2_windows_x64.zip": "e149c86d00fb99cce8d87def2cd1ff046c6889a0e912007d44668df5980cea3a", +} + +# Allowed shape for the optional `--version` flag (prevents URL injection). +import re as _re +_VERSION_RE = _re.compile(r"^[A-Za-z0-9._-]+$") _SUPPORTED = { ("darwin", "x86_64"), @@ -24,6 +42,7 @@ ("linux", "arm64"), ("linux", "aarch64"), ("win32", "x86_64"), + ("win32", "arm64"), } _PLATFORM_MAP = { @@ -43,6 +62,16 @@ def _get_bin_dir() -> Path: return Path.home() / ".rafter" / "bin" +def _user_agent() -> str: + """User-Agent for outbound HTTP. Identifies the rafter-cli release that's + making the request — distinct from the BETTERLEAKS_VERSION being installed.""" + try: + from .. import __version__ as _v + return f"rafter-cli/{_v}" + except Exception: + return "rafter-cli" + + class BinaryManager: def __init__(self) -> None: self.bin_dir = _get_bin_dir() @@ -72,40 +101,52 @@ def _arch_string(self) -> str: def is_platform_supported(self) -> bool: return (self._sys_platform(), self._machine()) in _SUPPORTED - def get_gitleaks_path(self) -> Path: + def get_betterleaks_path(self) -> Path: ext = ".exe" if self._sys_platform() == "win32" else "" - return self.bin_dir / f"gitleaks{ext}" + return self.bin_dir / f"betterleaks{ext}" + + def is_betterleaks_installed(self) -> bool: + return self.get_betterleaks_path().exists() - def is_gitleaks_installed(self) -> bool: - return self.get_gitleaks_path().exists() + def find_betterleaks_on_path(self) -> str | None: + """Find betterleaks on system PATH (like Node's which/where).""" + return shutil.which("betterleaks") - def find_gitleaks_on_path(self) -> str | None: - """Find gitleaks on system PATH (like Node's which/where).""" - return shutil.which("gitleaks") + def find_legacy_gitleaks(self) -> str | None: + """Find a leftover legacy gitleaks binary so verify/status can surface + an upgrade hint instead of a confusing "not found". PATH first + (Homebrew etc.), then ~/.rafter/bin/gitleaks. Read-only — never executes. + """ + on_path = shutil.which("gitleaks") + if on_path: + return on_path + ext = ".exe" if self._sys_platform() == "win32" else "" + local = self.bin_dir / f"gitleaks{ext}" + return str(local) if local.exists() else None - def verify_gitleaks(self) -> bool: - """Check if the managed gitleaks binary works (simple bool).""" - if not self.is_gitleaks_installed(): + def verify_betterleaks(self) -> bool: + """Check if the managed betterleaks binary works (simple bool).""" + if not self.is_betterleaks_installed(): return False - result = self.verify_gitleaks_verbose() + result = self.verify_betterleaks_verbose() return result["ok"] - def get_gitleaks_version(self) -> str: - """Return installed gitleaks version string, or 'not installed'/'unknown'.""" - if not self.is_gitleaks_installed(): + def get_betterleaks_version(self) -> str: + """Return installed betterleaks version string, or 'not installed'/'unknown'.""" + if not self.is_betterleaks_installed(): return "not installed" - result = self.verify_gitleaks_verbose() + result = self.verify_betterleaks_verbose() if result["ok"] and result["stdout"]: return result["stdout"] return "unknown" - def verify_gitleaks_verbose(self, binary_path: Optional[Path] = None) -> dict: - """Run 'gitleaks version' and return {ok, stdout, stderr}. + def verify_betterleaks_verbose(self, binary_path: Optional[Path] = None) -> dict: + """Run 'betterleaks version' and return {ok, stdout, stderr}. Accept any successful exit (code 0) rather than requiring specific - stdout content — some gitleaks builds output to stderr or vary format. + stdout content. """ - path = binary_path or self.get_gitleaks_path() + path = binary_path or self.get_betterleaks_path() try: result = subprocess.run( [str(path), "version"], @@ -120,7 +161,7 @@ def verify_gitleaks_verbose(self, binary_path: Optional[Path] = None) -> dict: def collect_binary_diagnostics(self, binary_path: Optional[Path] = None) -> str: """Return diagnostic string: file type, uname, libc detection.""" - path = binary_path or self.get_gitleaks_path() + path = binary_path or self.get_betterleaks_path() lines: list[str] = [] # file(1) output @@ -155,7 +196,7 @@ def collect_binary_diagnostics(self, binary_path: Optional[Path] = None) -> str: out = r.stdout + r.stderr if "musl" in out: lines.append( - " libc: musl (gitleaks linux builds target glibc; " + " libc: musl (betterleaks linux builds target glibc; " "musl systems need a musl build or static binary)" ) elif "GLIBC" in out or "GNU" in out: @@ -169,22 +210,27 @@ def collect_binary_diagnostics(self, binary_path: Optional[Path] = None) -> str: return "\n".join(lines) - def download_gitleaks( + def download_betterleaks( self, on_progress: Optional[Callable[[str], None]] = None, - version: str = GITLEAKS_VERSION, + version: str = BETTERLEAKS_VERSION, ) -> None: - """Download, extract, chmod, and verify the gitleaks binary. + """Download, extract, chmod, and verify the betterleaks binary. Args: on_progress: Optional callback for progress messages. - version: Gitleaks version to install (defaults to GITLEAKS_VERSION). + version: Betterleaks version to install (defaults to BETTERLEAKS_VERSION). """ log = on_progress or (lambda _: None) + if not _VERSION_RE.match(version): + raise ValueError( + f"Invalid betterleaks version: {version} (expected /^[A-Za-z0-9._-]+$/)" + ) + if not self.is_platform_supported(): raise RuntimeError( - f"Gitleaks not available for {self._sys_platform()}/{self._machine()}" + f"Betterleaks not available for {self._sys_platform()}/{self._machine()}" ) self.bin_dir.mkdir(parents=True, exist_ok=True) @@ -193,10 +239,10 @@ def download_gitleaks( arch = self._arch_string() url = self._build_download_url(plat, arch, version) - log(f"Downloading Gitleaks v{version} for {plat}/{arch}...") + log(f"Downloading Betterleaks v{version} for {plat}/{arch}...") log(f" URL: {url}") - archive_name = "gitleaks.zip" if plat == "windows" else "gitleaks.tar.gz" + archive_name = "betterleaks.zip" if plat == "windows" else "betterleaks.tar.gz" archive_path = self.bin_dir / archive_name try: @@ -205,10 +251,9 @@ def download_gitleaks( size_kb = archive_path.stat().st_size / 1024 log(f" Downloaded: {size_kb:.1f} KB") - # Verify SHA256 checksum against official checksums file log("Verifying checksum...") self._verify_checksum(archive_path, plat, arch, version, log) - log(" \u2713 Checksum verified") + log(" ✓ Checksum verified") log("Extracting binary...") if plat == "windows": @@ -217,21 +262,21 @@ def download_gitleaks( self._extract_tarball(archive_path) if self._sys_platform() != "win32": - gitleaks_path = self.get_gitleaks_path() - gitleaks_path.chmod(gitleaks_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + bl_path = self.get_betterleaks_path() + bl_path.chmod(bl_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) log(" chmod +x applied") - result = self.verify_gitleaks_verbose() + result = self.verify_betterleaks_verbose() if not result["ok"]: diag = self.collect_binary_diagnostics() raise RuntimeError( - f"Gitleaks binary failed to execute.\n" - f" Binary: {self.get_gitleaks_path()}\n" + f"Betterleaks binary failed to execute.\n" + f" Binary: {self.get_betterleaks_path()}\n" f" URL: {url}\n" - + (f" gitleaks version stdout: {result['stdout']}\n" if result["stdout"] else "") - + (f" gitleaks version stderr: {result['stderr']}\n" if result["stderr"] else "") + + (f" betterleaks version stdout: {result['stdout']}\n" if result["stdout"] else "") + + (f" betterleaks version stderr: {result['stderr']}\n" if result["stderr"] else "") + f"Diagnostics:\n{diag}\n" - + "Fix: ensure the binary matches your OS/arch, or install gitleaks manually and ensure it is on PATH." + + "Fix: ensure the binary matches your OS/arch, or install betterleaks manually and ensure it is on PATH." ) log(f" Verified: {result['stdout']}") @@ -239,23 +284,23 @@ def download_gitleaks( if archive_path.exists(): archive_path.unlink() - log("Gitleaks installed successfully") + log("Betterleaks installed successfully") except Exception: if archive_path.exists(): archive_path.unlink() - binary = self.get_gitleaks_path() + binary = self.get_betterleaks_path() if binary.exists(): binary.unlink() raise # ── private helpers ─────────────────────────────────────────────── - def _build_download_url(self, platform_str: str, arch_str: str, version: str = GITLEAKS_VERSION) -> str: - base = f"https://github.com/gitleaks/gitleaks/releases/download/v{version}" + def _build_download_url(self, platform_str: str, arch_str: str, version: str = BETTERLEAKS_VERSION) -> str: + base = f"https://github.com/betterleaks/betterleaks/releases/download/v{version}" if platform_str == "windows": - return f"{base}/gitleaks_{version}_windows_{arch_str}.zip" - return f"{base}/gitleaks_{version}_{platform_str}_{arch_str}.tar.gz" + return f"{base}/betterleaks_{version}_windows_{arch_str}.zip" + return f"{base}/betterleaks_{version}_{platform_str}_{arch_str}.tar.gz" def _verify_checksum( self, @@ -265,39 +310,50 @@ def _verify_checksum( version: str, on_progress: Callable[[str], None], ) -> None: - """Verify downloaded archive checksum against official gitleaks checksums file.""" - checksums_url = ( - f"https://github.com/gitleaks/gitleaks/releases/download/v{version}" - f"/gitleaks_{version}_checksums.txt" - ) - checksums_path = self.bin_dir / "checksums.txt" + """Verify downloaded archive checksum. - try: - self._download_file(checksums_url, checksums_path, lambda _: None) - checksums_content = checksums_path.read_text() - - if platform_str == "windows": - archive_filename = f"gitleaks_{version}_windows_{arch_str}.zip" - else: - archive_filename = f"gitleaks_{version}_{platform_str}_{arch_str}.tar.gz" + For BETTERLEAKS_VERSION (the version we vendor), use the SHA256 pinned + in source — this prevents a release-page compromise from re-signing both + the tarball and `checksums.txt`. For an explicit `--version `, + fall back to the upstream `checksums.txt` (TOFU at that moment). + """ + if platform_str == "windows": + archive_filename = f"betterleaks_{version}_windows_{arch_str}.zip" + else: + archive_filename = f"betterleaks_{version}_{platform_str}_{arch_str}.tar.gz" - expected_hash = self._parse_checksum_file(checksums_content, archive_filename) - if not expected_hash: - raise RuntimeError( - f"Checksum not found for {archive_filename} in checksums file" - ) + expected_hash: str | None = None + source = "" - actual_hash = self._compute_sha256(archive_path) - if actual_hash != expected_hash: - raise RuntimeError( - f"Checksum mismatch for {archive_filename}:\n" - f" Expected: {expected_hash}\n" - f" Actual: {actual_hash}\n" - f"The downloaded file may be corrupted or tampered with." - ) - finally: - if checksums_path.exists(): - checksums_path.unlink() + if version == BETTERLEAKS_VERSION and archive_filename in BETTERLEAKS_PINNED_HASHES: + expected_hash = BETTERLEAKS_PINNED_HASHES[archive_filename] + source = "pinned in source" + else: + checksums_url = ( + f"https://github.com/betterleaks/betterleaks/releases/download/v{version}" + f"/checksums.txt" + ) + checksums_path = self.bin_dir / "checksums.txt" + try: + self._download_file(checksums_url, checksums_path, lambda _: None) + checksums_content = checksums_path.read_text() + expected_hash = self._parse_checksum_file(checksums_content, archive_filename) + finally: + if checksums_path.exists(): + checksums_path.unlink() + source = "release checksums.txt" + + if not expected_hash: + raise RuntimeError(f"Checksum not found for {archive_filename} ({source})") + + actual_hash = self._compute_sha256(archive_path) + if actual_hash != expected_hash: + raise RuntimeError( + f"Checksum mismatch for {archive_filename} ({source}):\n" + f" Expected: {expected_hash}\n" + f" Actual: {actual_hash}\n" + f"The downloaded file may be corrupted or tampered with." + ) @staticmethod def _parse_checksum_file(content: str, filename: str) -> str | None: @@ -306,7 +362,6 @@ def _parse_checksum_file(content: str, filename: str) -> str | None: line = line.strip() if not line: continue - # Format: " " (two spaces between hash and filename) parts = line.split() if len(parts) >= 2 and parts[1] == filename: return parts[0].lower() @@ -329,19 +384,35 @@ def _download_file( url: str, dest: Path, on_progress: Callable[[str], None], - *, - _redirects: int = 0, ) -> None: - if _redirects > 10: - raise RuntimeError("Too many redirects") + # Defenses (mirrors Node): + # - HTTPS-only (initial URL + final URL after urllib's internal redirect chain) + # - 60s socket timeout + # - 200 MB body cap (betterleaks releases are ~12 MB; abort early on + # pathologically large responses to prevent disk-fill DoS) + MAX_BYTES = 200 * 1024 * 1024 + + if not url.lower().startswith("https://"): + raise RuntimeError(f"Refusing non-https download URL: {url}") request = urllib.request.Request( url, - headers={"User-Agent": f"rafter-cli/{GITLEAKS_VERSION}"}, + headers={"User-Agent": _user_agent()}, ) with urllib.request.urlopen(request, timeout=60) as response: + # urllib resolves redirects internally; verify the final URL is + # still https (defense in depth — strips any pathological mixed + # http/https redirect chain). + final_url = response.geturl() + if not final_url.lower().startswith("https://"): + raise RuntimeError(f"Refusing non-https final URL after redirects: {final_url}") total = int(response.headers.get("Content-Length", 0)) + if total > MAX_BYTES: + raise RuntimeError( + f"Download refused: Content-Length {total} exceeds cap {MAX_BYTES} " + f"(betterleaks releases are ~12 MB)." + ) downloaded = 0 last_pct = 0 @@ -350,6 +421,11 @@ def _download_file( chunk = response.read(65536) if not chunk: break + if downloaded + len(chunk) > MAX_BYTES: + raise RuntimeError( + f"Download aborted: stream exceeded {MAX_BYTES} bytes " + f"(betterleaks releases are ~12 MB)." + ) f.write(chunk) downloaded += len(chunk) if total > 0: @@ -359,39 +435,53 @@ def _download_file( last_pct = pct def _extract_zip(self, archive_path: Path) -> None: - """Extract only the gitleaks binary from a Windows zip archive.""" - allowed = {"gitleaks", "gitleaks.exe"} - with tempfile.TemporaryDirectory(prefix="rafter-gitleaks-") as tmp: + """Extract only the betterleaks binary from a Windows zip archive. + + Defensively rejects symlink/hardlink-style entries — zip can encode + these via Unix-mode external attrs, and we don't want a malicious + release pointing the binary at e.g. `~/.ssh/authorized_keys`. + """ + allowed = {"betterleaks", "betterleaks.exe"} + with tempfile.TemporaryDirectory(prefix="rafter-betterleaks-") as tmp: tmp_path = Path(tmp) with zipfile.ZipFile(archive_path, "r") as zf: for info in zf.infolist(): # Reject path-traversal entries (zip-slip) if info.filename.startswith("/") or ".." in info.filename: continue + # Reject symlinks/hardlinks (Unix mode bits in external_attr) + if (info.external_attr >> 16) & 0o170000 in (0o120000, 0o140000): + continue basename = os.path.basename(info.filename) if basename not in allowed: continue - # Extract only the matching binary, flattened into tmp info.filename = basename zf.extract(info, tmp_path) - # Locate extracted binary found: Path | None = None for name in allowed: candidate = tmp_path / name - if candidate.exists(): + if candidate.exists() and not candidate.is_symlink() and candidate.is_file(): found = candidate break if found is None: - raise RuntimeError("gitleaks binary not found in archive") + raise RuntimeError("betterleaks binary not found in archive (or is symlink/special)") target = self.bin_dir / found.name shutil.copy2(str(found), str(target)) def _extract_tarball(self, archive_path: Path) -> None: - """Extract only the gitleaks binary from the tarball.""" - # filter="data" was added in Python 3.12; fall back gracefully on older runtimes. + """Extract only the betterleaks binary from the tarball. + + Defensively rejects symlinks/hardlinks/devices and absolute / `..` paths. + Without the symlink reject a malicious release could ship a `betterleaks` + entry that's a symlink to e.g. `~/.ssh/authorized_keys`; the subsequent + `chmod +x` (which follows symlinks) would then mode-flip the target. + + Uses `filter="data"` on Python 3.12+ which adds a second layer of + defense (rejects unsafe member kinds at the stdlib level). + """ _extract_kwargs: dict = {} if sys.version_info >= (3, 12): _extract_kwargs["filter"] = "data" @@ -399,7 +489,23 @@ def _extract_tarball(self, archive_path: Path) -> None: with tarfile.open(archive_path, "r:gz") as tf: for member in tf.getmembers(): base = os.path.basename(member.name) - if base in ("gitleaks", "gitleaks.exe"): - # Flatten: extract directly to bin_dir with just the binary name - member.name = base - tf.extract(member, path=self.bin_dir, **_extract_kwargs) + if base not in ("betterleaks", "betterleaks.exe"): + continue + if member.issym() or member.islnk() or member.isdev(): + raise RuntimeError( + f"Refusing to extract non-regular tar entry: {member.name} " + f"(type={member.type!r})" + ) + if member.name.startswith("/") or ".." in member.name.split("/"): + raise RuntimeError(f"Refusing path-traversal tar entry: {member.name}") + member.name = base + tf.extract(member, path=self.bin_dir, **_extract_kwargs) + + # Belt-and-suspenders: confirm what landed is a regular file. + installed = self.get_betterleaks_path() + if installed.exists(): + if installed.is_symlink() or not installed.is_file(): + installed.unlink() + raise RuntimeError( + "Extracted betterleaks is not a regular file (symlink/special); aborting." + ) diff --git a/python/tests/test_agent_verify.py b/python/tests/test_agent_verify.py index 2a4f20a3..0ad137ba 100644 --- a/python/tests/test_agent_verify.py +++ b/python/tests/test_agent_verify.py @@ -11,7 +11,7 @@ from rafter_cli.commands.agent import ( agent_app, _check_config, - _check_gitleaks, + _check_betterleaks, _check_claude_code, _check_openclaw, _check_codex, @@ -63,45 +63,45 @@ def test_fails_when_config_invalid_json(self, tmp_path): assert "Invalid" in r.detail -# ── _check_gitleaks ──────────────────────────────────────────────────── +# ── _check_betterleaks ──────────────────────────────────────────────────── -class TestCheckGitleaks: - def test_passes_when_gitleaks_on_path(self): - verify_result = {"ok": True, "stdout": "gitleaks version 8.18.2", "stderr": ""} - with patch("shutil.which", return_value="/usr/local/bin/gitleaks"), \ +class TestCheckBetterleaks: + def test_passes_when_betterleaks_on_path(self): + verify_result = {"ok": True, "stdout": "betterleaks 1.1.2", "stderr": ""} + with patch("shutil.which", return_value="/usr/local/bin/betterleaks"), \ patch("rafter_cli.commands.agent.BinaryManager") as MockBM: - MockBM.return_value.verify_gitleaks_verbose.return_value = verify_result - r = _check_gitleaks() + MockBM.return_value.verify_betterleaks_verbose.return_value = verify_result + r = _check_betterleaks() assert r.passed - assert "gitleaks version" in r.detail + assert "betterleaks" in r.detail - def test_passes_when_gitleaks_in_rafter_bin(self, tmp_path): - rafter_bin = tmp_path / ".rafter" / "bin" / "gitleaks" + def test_passes_when_betterleaks_in_rafter_bin(self, tmp_path): + rafter_bin = tmp_path / ".rafter" / "bin" / "betterleaks" rafter_bin.parent.mkdir(parents=True) rafter_bin.touch() - verify_result = {"ok": True, "stdout": "gitleaks version 8.18.2", "stderr": ""} + verify_result = {"ok": True, "stdout": "betterleaks 1.1.2", "stderr": ""} with patch("shutil.which", return_value=None), \ patch("pathlib.Path.home", return_value=tmp_path), \ patch("rafter_cli.commands.agent.BinaryManager") as MockBM: - MockBM.return_value.verify_gitleaks_verbose.return_value = verify_result - r = _check_gitleaks() + MockBM.return_value.verify_betterleaks_verbose.return_value = verify_result + r = _check_betterleaks() assert r.passed def test_fails_when_not_found_anywhere(self, tmp_path): with patch("shutil.which", return_value=None), \ patch("pathlib.Path.home", return_value=tmp_path): - r = _check_gitleaks() + r = _check_betterleaks() assert not r.passed assert not r.optional # hard failure assert "Not found" in r.detail def test_fails_with_diagnostics_when_binary_broken(self): verify_result = {"ok": False, "stdout": "", "stderr": "exec format error"} - with patch("shutil.which", return_value="/usr/local/bin/gitleaks"), \ + with patch("shutil.which", return_value="/usr/local/bin/betterleaks"), \ patch("rafter_cli.commands.agent.BinaryManager") as MockBM: - MockBM.return_value.verify_gitleaks_verbose.return_value = verify_result + MockBM.return_value.verify_betterleaks_verbose.return_value = verify_result MockBM.return_value.collect_binary_diagnostics.return_value = " file: ELF 64-bit" - r = _check_gitleaks() + r = _check_betterleaks() assert not r.passed assert not r.optional assert "failed to execute" in r.detail @@ -371,8 +371,8 @@ def test_exits_0_when_all_core_checks_pass(self, tmp_path): """All core checks pass, optional absent → exit 0.""" with patch("rafter_cli.commands.agent._check_config", return_value=_CheckResult("Config", True, "ok")), \ - patch("rafter_cli.commands.agent._check_gitleaks", - return_value=_CheckResult("Gitleaks", True, "ok")), \ + patch("rafter_cli.commands.agent._check_betterleaks", + return_value=_CheckResult("Betterleaks", True, "ok")), \ patch("rafter_cli.commands.agent._check_claude_code", return_value=_CheckResult("Claude Code", False, "not configured", optional=True)), \ patch("rafter_cli.commands.agent._check_openclaw", @@ -386,8 +386,8 @@ def test_exits_0_with_all_checks_passing(self, tmp_path): """All checks pass → exit 0, no warnings.""" with patch("rafter_cli.commands.agent._check_config", return_value=_CheckResult("Config", True, "ok")), \ - patch("rafter_cli.commands.agent._check_gitleaks", - return_value=_CheckResult("Gitleaks", True, "ok")), \ + patch("rafter_cli.commands.agent._check_betterleaks", + return_value=_CheckResult("Betterleaks", True, "ok")), \ patch("rafter_cli.commands.agent._check_claude_code", return_value=_CheckResult("Claude Code", True, "ok")), \ patch("rafter_cli.commands.agent._check_openclaw", @@ -401,8 +401,8 @@ def test_exits_1_when_config_missing(self): """Config failure (hard) → exit 1.""" with patch("rafter_cli.commands.agent._check_config", return_value=_CheckResult("Config", False, "Not found")), \ - patch("rafter_cli.commands.agent._check_gitleaks", - return_value=_CheckResult("Gitleaks", True, "ok")), \ + patch("rafter_cli.commands.agent._check_betterleaks", + return_value=_CheckResult("Betterleaks", True, "ok")), \ patch("rafter_cli.commands.agent._check_claude_code", return_value=_CheckResult("Claude Code", False, "not configured", optional=True)), \ patch("rafter_cli.commands.agent._check_openclaw", @@ -412,12 +412,12 @@ def test_exits_1_when_config_missing(self): result = runner.invoke(agent_app, ["verify"]) assert result.exit_code == 1 - def test_exits_1_when_gitleaks_broken(self): - """Gitleaks failure (hard) → exit 1.""" + def test_exits_1_when_betterleaks_broken(self): + """Betterleaks failure (hard) → exit 1.""" with patch("rafter_cli.commands.agent._check_config", return_value=_CheckResult("Config", True, "ok")), \ - patch("rafter_cli.commands.agent._check_gitleaks", - return_value=_CheckResult("Gitleaks", False, "binary broken")), \ + patch("rafter_cli.commands.agent._check_betterleaks", + return_value=_CheckResult("Betterleaks", False, "binary broken")), \ patch("rafter_cli.commands.agent._check_claude_code", return_value=_CheckResult("Claude Code", False, "absent", optional=True)), \ patch("rafter_cli.commands.agent._check_openclaw", @@ -431,8 +431,8 @@ def test_exits_0_when_only_optional_checks_fail(self): """Only optional checks absent → exit 0 (WARN not FAIL).""" with patch("rafter_cli.commands.agent._check_config", return_value=_CheckResult("Config", True, "ok")), \ - patch("rafter_cli.commands.agent._check_gitleaks", - return_value=_CheckResult("Gitleaks", True, "ok")), \ + patch("rafter_cli.commands.agent._check_betterleaks", + return_value=_CheckResult("Betterleaks", True, "ok")), \ patch("rafter_cli.commands.agent._check_claude_code", return_value=_CheckResult("Claude Code", False, "absent", optional=True)), \ patch("rafter_cli.commands.agent._check_openclaw", diff --git a/python/tests/test_gitleaks_severity.py b/python/tests/test_betterleaks_severity.py similarity index 54% rename from python/tests/test_gitleaks_severity.py rename to python/tests/test_betterleaks_severity.py index 5d119886..d14acd5a 100644 --- a/python/tests/test_gitleaks_severity.py +++ b/python/tests/test_betterleaks_severity.py @@ -1,9 +1,9 @@ -"""Tests for GitleaksScanner._get_severity mapping.""" +"""Tests for BetterleaksScanner._get_severity mapping.""" from __future__ import annotations import pytest -from rafter_cli.scanners.gitleaks import GitleaksScanner +from rafter_cli.scanners.betterleaks import BetterleaksScanner class TestGetSeverity: @@ -22,7 +22,7 @@ class TestGetSeverity: "azure-devops-pat", ]) def test_critical_rules(self, rule_id): - assert GitleaksScanner._get_severity(rule_id, []) == "critical" + assert BetterleaksScanner._get_severity(rule_id, []) == "critical" @pytest.mark.parametrize("rule_id", [ "api-key", @@ -31,29 +31,43 @@ def test_critical_rules(self, rule_id): "token-refresh", ]) def test_high_rules(self, rule_id): - assert GitleaksScanner._get_severity(rule_id, []) == "high" + assert BetterleaksScanner._get_severity(rule_id, []) == "high" @pytest.mark.parametrize("rule_id", [ "generic-secret", ]) def test_medium_rules(self, rule_id): - assert GitleaksScanner._get_severity(rule_id, []) == "medium" + assert BetterleaksScanner._get_severity(rule_id, []) == "medium" def test_unknown_rule_defaults_to_high(self): - assert GitleaksScanner._get_severity("some-unknown-rule", []) == "high" + assert BetterleaksScanner._get_severity("some-unknown-rule", []) == "high" # ── No false positives ─────────────────────────────────────── def test_spatial_data_not_critical(self): """'-pat' should only match at end of rule ID.""" - sev = GitleaksScanner._get_severity("spatial-data", []) + sev = BetterleaksScanner._get_severity("spatial-data", []) assert sev != "critical" def test_file_pattern_not_critical(self): - sev = GitleaksScanner._get_severity("file-pattern", []) + sev = BetterleaksScanner._get_severity("file-pattern", []) assert sev != "critical" def test_tokenizer_not_matched_by_token_rule(self): """'tokenizer' doesn't contain '-token' or start with 'token-', falls to default.""" - sev = GitleaksScanner._get_severity("tokenizer-config", []) + sev = BetterleaksScanner._get_severity("tokenizer-config", []) assert sev == "high" # default for unknown rules, not via token match + + # ── Tag-based classification (parity with Node) ────────────── + + def test_tag_key_plus_secret_is_critical(self): + sev = BetterleaksScanner._get_severity("some-unknown-rule", ["key", "secret"]) + assert sev == "critical" + + def test_tag_api_is_high(self): + sev = BetterleaksScanner._get_severity("some-unknown-rule", ["api"]) + assert sev == "high" + + def test_tag_generic_is_medium(self): + sev = BetterleaksScanner._get_severity("some-unknown-rule", ["generic"]) + assert sev == "medium" diff --git a/python/tests/test_binary_manager.py b/python/tests/test_binary_manager.py new file mode 100644 index 00000000..8c3ce906 --- /dev/null +++ b/python/tests/test_binary_manager.py @@ -0,0 +1,92 @@ +"""Targeted tests for the betterleaks BinaryManager security guards.""" +from __future__ import annotations + +import pytest + +from rafter_cli.utils.binary_manager import ( + BETTERLEAKS_PINNED_HASHES, + BETTERLEAKS_VERSION, + BinaryManager, +) + + +class TestVersionValidation: + """`--version` flows into a download URL — guard against injection.""" + + @pytest.mark.parametrize( + "bad_version", + [ + "1.1.2/../evil", + "../etc/passwd", + "1.1.2 && rm -rf /", + "1.1.2;curl evil.com", + "v1.1.2/whatever", # slashes not allowed + "", # empty + ], + ) + def test_rejects_invalid_version(self, bad_version): + bm = BinaryManager() + with pytest.raises(ValueError, match="Invalid betterleaks version"): + bm.download_betterleaks(version=bad_version) + + @pytest.mark.parametrize( + "good_version", + ["1.1.2", "1.0.0", "v1.1.2", "1.1.2-rc1", "2.0.0_beta"], + ) + def test_accepts_well_formed_version(self, good_version, monkeypatch): + """Valid shape should pass validation. Block before any network call by + forcing platform-unsupported.""" + bm = BinaryManager() + monkeypatch.setattr(bm, "is_platform_supported", lambda: False) + # Should reach the platform check (then raise RuntimeError, not ValueError). + with pytest.raises(RuntimeError, match="not available for"): + bm.download_betterleaks(version=good_version) + + +class TestPinnedHashes: + """The bundled BETTERLEAKS_VERSION must have a complete hash table — + otherwise we silently fall back to fetching the upstream checksums.txt + on the default install path, which the migration explicitly avoids.""" + + def test_pinned_hashes_cover_all_supported_artifacts(self): + version = BETTERLEAKS_VERSION + expected = { + f"betterleaks_{version}_darwin_arm64.tar.gz", + f"betterleaks_{version}_darwin_x64.tar.gz", + f"betterleaks_{version}_linux_arm64.tar.gz", + f"betterleaks_{version}_linux_x64.tar.gz", + f"betterleaks_{version}_windows_arm64.zip", + f"betterleaks_{version}_windows_x64.zip", + } + missing = expected - set(BETTERLEAKS_PINNED_HASHES) + assert not missing, ( + f"Missing pinned SHA256 for: {missing}. " + f"After bumping BETTERLEAKS_VERSION, refresh BETTERLEAKS_PINNED_HASHES." + ) + + def test_pinned_hashes_are_64_hex_chars(self): + for filename, hash_ in BETTERLEAKS_PINNED_HASHES.items(): + assert len(hash_) == 64, f"{filename}: hash is not 64 chars" + int(hash_, 16) # raises ValueError if not hex + + +class TestNonHttpsRefused: + """`_download_file` is the only network entry point; refuse non-https.""" + + def test_refuses_http_url(self): + bm = BinaryManager() + with pytest.raises(RuntimeError, match="non-https"): + bm._download_file( + "http://example.com/foo", # type: ignore[arg-type] + bm.bin_dir / "_test.bin", + lambda _: None, + ) + + def test_refuses_file_url(self): + bm = BinaryManager() + with pytest.raises(RuntimeError, match="non-https"): + bm._download_file( + "file:///etc/passwd", # type: ignore[arg-type] + bm.bin_dir / "_test.bin", + lambda _: None, + ) diff --git a/python/tests/test_e2e_cli.py b/python/tests/test_e2e_cli.py index fe3e49af..a69eeb1e 100644 --- a/python/tests/test_e2e_cli.py +++ b/python/tests/test_e2e_cli.py @@ -262,6 +262,20 @@ def test_exits_0_for_clean_file(self, tmp_path): _, _, rc = rafter(f"secrets {f} --engine patterns --quiet") assert rc == 0 + def test_engine_gitleaks_no_longer_accepted(self, tmp_path): + """`--engine gitleaks` was removed; the validator should reject it.""" + f = tmp_path / "clean.txt" + f.write_text("no secrets\n") + _, stderr, rc = rafter(f"secrets {f} --engine gitleaks --quiet") + assert rc == 2, f"expected rc=2; got rc={rc}, stderr={stderr!r}" + assert "Invalid engine" in stderr + + def test_with_gitleaks_no_longer_accepted(self): + """`--with-gitleaks` was removed; typer/click should report unknown option.""" + _, stderr, rc = rafter("agent init --with-gitleaks") + assert rc != 0 + assert "no such option" in stderr.lower() or "no such option" in stderr.lower() + # --------------------------------------------------------------------------- # Command risk assessment diff --git a/python/tests/test_error_handling_gauntlet.py b/python/tests/test_error_handling_gauntlet.py index bfd0c8dd..776f9655 100644 --- a/python/tests/test_error_handling_gauntlet.py +++ b/python/tests/test_error_handling_gauntlet.py @@ -534,23 +534,23 @@ def test_403_generic_route(self): # --------------------------------------------------------------------------- -# 7. GitleaksScanner — availability +# 7. BetterleaksScanner — availability # --------------------------------------------------------------------------- -from rafter_cli.scanners.gitleaks import GitleaksScanner +from rafter_cli.scanners.betterleaks import BetterleaksScanner -class TestGitleaksScannerErrors: +class TestBetterleaksScannerErrors: def test_is_available_returns_bool(self): - scanner = GitleaksScanner() + scanner = BetterleaksScanner() result = scanner.is_available() assert isinstance(result, bool) def test_scan_file_with_missing_binary(self, tmp_path): - """Scanner with no gitleaks binary should handle error gracefully.""" - scanner = GitleaksScanner() + """Scanner with no betterleaks binary should handle error gracefully.""" + scanner = BetterleaksScanner() # Override the binary path after construction - scanner._path = "/nonexistent/gitleaks" + scanner._path = "/nonexistent/betterleaks" test_file = tmp_path / "test.txt" test_file.write_text("test content") try: diff --git a/python/tests/test_mcp_server.py b/python/tests/test_mcp_server.py index e18025f0..85016cdd 100644 --- a/python/tests/test_mcp_server.py +++ b/python/tests/test_mcp_server.py @@ -44,19 +44,19 @@ def test_scan_clean_file(self, tmp_path): results = handle_scan_secrets(str(f), engine="patterns") assert results[0]["matches"] == [] - def test_gitleaks_not_available_falls_back(self, tmp_path): + def test_betterleaks_not_available_falls_back(self, tmp_path): f = tmp_path / "test.txt" f.write_text("AKIAIOSFODNN7EXAMPLE1\n") - with patch("rafter_cli.commands.mcp_server.GitleaksScanner") as mock_gl: - mock_gl.return_value.is_available.return_value = False + with patch("rafter_cli.commands.mcp_server.BetterleaksScanner") as mock_bl: + mock_bl.return_value.is_available.return_value = False results = handle_scan_secrets(str(f), engine="auto") assert len(results) == 1 - def test_gitleaks_only_raises_when_unavailable(self): - with patch("rafter_cli.commands.mcp_server.GitleaksScanner") as mock_gl: - mock_gl.return_value.is_available.return_value = False + def test_betterleaks_only_raises_when_unavailable(self): + with patch("rafter_cli.commands.mcp_server.BetterleaksScanner") as mock_bl: + mock_bl.return_value.is_available.return_value = False with pytest.raises(RuntimeError, match="not installed"): - handle_scan_secrets("/tmp", engine="gitleaks") + handle_scan_secrets("/tmp", engine="betterleaks") class TestEvaluateCommand: diff --git a/recipes/README.md b/recipes/README.md index 4e09b875..eb7f73a2 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -25,7 +25,7 @@ Integration snippets for adding Rafter security to your development workflow. Ea npm install -g @rafter-security/cli pip install rafter-cli -# One-command setup: config, gitleaks, agent skills +# One-command setup: config, betterleaks, agent skills rafter agent init ``` diff --git a/recipes/gemini-cli.md b/recipes/gemini-cli.md index e4a3b8a3..e86b57f0 100644 --- a/recipes/gemini-cli.md +++ b/recipes/gemini-cli.md @@ -41,7 +41,7 @@ Once the MCP server is configured, Gemini CLI can call the following tools: | Tool | Description | |------|-------------| -| `scan_secrets` | Scan files or directories for hardcoded secrets and credentials. Supports `gitleaks` and `patterns` engines. | +| `scan_secrets` | Scan files or directories for hardcoded secrets and credentials. Supports `betterleaks` and `patterns` engines. | | `evaluate_command` | Check if a shell command is allowed by Rafter security policy. Returns risk level and approval requirement. | | `read_audit_log` | Query the Rafter audit log with optional filtering by event type, count, or timestamp. | | `get_config` | Read Rafter configuration — full config or a specific key via dot-path (e.g. `agent.commandPolicy`). | diff --git a/recipes/pre-commit.md b/recipes/pre-commit.md index 0409aee4..697e08b3 100644 --- a/recipes/pre-commit.md +++ b/recipes/pre-commit.md @@ -1,6 +1,6 @@ # Pre-Commit Hook -Block secrets from entering version control. Rafter scans staged files before every commit and rejects any that contain hardcoded credentials. 21+ credential patterns via Gitleaks, deterministic results, exit code 1 on findings. +Block secrets from entering version control. Rafter scans staged files before every commit and rejects any that contain hardcoded credentials. 21+ built-in credential patterns plus optional Betterleaks integration (the gitleaks successor) for higher-recall detection. Deterministic results, exit code 1 on findings. ## Pre-commit framework @@ -78,4 +78,4 @@ git commit --no-verify rafter agent verify ``` -Checks that the hook is installed, Gitleaks binary is present, and config is valid. +Checks that the hook is installed, Betterleaks binary is present, and config is valid. diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index 202ba73b..2447693a 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -153,11 +153,11 @@ Initialize local security system. Creates config and detects available developme - `--with-cursor` — install Cursor integration - `--with-windsurf` — install Windsurf integration - `--with-continue` — install Continue.dev integration -- `--with-gitleaks` — download and install Gitleaks binary -- `--all` — install all detected integrations and download Gitleaks +- `--with-betterleaks` — download and install Betterleaks binary (the gitleaks successor) +- `--all` — install all detected integrations and download Betterleaks - `-i, --interactive` — guided setup — prompts for each detected integration (Node only) -- `--update` — re-download gitleaks and reinstall integrations without resetting config -- `--local` — install integration configs into the current working directory instead of the user home. Writes to `./.claude/`, `./.agents/`, `./.gemini/`, `./.cursor/` etc. Supports `--with-claude-code`, `--with-codex`, `--with-gemini`, `--with-cursor`. User-level side effects (global config, `agent.environments.*.enabled`, auto-detection, gitleaks download) are suppressed in this mode. Intended for benchmark harnesses, one-off project setup, and ephemeral containers. +- `--update` — re-download betterleaks and reinstall integrations without resetting config +- `--local` — install integration configs into the current working directory instead of the user home. Writes to `./.claude/`, `./.agents/`, `./.gemini/`, `./.cursor/` etc. Supports `--with-claude-code`, `--with-codex`, `--with-gemini`, `--with-cursor`. User-level side effects (global config, `agent.environments.*.enabled`, auto-detection, betterleaks download) are suppressed in this mode. Intended for benchmark harnesses, one-off project setup, and ephemeral containers. ### rafter agent list [OPTIONS] @@ -289,7 +289,7 @@ Persists `skillInstallations...enabled = false` in `~/.rafter/co Aliases: `rafter scan local`, `rafter agent scan` (both still supported for backward compatibility) -Scan files or directories for **hardcoded secrets** (21+ patterns + gitleaks). **Secrets only — not a full code-security scan.** For SAST + SCA, use `rafter run`. +Scan files or directories for **hardcoded secrets** (21+ patterns + betterleaks). **Secrets only — not a full code-security scan.** For SAST + SCA, use `rafter run`. The `secrets` spelling is preferred because it makes the scope explicit; `scan local` reads as "the full scan, locally" which it is not. @@ -299,10 +299,10 @@ The `secrets` spelling is preferred because it makes the scope explicit; `scan l - `--format ` — output format: `text`, `json`, or `sarif` (default: `text`) - `--staged` — scan git staged files only - `--diff ` — scan files changed since a git ref (e.g., `HEAD~1`, `main`) -- `--engine ` — `gitleaks`, `patterns`, or `auto` (default) +- `--engine ` — `betterleaks`, `patterns`, or `auto` (default). - `--baseline` — filter findings present in the saved baseline (see `rafter agent baseline`) - `--watch` — watch path for file changes and re-scan on each change; Ctrl+C exits -- `--history` — scan the full git history for previously-committed secrets (requires `--engine gitleaks`; invokes `gitleaks detect` against the repo history) +- `--history` — scan the full git history for previously-committed secrets (requires `--engine betterleaks`; invokes `betterleaks git` against the repo history) Exit codes: 0 = clean, 1 = secrets found, 2 = runtime error. @@ -767,7 +767,7 @@ Check agent security integration status. Reports whether config files, hooks, an | Name | Severity | Detection | Pass criterion | |---|---|---|---| | `Config` | hard (exit 1 on fail) | `~/.rafter/config.json` exists and parses | valid JSON | -| `Gitleaks` | hard | binary on PATH or at `~/.rafter/bin/gitleaks` | `--version` succeeds | +| `Betterleaks` | hard | binary on PATH or at `~/.rafter/bin/betterleaks` | `version` succeeds | | `Claude Code` | optional | `~/.claude/` exists | `settings.json` PreToolUse contains `rafter hook pretool` | | `OpenClaw` | optional | `~/.openclaw/skills/` exists | `rafter-security.md` skill present | | `Codex CLI` | optional | `~/.codex/` exists | `~/.agents/skills/rafter/SKILL.md` present | @@ -789,7 +789,7 @@ With `--probe`, an additional `Claude Code (probe)` check appears as the last en { "checks": [ { "name": "Config", "status": "pass", "detail": "/home/u/.rafter/config.json" }, - { "name": "Gitleaks", "status": "fail", "detail": "Not found on PATH or at ..." }, + { "name": "Betterleaks", "status": "fail", "detail": "Not found on PATH or at ..." }, { "name": "Claude Code", "status": "warn", "detail": "Not detected — run 'rafter agent init --with-claude-code' to enable" } ], "summary": { @@ -802,17 +802,17 @@ With `--probe`, an additional `Claude Code (probe)` check appears as the last en } ``` -`status` is one of `pass | warn | fail`. `warn` is reserved for optional integrations that aren't installed; `fail` is reserved for hard failures (Config, Gitleaks, or any failing `--probe` check). +`status` is one of `pass | warn | fail`. `warn` is reserved for optional integrations that aren't installed; `fail` is reserved for hard failures (Config, Betterleaks, or any failing `--probe` check). ### rafter agent status Show agent security status dashboard. Displays config summary, installed integrations, audit log summary, and recent events. -### rafter agent update-gitleaks [OPTIONS] +### rafter agent update-betterleaks [OPTIONS] -Update (or reinstall) the managed gitleaks binary. +Update (or reinstall) the managed betterleaks binary. -- `--version ` — specific gitleaks version to install (default: current bundled version) +- `--version ` — specific betterleaks version to install (default: current bundled version) ### rafter agent baseline SUBCOMMAND @@ -878,7 +878,7 @@ Start MCP server over stdio transport. Exposes 6 tools and 3 resources. **`scan_secrets` inputs:** - `path` (required) — file or directory path to scan -- `engine` (optional) — `auto` (default), `gitleaks`, or `patterns` +- `engine` (optional) — `auto` (default), `betterleaks`, or `patterns`. **`evaluate_command` output schema:** ```json diff --git a/shared-docs/SHOW_HN_DRAFT.md b/shared-docs/SHOW_HN_DRAFT.md index 8a4d705e..aa7a2c31 100644 --- a/shared-docs/SHOW_HN_DRAFT.md +++ b/shared-docs/SHOW_HN_DRAFT.md @@ -20,7 +20,7 @@ Hi HN, I'm [NAME] and I built Rafter, an open-source security toolkit for develo **What Rafter does:** -- **Secret scanning** — 21+ built-in patterns (AWS, GitHub, Stripe, etc.), pre-commit hooks, CI integration. Dual engine: tries Gitleaks first, falls back to built-in regex. Deterministic — same inputs, same findings. JSON output you can pipe to `jq` or feed to any tool. +- **Secret scanning** — 21+ built-in patterns (AWS, GitHub, Stripe, etc.), pre-commit hooks, CI integration. Dual engine: tries Betterleaks (the gitleaks successor) first, falls back to built-in regex. Deterministic — same inputs, same findings. JSON output you can pipe to `jq` or feed to any tool. - **Custom rules** — Define your own patterns in `.rafter.yml`. They work exactly like built-in rules — same JSON output, same audit log, same pre-commit enforcement. - **Policy enforcement** — 4-tier risk classification (critical/high/medium/low). Blocks `rm -rf /`, requires approval for `sudo rm`, allows `npm install`. Same rules for every developer. - **Extension auditing** — Scans third-party extensions for embedded secrets, suspicious URLs, and obfuscated commands before you install them. @@ -53,8 +53,8 @@ Would love feedback on the pattern library, custom rule authoring, and which pla Founder should be in comments within 5 minutes of posting. Prepared answers for likely questions: -**Q: "Why not just use Gitleaks directly?"** -A: Rafter uses Gitleaks when available but adds policy enforcement, custom rules, extension auditing, multi-platform config, and MCP. It's the integration layer, not a replacement. +**Q: "Why not just use Betterleaks (or Gitleaks) directly?"** +A: Rafter uses Betterleaks when available but adds policy enforcement, custom rules, extension auditing, multi-platform config, and MCP. It's the integration layer, not a replacement. **Q: "How is this different from pre-commit hooks?"** A: Pre-commit catches secrets at commit time. Rafter also enforces policy on live commands, audits extensions, and works at runtime — not just commit time.