Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`

---

Expand Down
2 changes: 1 addition & 1 deletion node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 14 additions & 14 deletions node/src/utils/env-guard.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
13 changes: 6 additions & 7 deletions node/tests/e2e-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> });
const combined = (r.stdout + r.stderr).toLowerCase();
expect(combined).not.toContain("no api key");
expect(combined).toContain("no api key");
}, 30000);
});

Expand Down
19 changes: 14 additions & 5 deletions node/tests/env-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions python/rafter_cli/utils/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import requests
import typer
from dotenv import load_dotenv

API_BASE = "https://rafter.so/api/"

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions python/tests/test_config_secret_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion shared-docs/CLI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading