diff --git a/README.md b/README.md index 05ef874a..6689a1ab 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Requires Python 3.10+. Full feature parity with Node.js including local security Agentic security audits backed by a full SAST/SCA toolchain, via the Rafter API. The analysis engine examines your codebase the way a professional cybersecurity auditor would — following data flows across files, reasoning about authentication and authorization logic, and identifying vulnerabilities that pattern-matching alone cannot catch — then validates and enriches findings with industry-standard static analysis, dependency scanning, and secret detection. Runs against the **remote repository** on GitHub, not local files. Your code is deleted immediately after analysis completes. Auto-detection uses your local Git config to determine which repo and branch to analyze. ```sh -export RAFTER_API_KEY="your-key" # or use .env file +export RAFTER_API_KEY="your-key" rafter run # scan current repo (auto-detected) rafter scan --repo myorg/myrepo --branch main # scan specific repo @@ -153,7 +153,7 @@ rafter get SCAN_ID > scan_results.json 1. Sign up at [rafter.so](https://rafter.so) 2. Dashboard → Settings → API Keys -3. `export RAFTER_API_KEY="your-key"` or add to `.env` +3. `export RAFTER_API_KEY="your-key"` --- diff --git a/node/src/index.ts b/node/src/index.ts index 4e76204a..655b9c55 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -24,7 +24,7 @@ import { checkForUpdate } from "./utils/update-checker.js"; import { setAgentMode } from "./utils/formatter.js"; import { createRequire } from "module"; -// rf-7dda: a repo `.env` must not be able to disable the hook or its timeouts. +// A repo `.env` must not be able to set any RAFTER_* variable (key, token, disables). guardSecurityEnvFromDotenv(() => dotenv.config()); const require = createRequire(import.meta.url); diff --git a/node/src/utils/env-guard.ts b/node/src/utils/env-guard.ts index 27804b16..8847aea8 100644 --- a/node/src/utils/env-guard.ts +++ b/node/src/utils/env-guard.ts @@ -1,22 +1,22 @@ /** - * Security-control env vars must never be settable by a project `.env`. + * A project `.env` must never supply a rafter setting. * * `dotenv.config()` runs at CLI startup (index.ts) and, with no path, loads - * `$CWD/.env` — which, when rafter runs inside an agent hook on a cloned repo, - * is a file IN THE UNTRUSTED REPOSITORY. dotenv does not override a variable - * already present in the real environment, but it DOES introduce one that was - * unset — so a repo shipping `RAFTER_DISABLE_HOOKS=1` (or any `RAFTER_DISABLE_*` - * / `RAFTER_HOOK_*` value) could switch off the victim's command policy and - * secret scanning. That defeats the control whose own contract (hook-control.ts) - * says the disable signal is honored only from the machine owner's environment. + * `$CWD/.env` — which, when rafter runs inside an agent hook or a scan on a + * cloned repo, is a file IN THE UNTRUSTED REPOSITORY. dotenv does not override + * a variable already present in the real environment, but it DOES introduce one + * that was unset. Every `RAFTER_*` variable is an operator setting: the disable + * switches and hook timeouts, but also the API key (which outranks the key the + * operator stored in ~/.rafter/config.json), the GitHub token, the notify + * webhook and the paid-scan confirmation. None of them may come from the repo + * being scanned. * - * This runs dotenv, then drops any `RAFTER_DISABLE_*` / `RAFTER_HOOK_*` variable - * that was NOT already set in the real environment before dotenv ran. The - * owner's real values are preserved untouched; legitimate `.env` keys that do - * not match those prefixes (RAFTER_API_KEY, RAFTER_GITHUB_TOKEN, …) are - * unaffected. rf-7dda / sable-nz4y sibling. + * This runs dotenv, then drops any `RAFTER_*` variable that was NOT already set + * in the real environment before dotenv ran. The owner's real values are + * preserved untouched. This matches the Python runtime, which never reads the + * working directory's `.env`. */ -const PROTECTED_PREFIX = /^RAFTER_(DISABLE_|HOOK_)/; +const PROTECTED_PREFIX = /^RAFTER_/; export function guardSecurityEnvFromDotenv( applyDotenv: () => void, diff --git a/node/tests/e2e-cli.test.ts b/node/tests/e2e-cli.test.ts index 44f4919b..7a356746 100644 --- a/node/tests/e2e-cli.test.ts +++ b/node/tests/e2e-cli.test.ts @@ -380,17 +380,16 @@ describe("CLI e2e — dotenv loading", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it("loads RAFTER_API_KEY from .env file in cwd", () => { - // Write a .env file with a fake API key + it("ignores RAFTER_API_KEY from a .env file in cwd", () => { + // The working directory may be an untrusted cloned repo: its .env must not + // supply the operator's credential. With no key anywhere else, the CLI + // must report the key as missing rather than use the repo's. fs.writeFileSync(path.join(tmpDir, ".env"), "RAFTER_API_KEY=test-key-from-dotenv\n"); - // Run from tmpDir WITHOUT setting RAFTER_API_KEY in env — let .env provide it. - // The usage command will attempt to call the API (and fail), but it should NOT - // complain about a missing API key since .env provides one. - const envWithoutKey = { ...process.env }; + const envWithoutKey = { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir }; delete envWithoutKey.RAFTER_API_KEY; const r = rafter("usage", { cwd: tmpDir, env: envWithoutKey as Record }); const combined = (r.stdout + r.stderr).toLowerCase(); - expect(combined).not.toContain("no api key"); + expect(combined).toContain("no api key"); }, 30000); }); diff --git a/node/tests/env-guard.test.ts b/node/tests/env-guard.test.ts index 9294184a..b0b8280c 100644 --- a/node/tests/env-guard.test.ts +++ b/node/tests/env-guard.test.ts @@ -29,14 +29,23 @@ describe("guardSecurityEnvFromDotenv (rf-7dda)", () => { expect(hookEnabled(env)).toBe(false); }); - it("preserves a legitimate non-security .env key (RAFTER_API_KEY)", () => { - const env: any = {}; + it("drops repo-.env credentials and approvals; the operator's real values survive", () => { + const env: any = { RAFTER_GITHUB_TOKEN: "ghp-operator" }; guardSecurityEnvFromDotenv( - () => applyDotenv({ RAFTER_API_KEY: "sk-legit", RAFTER_DISABLE_HOOKS: "1" }, env), + () => applyDotenv({ + RAFTER_API_KEY: "repo-key", + RAFTER_GITHUB_TOKEN: "ghp-repo", + RAFTER_CONFIRM: "1", + RAFTER_NOTIFY_WEBHOOK: "https://example.invalid/hook", + UNRELATED: "kept", + }, env), env, ); - expect(env.RAFTER_API_KEY).toBe("sk-legit"); - expect(hookEnabled(env)).toBe(true); + expect(env.RAFTER_API_KEY).toBeUndefined(); + expect(env.RAFTER_CONFIRM).toBeUndefined(); + expect(env.RAFTER_NOTIFY_WEBHOOK).toBeUndefined(); + expect(env.RAFTER_GITHUB_TOKEN).toBe("ghp-operator"); + expect(env.UNRELATED).toBe("kept"); }); it("drops the fail-open RAFTER_HOOK_STDIN_TIMEOUT_MS and every sub-part disable", () => { diff --git a/python/README.md b/python/README.md index 7519c532..fc32a4a1 100644 --- a/python/README.md +++ b/python/README.md @@ -21,7 +21,7 @@ Requires Python 3.10+. ### Remote Code Analysis ```bash -export RAFTER_API_KEY="your-key" # or add to .env file +export RAFTER_API_KEY="your-key" rafter run # scan current repo (auto-detected) rafter scan --repo myorg/myrepo --branch main # scan specific repo diff --git a/python/rafter_cli/utils/api.py b/python/rafter_cli/utils/api.py index ee4ccb36..6d4e5fcb 100644 --- a/python/rafter_cli/utils/api.py +++ b/python/rafter_cli/utils/api.py @@ -7,7 +7,6 @@ import requests import typer -from dotenv import load_dotenv API_BASE = "https://rafter.so/api/" @@ -133,7 +132,9 @@ def resolve_key(cli_opt: str | None) -> str: """Resolve API key: --api-key flag > RAFTER_API_KEY env > global config.""" if cli_opt: return cli_opt - load_dotenv() + # No .env loading here: python-dotenv searches upward from this package's + # install path, which for a virtualenv inside a cloned repo reaches the + # repo's own .env. A repo must never supply the operator's credential. env_key = os.getenv("RAFTER_API_KEY") if env_key: return env_key diff --git a/python/tests/test_config_secret_handling.py b/python/tests/test_config_secret_handling.py index dd1168a9..05b6e974 100644 --- a/python/tests/test_config_secret_handling.py +++ b/python/tests/test_config_secret_handling.py @@ -90,3 +90,15 @@ def test_env_over_config(self, home, monkeypatch): def test_global_config_used_when_no_flag_or_env(self, home): # No longer a dead path. assert resolve_key(None) == "CONFIG-key" + + def test_dotenv_cannot_supply_key(self, home, monkeypatch): + # A .env that python-dotenv's search would find (for example in a + # cloned repo that also holds the virtualenv) must not outrank the + # operator's stored key. + dotenv_file = home / "repo.env" + dotenv_file.write_text("RAFTER_API_KEY=REPO-key\n") + monkeypatch.setattr("dotenv.main.find_dotenv", lambda *a, **k: str(dotenv_file)) + # Register RAFTER_API_KEY for restore even if a load sets it. + monkeypatch.setenv("RAFTER_API_KEY", "placeholder") + monkeypatch.delenv("RAFTER_API_KEY") + assert resolve_key(None) == "CONFIG-key" diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index 30f59668..34a8fbe0 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -1459,7 +1459,7 @@ rafter agent config set agent.riskLevel aggressive ## Notes -- API key: provided via `--api-key` flag, `RAFTER_API_KEY` env var, or `.env` file +- API key: provided via `--api-key` flag, `RAFTER_API_KEY` env var, or a key stored in the global `~/.rafter/config.json`. A `.env` file in the working directory is never read for `RAFTER_*` settings - Git auto-detection works in CI (supports `GITHUB_REPOSITORY`, `GITHUB_REF_NAME`, `CI_REPOSITORY`, `CI_COMMIT_BRANCH`, `CI_BRANCH`) - Remote code analysis targets the remote repository, not local files - All scan data to stdout, all status messages to stderr