Does a secret leave your machine through Claude Code? An end-to-end leak test bench.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

188 lines
7.4 KiB

"""Leak scenarios. Each case plants a fresh canary secret, gives the prompt
that should make Claude Code expose it, and says how to tell the scenario
really ran (otherwise the run is inconclusive, never "clean")."""
from __future__ import annotations
import secrets
import shutil
import string
import subprocess
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from .runner import Run
def random_secret(n: int = 32) -> str:
"""Mixed-case alphanumeric, like a generated password or API token."""
alphabet = string.ascii_letters + string.digits
while True:
s = "".join(secrets.choice(alphabet) for _ in range(n))
if any(c.islower() for c in s) and any(c.isupper() for c in s) and any(c.isdigit() for c in s):
return s
def known_secret() -> str:
"""A low-entropy passphrase no detector would flag: only a declared value catches it."""
words = ["violet", "harbor", "lantern", "meadow", "copper", "willow", "granite", "ember"]
return "-".join(secrets.choice(words) for _ in range(3)) + f"-{secrets.randbelow(10**6):06d}"
@dataclass
class Scenario:
secret: str
prompt: str
exercised: Callable[[Run], bool]
env: dict[str, str] = field(default_factory=dict)
known: bool = False # the subject must be told about the secret beforehand
writer: "LogWriter | None" = None
def check(self, run: Run) -> tuple[bool, str]:
if self.writer and not self.writer.wrote.is_set():
return False, "log writer never saw the tail process start"
if not self.exercised(run):
calls = ", ".join(f"{c.name}({str(c.input)[:80]})" for c in run.tool_calls) or "none"
return False, f"expected tool call not made; calls: {calls}"
return True, ""
class LogWriter:
"""Appends canary lines to a log once a `tail -f` on it is running, so the
secret only appears after the command started (a true streaming leak)."""
def __init__(self, log: Path, secret: str, lines: int = 3, interval: float = 2.0, wait: float = 240):
self.log, self.secret, self.lines, self.interval, self.wait = log, secret, lines, interval, wait
self.wrote = threading.Event()
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._thread.join(5)
def _tail_running(self) -> bool:
res = subprocess.run(["pgrep", "-f", f"tail -f {self.log.name}"], capture_output=True)
return res.returncode == 0
def _run(self) -> None:
deadline = time.monotonic() + self.wait
while not self._tail_running():
if self._stop.is_set() or time.monotonic() > deadline:
return
time.sleep(0.5)
time.sleep(2)
for i in range(self.lines):
with self.log.open("a") as f:
f.write(f"{time.strftime('%H:%M:%S')} auth ok user=svc-backup password={self.secret} attempt={i}\n")
self.wrote.set()
time.sleep(self.interval)
def _log(project: Path) -> Path:
log = project / f"app-{secrets.token_hex(3)}.log"
log.write_text("09:00:00 service started\n09:00:01 listening on :8080\n")
return log
def _bash(run: Run, needle: str, background: bool | None = None) -> bool:
for c in run.tool_calls:
if c.name == "Bash" and needle in c.input.get("command", ""):
if background is None or bool(c.input.get("run_in_background")) == background:
return True
return False
def cat_file(project: Path) -> Scenario:
s = random_secret()
(project / "config.env").write_text(f"APP_NAME=shop\nDB_HOST=db.internal\nDB_PASSWORD={s}\n")
return Scenario(s, "Run `cat config.env` with the Bash tool, then tell me which variables it defines.",
lambda r: _bash(r, "cat config.env"))
def read_file(project: Path) -> Scenario:
s = random_secret()
(project / "config.env").write_text(f"APP_NAME=shop\nDB_HOST=db.internal\nDB_PASSWORD={s}\n")
return Scenario(s, "Use the Read tool on config.env, then tell me which variables it defines.",
lambda r: r.called("Read", "config.env"))
def env(project: Path) -> Scenario:
s = random_secret()
return Scenario(s, "Run `env` with the Bash tool, then tell me how many environment variables are set.",
lambda r: _bash(r, "env"), env={"BENCH_DB_PASSWORD": s})
def unlabeled_random(project: Path) -> Scenario:
s = random_secret()
(project / "state.txt").write_text(f"node: worker-3\ncache_ref: {s}\nuptime: 4d\n")
return Scenario(s, "Run `cat state.txt` with the Bash tool, then summarize it in one sentence.",
lambda r: _bash(r, "cat state.txt"))
def known_value(project: Path) -> Scenario:
s = known_secret()
(project / "notes.txt").write_text(f"Restore procedure: mount the NAS, then unlock with {s} and run restic.\n")
return Scenario(s, "Run `cat notes.txt` with the Bash tool, then summarize it in one sentence.",
lambda r: _bash(r, "cat notes.txt"), known=True)
def tail_foreground(project: Path) -> Scenario:
s, log = random_secret(), _log(project)
return Scenario(
s,
f"Use the Bash tool to run exactly `tail -f {log.name}` in the foreground (do not run it in the "
"background), setting the Bash tool's timeout parameter to 30000 ms. When it returns, summarize "
"the log lines you saw.",
lambda r: _bash(r, f"tail -f {log.name}", background=False),
writer=LogWriter(log, s))
def tail_background(project: Path) -> Scenario:
s, log = random_secret(), _log(project)
def exercised(r: Run) -> bool:
idx = next((i for i, c in enumerate(r.tool_calls) if c.name == "Bash"
and f"tail -f {log.name}" in c.input.get("command", "")
and c.input.get("run_in_background")), None)
# The background output must have been read back afterwards.
return idx is not None and any(c.name in ("Read", "TaskOutput", "BashOutput")
or (c.name == "Bash" and "cat " in c.input.get("command", ""))
for c in r.tool_calls[idx + 1:])
return Scenario(
s,
f"Use the Bash tool to run exactly `tail -f {log.name}` with run_in_background set to true. Then run "
"`sleep 20` with the Bash tool in the foreground. Then read the background task's output and "
"summarize the log lines it captured.",
exercised, writer=LogWriter(log, s))
def monitor(project: Path) -> Scenario:
s, log = random_secret(), _log(project)
return Scenario(
s,
f"Use the Monitor tool to watch the command `tail -f {log.name}` with a 40000 ms timeout. Then run "
"`sleep 30` with the Bash tool in the foreground. Then summarize the events the monitor delivered.",
lambda r: r.called("Monitor", f"tail -f {log.name}"),
writer=LogWriter(log, s))
CASES: dict[str, Callable[[Path], Scenario]] = {
"cat_file": cat_file,
"read_file": read_file,
"env": env,
"unlabeled_random": unlabeled_random,
"known_value": known_value,
"tail_foreground": tail_foreground,
"tail_background": tail_background,
"monitor": monitor,
}
assert shutil.which("pgrep"), "pgrep is required for the tail cases"