diff --git a/.gitignore b/.gitignore index d5441c0..20132fd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ Makefile.in /config.log /config.status /codebud +/codebud-sec /codebud-*.tar.gz /codebud-*.tar.gz.sig /codebud-*.tar.gz.sha256 diff --git a/Makefile.am b/Makefile.am index e3cb322..c3c60e8 100644 --- a/Makefile.am +++ b/Makefile.am @@ -11,13 +11,17 @@ SUBDIRS = src docs # ─── The codebud command ───────────────────────────────────────────────────── # A small POSIX sh launcher for the configured Python and package directory. -bin_SCRIPTS = codebud -CLEANFILES = codebud +bin_SCRIPTS = codebud codebud-sec +CLEANFILES = codebud codebud-sec -codebud: $(srcdir)/src/launcher.in Makefile +# codebud runs the codebud package, codebud-sec the security layer. +codebud codebud-sec: $(srcdir)/src/launcher.in Makefile $(AM_V_GEN)rm -f $@ $@.tmp && \ + module=`echo $@ | sed 's/-/_/'` && \ $(SED) -e 's|@PYTHON[@]|$(PYTHON)|g' \ -e 's|@pythondir[@]|$(pythondir)|g' \ + -e "s|@MODULE[@]|$$module|g" \ + -e 's|@COMMAND[@]|$@|g' \ $(srcdir)/src/launcher.in > $@.tmp && \ chmod +x $@.tmp && mv $@.tmp $@ diff --git a/NEWS b/NEWS index c35c201..8cfb1c0 100644 --- a/NEWS +++ b/NEWS @@ -85,6 +85,17 @@ a safety policy you can read and change. exploitation tools, no files sent to a target. A profile requires an engagement with engagement = true. + codebud-sec, the security layer's command: codebud-sec chat|run|plan + --engagement FILE runs codebud inside an engagement -- its scope guard + and network fence active, every step recorded before it runs and after + it ends in an encrypted, hash-chained evidence log in codebud's data + directory, out of the steps' reach; a later session must use the same + engagement file. codebud-sec evidence add|verify|show|close keeps the + log; codebud-sec pin is codebud integrity pin. Scanning steps run + behind a network fence (pasta and nftables, optional): only their + targets are reachable, no DNS, nothing of the machine itself. A new + manual chapter, "Security Work". + A profile can ask for observer isolation (isolation = "observer"): command steps see the host's processes through a read-only /proc, for live triage, but still cannot signal them or reach the network. @@ -107,5 +118,5 @@ a safety policy you can read and change. Alpha: the command line, configuration and data formats may still change before 1.0. Isolation needs bubblewrap and user namespaces (GNU/Linux); elsewhere steps run unisolated with a warning. The - security layer (codebud_sec: engagement file, evidence log) ships but - is not used yet. + security layer (codebud_sec) is for authorized work only and is used + through codebud-sec; its network fence needs pasta (passt) and nft. diff --git a/README.md b/README.md index d613cc3..0d35b54 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,24 @@ codebud integrity pin # record what is installed n codebud integrity verify # what changed since (exit 1 if anything) ``` +## Security work + +For authorized security work only -- incident response on hosts you own, +penetration tests you are contracted to run -- `codebud-sec` runs codebud +inside an engagement: a TOML file with the client, the in-scope and +excluded targets and the time window. Every network target a step names is +checked against it (out of scope is refused), scanning steps run where the +network itself lets through only their targets, and every step goes into +an encrypted, hash-chained evidence log. + +```bash +codebud-sec chat --engagement acme.toml # security-pentest profile +codebud-sec evidence add disk.E01 --engagement acme.toml +codebud-sec evidence verify --engagement acme.toml # record the head hash elsewhere +``` + +See "Security Work" in the manual. + ## Project search Index a project once and every request gets its most relevant code as @@ -151,14 +169,15 @@ make pdf # → docs/codebud.pdf Topics covered: Introduction · Installation · Model Management · LLM Execution · Performance Benchmarking · Usage · Configuration · -Architecture · Tools Reference · Safety & Sandbox · Development · +Architecture · Tools Reference · Safety & Sandbox · Privacy · Security Work · +Development · Troubleshooting · Glossary. ## Project layout ``` src/codebud/ The package: agent, CLI (cli/), tools, default policy -src/codebud_sec/ Security layer (engagement file, evidence log); not used yet +src/codebud_sec/ Security layer: codebud-sec, engagements, scope, evidence log docs/ GNU Texinfo manual scripts/ Developer scripts (benchmark, search and plan evals) tests/ Test suite (pytest) diff --git a/docs/Makefile.am b/docs/Makefile.am index 5105379..9607412 100644 --- a/docs/Makefile.am +++ b/docs/Makefile.am @@ -17,6 +17,7 @@ codebud_TEXINFOS = \ chapters/model-management.texi \ chapters/performance-benchmarking.texi \ chapters/privacy.texi \ + chapters/security.texi \ chapters/safety-and-sandbox.texi \ chapters/tools-reference.texi \ chapters/troubleshooting.texi \ diff --git a/docs/chapters/architecture.texi b/docs/chapters/architecture.texi index 5684108..7c246bd 100644 --- a/docs/chapters/architecture.texi +++ b/docs/chapters/architecture.texi @@ -637,6 +637,8 @@ src/codebud/ the package └── host_network.py host_network (observer profiles only) src/codebud_sec/ the security layer (authorized security work) +├── cli.py the codebud-sec command: sessions inside an +│ engagement, evidence add/verify/show/close, pin ├── engagement.py engagement file: authorized scope ├── scope.py scope guard: every network target read from the │ command line and checked (DNS tools, whois, diff --git a/docs/chapters/development.texi b/docs/chapters/development.texi index cc11514..add5147 100644 --- a/docs/chapters/development.texi +++ b/docs/chapters/development.texi @@ -55,6 +55,8 @@ tests/ ├── test_chat_input.py / menu, toolbar, Shift-Tab (prompt_toolkit) ├── test_cli.py subcommands, doctor, display, chat REPL, │ history, sessions, modes, run --yes +├── test_codebud_sec.py codebud-sec: guard, fence and recorder on +│ for a session only; evidence commands; pin ├── test_confine.py file tools: project-only writes, no .git/ │ .codebud/, no secret reads, symlinks, display ├── test_core.py AgentCore: models, /compact, context usage diff --git a/docs/chapters/safety-and-sandbox.texi b/docs/chapters/safety-and-sandbox.texi index 77a21c1..51412c5 100644 --- a/docs/chapters/safety-and-sandbox.texi +++ b/docs/chapters/safety-and-sandbox.texi @@ -230,7 +230,11 @@ is registered this way, so the core never imports that layer. A check can say what it @emph{provides} (@code{add_step_check(check, provides="engagement")}), and a profile with @code{engagement = true} refuses command and web search steps while no registered check provides -an engagement (@pxref{Profiles}). +an engagement (@pxref{Profiles}). A layer can also register a step +@emph{recorder} (@code{add_step_recorder()}): the executor tells it about +every step before it runs, after it ends and when it is refused, and a +step whose start cannot be recorded does not run --- the security +layer's evidence log is kept this way. @code{describe_rules()} returns a compact summary of the three policy lists, which is embedded in the planner prompt so the model can avoid diff --git a/docs/chapters/security.texi b/docs/chapters/security.texi new file mode 100644 index 0000000..0660aac --- /dev/null +++ b/docs/chapters/security.texi @@ -0,0 +1,185 @@ +@node Security Work +@chapter Security Work +@cindex security work +@cindex codebud-sec + +Codebud's security layer (@file{codebud_sec}, part of the Codebud +distribution) is for @emph{authorized} security work: incident response +on hosts you own or are authorized to examine, and penetration tests you +are contracted to run. It adds an engagement file that states what was +authorized, a command, @command{codebud-sec}, that keeps every step to +it, and an evidence log that records every step. Nothing here grants +access to anything; it only states, and helps keep to, what was already +authorized. Use it only within a written authorization (rules of +engagement, a signed scope, an incident ticket). + +@menu +* Security Work at a Glance:: What codebud-sec adds to codebud. +* The Engagement File:: Client, scope, window, fence. +* Running codebud-sec:: Sessions, evidence, pinning. +* The Evidence Log:: What is recorded and how to check it. +* What Security Work Does Not Cover:: The limits, plainly. +@end menu + + +@node Security Work at a Glance +@section Security Work at a Glance + +@multitable @columnfractions .32 .68 +@headitem Piece @tab What it does +@item Security profiles +@tab @code{security-ir} (incident response: read-only triage and +forensics, no network) and @code{security-pentest} (reconnaissance and +scanning of in-scope hosts); @pxref{Profiles}. +@item Engagement file +@tab A TOML file naming the client, the in-scope and excluded targets and +the time window. +@item Scope guard +@tab Reads every host a network step will contact from its command line +and refuses the step when one is out of scope, excluded, or the window +is closed (lookups out of scope ask instead). A command it cannot read +is refused. +@item Network fence +@tab Runs scanning steps where the network itself lets through only +their targets (@pxref{Isolated Steps}). +@item Evidence log +@tab An encrypted, hash-chained, append-only record of every step, before +it runs and after it ends, refused steps too. +@item Integrity +@tab Both profiles refuse to start without a pinned manifest of the +models and programs they run (@pxref{Integrity Checks}). +@end multitable + +The security profiles need an encryption key (@command{codebud key +init}) and a pinned manifest (@command{codebud-sec pin}). +@code{security-pentest} runs no command until an engagement is open, so +it is used through @command{codebud-sec}. + + +@node The Engagement File +@section The Engagement File +@cindex engagement file + +@example +client = "ACME Ltd" +ticket = "PT-2026-042" +operator = "A. Analyst" +in_scope = ["10.20.0.0/16", "app.acme.example", "2001:db8:42::/48"] +out_of_scope = ["10.20.5.0/24", "admin.app.acme.example"] +starts = 2026-11-16T08:00:00Z +ends = 2026-11-20T18:00:00Z +fence_scanning = true +notes = "Production: no denial of service; scans 08:00-18:00 only." +@end example + +@table @code +@item in_scope +Networks (CIDR), addresses and names. A name covers its subdomains +(@code{app.acme.example} covers @code{api.app.acme.example}). Required. +@item out_of_scope +Excluded networks, addresses and names; they win over @code{in_scope}. +A network is in scope only when all of it is. +@item starts, ends +The window the work is authorized for (UTC unless an offset is given). +Outside it, active steps are refused. +@item fence_scanning +@code{true} (the default) runs scanning steps behind the network fence; +@code{false} gives raw scans (nmap SYN, UDP and OS scans, masscan) back, +with the scope guard as their only check. +@item client, ticket, operator, notes +Recorded in the evidence log. +@end table + +A name is never matched against an address, and the guard never +resolves a name to decide scope; the fence resolves the names a step +uses just before it runs. + + +@node Running codebud-sec +@section Running codebud-sec + +@example +codebud-sec chat --engagement acme.toml +codebud-sec run --engagement acme.toml "scan the web tier for open ports" +codebud-sec plan --engagement acme.toml --profile security-ir "..." +@end example + +@noindent +@command{chat}, @command{run} and @command{plan} open the engagement and +then run codebud itself, with the @code{security-pentest} profile unless +@option{--profile} names another. Any other option is passed on to +codebud (@option{--resume}, @option{-m}, @dots{}). For the whole session +the scope guard and the network fence are active and every step is +recorded; when it ends they are taken down. Outside the window, +@command{codebud-sec} says so at the start and active steps are refused. + +@example +codebud-sec evidence add disk.E01 rules-of-engagement.pdf \ + -m "image received from the client" --engagement acme.toml +codebud-sec evidence verify [--files] --engagement acme.toml +codebud-sec evidence show --engagement acme.toml +codebud-sec evidence close --engagement acme.toml +codebud-sec pin +@end example + +@noindent +@command{evidence add} records files with their SHA-256 (and a note). +@command{verify} checks the hash chain, and with @option{--files} that +the recorded files are unchanged. @command{show} lists the entries. +@command{close} ends the log: nothing can be added after it. +@command{pin} is @command{codebud integrity pin}. + +The log is kept in Codebud's data directory, +@file{~/.local/share/codebud/evidence/-.jsonl}, one per +engagement file: steps never see that directory, so no step can change +the log. @option{--evidence LOG} names another place --- never one +inside the project, which steps may write. When a later session or +@command{evidence add} reopens a log, its whole chain is checked (the +passphrase is asked for that), and the engagement file must be the one +the log was opened with: a changed scope needs a new log. + + +@node The Evidence Log +@section The Evidence Log +@cindex evidence log + +The log is a JSON Lines file, one entry per line, each holding the hash +of the one before; editing, removing or reordering an entry breaks the +chain. The first entry names the engagement file with its SHA-256, so +the log says exactly what was authorized. Each session adds a +@code{start} and an @code{end} note; each step a @code{step} entry before +it runs (the tool, its arguments, the description) and one after it ends +(its exit status and output); a refused step a @code{warning} with the +reason. A step whose start cannot be recorded does not run. Output or +arguments longer than 64 KiB are kept as their SHA-256, size and +beginning. + +The log is encrypted at rest with your key: each entry is sealed, and +only its sequence number, kind and the two hashes stay readable, so that +appending needs no passphrase. Reading and a full @command{verify} ask +for it. + +@cindex head hash +What the chain proves is relative to its head: anyone who can write the +file could rewrite it consistently. @command{verify} prints the head +hash --- record it outside the log (the case notes, a ticket, a signed +message), and the log then shows any change made before that point. + + +@node What Security Work Does Not Cover +@section What Security Work Does Not Cover + +@itemize +@item Authorization. The engagement file states it; it does not grant +it, and Codebud cannot tell whether it is true. +@item Names. The client's DNS decides what an in-scope name resolves to, +and an address shared with other sites (a CDN) is reachable while a step +that names it runs. +@item Lookups. @command{dig}, @command{host} and @command{whois} are not +fenced; out of scope they ask rather than refuse. +@item Raw scans. With @code{fence_scanning = false}, scanning relies on +the scope guard reading the command line. +@item The machine itself. The evidence log and the integrity checks +detect changes made through Codebud and to the files they cover; a +compromised operating system can defeat both (RFC 3227, section 5). +@end itemize diff --git a/docs/codebud.texi b/docs/codebud.texi index 9df6dd7..5716e3b 100644 --- a/docs/codebud.texi +++ b/docs/codebud.texi @@ -57,6 +57,7 @@ This manual is divided into the following chapters: * Tools Reference:: Every built-in tool documented. * Safety and Sandbox:: How Codebud limits what the LLM can do. * Privacy:: What stays on your machine, and how to check. +* Security Work:: Engagements, codebud-sec and the evidence log. * Development:: Adding tools, running tests, iterating on prompts. * Troubleshooting:: Common failures and how to fix them. * Glossary:: Key terms defined. @@ -74,6 +75,7 @@ This manual is divided into the following chapters: @include chapters/tools-reference.texi @include chapters/safety-and-sandbox.texi @include chapters/privacy.texi +@include chapters/security.texi @include chapters/development.texi @include chapters/troubleshooting.texi @include chapters/glossary.texi diff --git a/pyproject.toml b/pyproject.toml index 656b57f..e79f630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dev = [ [project.scripts] codebud = "codebud.cli.main:main" +codebud-sec = "codebud_sec.cli:main" [project.urls] Homepage = "https://github.com/mkhellat/codebud" diff --git a/src/Makefile.am b/src/Makefile.am index f042f93..177dc34 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -61,6 +61,8 @@ nobase_python_PYTHON = \ codebud/tools/tool_registry.py \ codebud/tools/web_search.py \ codebud_sec/__init__.py \ + codebud_sec/__main__.py \ + codebud_sec/cli.py \ codebud_sec/engagement.py \ codebud_sec/evidence.py \ codebud_sec/fence.py \ diff --git a/src/codebud/cli/errors.py b/src/codebud/cli/errors.py index 6b7fd3b..c3d3606 100644 --- a/src/codebud/cli/errors.py +++ b/src/codebud/cli/errors.py @@ -103,6 +103,14 @@ "The active agent mode does not allow this tool.", "Switch the mode: /mode ask in chat, or --mode ask.", ), + # Scope before policy: an engagement's refusal is reported as the policy's. + ( + "engagement scope", + "A plan step reaches outside the open engagement, or its targets cannot be " + "read; the reason says which.", + "Rephrase the request so each step names only in-scope targets and meets the " + "reason given. The scope is the engagement file's: no allow-list can widen it.", + ), ( "safety policy", "A plan step uses a command or argument the safety policy does not allow.", diff --git a/src/codebud/executor.py b/src/codebud/executor.py index 6335608..b894266 100644 --- a/src/codebud/executor.py +++ b/src/codebud/executor.py @@ -22,6 +22,8 @@ from typing import Any +from codebud import safety as _safety + class Executor: """ @@ -80,14 +82,22 @@ def execute_plan(self, plan_output: dict[str, Any]) -> dict[str, Any]: if self.safety is not None: allowed, reason = self.safety.may_execute(step) if not allowed: + _safety.record_step("refused", step, {"error": reason}) return {"status": "step_refused", "error": reason, "step": step} + # A step that cannot be recorded does not run (step recorders, + # safety.py: the evidence log of an engagement). + unrecorded = _safety.record_step("start", step) + if unrecorded: + return {"status": "step_refused", "error": unrecorded, "step": step} + tool = self.tool_registry.get(tool_name) # Execute the tool try: tool_result = tool.run(args) except Exception as e: + _safety.record_step("done", step, {"error": f"crashed: {e}"}) return { "status": "step_error", "error": f"Tool '{tool_name}' crashed: {e}", @@ -96,6 +106,7 @@ def execute_plan(self, plan_output: dict[str, Any]) -> dict[str, Any]: # Validate tool result structure if not self._validate_tool_result(tool_result): + _safety.record_step("done", step, {"error": "malformed result"}) return { "status": "step_error", "error": f"Tool '{tool_name}' returned malformed result", @@ -112,6 +123,10 @@ def execute_plan(self, plan_output: dict[str, Any]) -> dict[str, Any]: # Add memory snapshot self.memory.add_snapshot(step, results[step_id]) + # Recorded after it ran; a failure here is reported with the step. + late = _safety.record_step("done", step, results[step_id]) + if late: + results[step_id]["stderr"] += f"\ncodebud: {late}" # Stop on error if tool_result.get("returncode", 0) != 0: diff --git a/src/codebud/safety.py b/src/codebud/safety.py index 811ee7c..07bb0d6 100644 --- a/src/codebud/safety.py +++ b/src/codebud/safety.py @@ -500,6 +500,35 @@ def provided(what: str) -> bool: return any(_PROVIDES.get(check) == what for check in _STEP_CHECKS) +# Step recorders (the security layer's evidence log): told about every +# step the executor runs or refuses. ``recorder(event, step, result)``, +# event "start" (before the step runs: a recorder that fails then keeps +# the step from running, so nothing runs unrecorded), "done" (with its +# result) or "refused" (result: {"error": why}). +_STEP_RECORDERS: list = [] + + +def add_step_recorder(recorder) -> None: + _STEP_RECORDERS.append(recorder) + + +def remove_step_recorder(recorder) -> None: + if recorder in _STEP_RECORDERS: + _STEP_RECORDERS.remove(recorder) + + +def record_step( + event: str, step: dict[str, Any], result: dict[str, Any] | None = None +) -> str | None: + """Tell every recorder; the first failure, as a reason, or None.""" + for recorder in list(_STEP_RECORDERS): + try: + recorder(event, step, result) + except Exception as exc: # noqa: BLE001 - a failing record is reported + return f"a step recorder failed ({type(exc).__name__}: {exc})" + return None + + def _engagement_refusal(step: dict[str, Any]) -> str | None: """A profile with ``engagement = true`` runs no command or web search unless an engagement's scope check is registered: its tools reach diff --git a/src/codebud_sec/__main__.py b/src/codebud_sec/__main__.py new file mode 100644 index 0000000..98faa76 --- /dev/null +++ b/src/codebud_sec/__main__.py @@ -0,0 +1,11 @@ +""" +codebud_sec/__main__.py + +Lets `python -m codebud_sec ...` run the codebud-sec command. +""" + +import sys + +from codebud_sec.cli import main + +sys.exit(main()) diff --git a/src/codebud_sec/cli.py b/src/codebud_sec/cli.py new file mode 100644 index 0000000..be04ff2 --- /dev/null +++ b/src/codebud_sec/cli.py @@ -0,0 +1,289 @@ +""" +codebud_sec/cli.py + +The codebud-sec command (phase 10, step 5 plan, PR 7; D14a): codebud +for authorized security work, with an engagement open. + + codebud-sec chat|run|plan --engagement FILE [--evidence LOG] [--profile NAME] ... + codebud-sec evidence add FILE... --engagement FILE [-m NOTE] + codebud-sec evidence verify|show|close --engagement FILE + codebud-sec pin [...] (codebud integrity pin) + +chat, run and plan open the engagement and then hand over to codebud +itself (codebud.cli.main) with a security profile -- security-pentest +unless --profile names another. While it runs: + +- the engagement's scope guard and network fence are active + (scope.ScopeGuard): every network target is judged, scanning steps + are fenced to their targets, and a profile with engagement = true + runs commands only now; +- every step is recorded in the engagement's evidence log before it runs + and after it ends, refused steps too (codebud.safety step recorders); + a step that cannot be recorded does not run. The log is created + encrypted in codebud's data directory, which steps never see + (evidence/-.jsonl), unless --evidence names another -- never + one inside the project, which steps may write. Later sessions reopen + it: its whole chain is checked (the key is unlocked for that), and the + engagement file must be the one the log was opened with. +""" + +import argparse +import hashlib +import os +import sys +from pathlib import Path + +from codebud import crypto + +from . import evidence +from .engagement import EngagementError, load +from .scope import ScopeGuard + +PROG = "codebud-sec" +DEFAULT_PROFILE = "security-pentest" +SESSION_COMMANDS = ("chat", "run", "plan") +# Longer text in a step or its output is kept as its SHA-256, size and head. +_TEXT_LIMIT = 64 * 1024 + + +def _fit(value): + """``value`` for the log: long text as its hash, size and beginning.""" + if isinstance(value, str) and len(value) > _TEXT_LIMIT: + raw = value.encode("utf-8", "replace") + return { + "sha256": hashlib.sha256(raw).hexdigest(), + "bytes": len(raw), + "head": value[: _TEXT_LIMIT // 4], + } + if isinstance(value, dict): + return {k: _fit(v) for k, v in value.items()} + if isinstance(value, list): + return [_fit(v) for v in value] + return value + + +class EvidenceRecorder: + """A codebud.safety step recorder writing to an evidence log.""" + + def __init__(self, log: evidence.EvidenceLog): + self.log = log + + def __call__(self, event: str, step: dict, result: dict | None) -> None: + data = { + "event": event, + "step": _fit({k: step.get(k) for k in ("id", "tool", "description", "args")}), + } + if result is not None: + data["result"] = _fit({k: v for k, v in result.items() if k != "metadata"}) + self.log.append("warning" if event == "refused" else "step", data) + + +def evidence_path(engagement_file: Path, given: str | None) -> Path: + """The evidence log: ``given``, or one per engagement file in codebud's + data directory. EvidenceError for one inside the project, which steps + may write.""" + from codebud import confine, paths + + if given: + path = Path(given) + real = Path(os.path.realpath(path)) + project = confine.project_root() + if real == project or project in real.parents: + raise evidence.EvidenceError( + f"{given} is inside the project, where steps can write; " + "keep the evidence log outside it" + ) + return path + real = os.path.realpath(engagement_file) + tag = hashlib.sha256(real.encode()).hexdigest()[:12] + return paths.data_dir() / "evidence" / f"{engagement_file.stem}-{tag}.jsonl" + + +def _open_log(engagement, path: Path) -> evidence.EvidenceLog: + """Create the log, or reopen it: its whole chain checked, and the + engagement file the same as when the log was opened.""" + if not path.exists(): + return evidence.EvidenceLog.create(path, engagement=engagement, encrypt=True) + try: + crypto.ensure_unlocked() + except crypto.CryptoError as exc: + raise evidence.EvidenceError(f"cannot check {path}: {exc}") from exc + log = evidence.EvidenceLog.open(path) + first = evidence.entries(path)[0].get("data", {}) + if first.get("engagement_sha256") != evidence.file_sha256(engagement.path): + raise evidence.EvidenceError( + f"{engagement.path} is not the engagement file {path} was opened with " + "(it changed, or it is another one); a changed scope needs a new log " + "(--evidence NEW)" + ) + return log + + +def _fail(message: str) -> int: + print(f"{PROG}: {message}", file=sys.stderr) + return 2 + + +def _session(args, rest: list[str]) -> int: + from codebud.cli.main import main as run_codebud + + try: + engagement = load(args.engagement) + except EngagementError as exc: + return _fail(str(exc)) + if not crypto.has_key(): + return _fail("the evidence log is encrypted: create a key first (codebud key init)") + try: + path = evidence_path(Path(args.engagement), args.evidence) + log = _open_log(engagement, path) + except evidence.EvidenceError as exc: + return _fail(str(exc)) + if not engagement.in_window(): + print( + f"{PROG}: the engagement window is not open now; active steps will be refused", + file=sys.stderr, + ) + print(f"{PROG}: engagement {engagement.header()}; evidence log {path}", file=sys.stderr) + + from codebud import safety + + guard, recorder = ScopeGuard(engagement), EvidenceRecorder(log) + log.append("note", {"session": "start", "command": args.command, "profile": args.profile}) + guard.activate() + safety.add_step_recorder(recorder) + argv = ["codebud", "--profile", args.profile, args.command, *rest] + saved, sys.argv = sys.argv, argv + status = 0 + try: + run_codebud() + except SystemExit as exc: + if isinstance(exc.code, int) or exc.code is None: + status = exc.code or 0 + else: # a message, which Python would have printed + print(exc.code, file=sys.stderr) + status = 1 + finally: + sys.argv = saved + safety.remove_step_recorder(recorder) + guard.deactivate() + log.append("note", {"session": "end", "status": status}) + return status + + +def _log_for(args) -> tuple[Path | None, object]: + try: + engagement = load(args.engagement) + return evidence_path(Path(args.engagement), args.evidence), engagement + except (EngagementError, evidence.EvidenceError) as exc: + return None, str(exc) + + +def _unlock() -> str | None: + try: + crypto.ensure_unlocked() + except crypto.CryptoError as exc: + return str(exc) + return None + + +def _evidence(args) -> int: + path, engagement = _log_for(args) + if path is None: + return _fail(engagement) + if args.action == "add": + missing = [f for f in args.files if not Path(f).is_file()] + if missing: + return _fail(f"not a file: {', '.join(missing)}") + try: + log = _open_log(engagement, path) + entry = log.append( + "note", {"note": args.message or "", "added": args.files}, args.files + ) + except evidence.EvidenceError as exc: + return _fail(str(exc)) + for record in entry.get("artefacts", []): + print(f"{record.get('sha256', '?')} {record.get('path', '?')}") + return 0 + if not path.exists(): + return _fail(f"no evidence log at {path}") + if args.action == "close": + try: + evidence.EvidenceLog.open(path).close({"by": PROG}) + except evidence.EvidenceError as exc: + return _fail(str(exc)) + print(f"{PROG}: closed {path}") + return 0 + problem = _unlock() + if problem: + return _fail(problem) + if args.action == "verify": + result = evidence.verify(path, check_artefacts=args.files_too) + if result.artefact_problems: + return _fail("recorded files changed: " + "; ".join(result.artefact_problems)) + if not result.ok: + return _fail(f"{path} does not verify: {result.error}") + print(f"{path}: {result.entries} entries, chain intact; head {result.head}") + print("Record the head hash outside the log: it shows any later change.") + return 0 + for entry in evidence.entries(path): + data = entry.get("data", {}) + what = data.get("event") or data.get("session") or data.get("note") or "" + step = data.get("step", {}).get("args", {}) if isinstance(data.get("step"), dict) else {} + detail = step.get("cmd") or step.get("path") or "" + print( + f"{entry.get('seq'):>5} {entry.get('time', '')} {entry.get('kind'):8} {what} {detail}" + ) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROG, description="codebud for authorized security work, with an engagement open." + ) + sub = parser.add_subparsers(dest="command", required=True) + + def engagement_options(p): + p.add_argument("--engagement", required=True, metavar="FILE", help="the engagement file") + p.add_argument("--evidence", metavar="LOG", help="the evidence log (default: beside it)") + + for name in SESSION_COMMANDS: + p = sub.add_parser(name, help=f"codebud {name} inside the engagement", add_help=False) + engagement_options(p) + p.add_argument("--profile", default=DEFAULT_PROFILE, help="a security profile") + ev = sub.add_parser("evidence", help="the engagement's evidence log") + ev_sub = ev.add_subparsers(dest="action", required=True) + add = ev_sub.add_parser("add", help="record files (their SHA-256) in the log") + add.add_argument("files", nargs="+") + add.add_argument("-m", "--message", help="a note to go with them") + engagement_options(add) + for action, text in ( + ("verify", "check the hash chain"), + ("show", "list the entries"), + ("close", "end the log"), + ): + p = ev_sub.add_parser(action, help=text) + engagement_options(p) + if action == "verify": + p.add_argument("--files", dest="files_too", action="store_true", + help="also check the recorded files' hashes") # fmt: skip + sub.add_parser("pin", help="codebud integrity pin", add_help=False) + return parser + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if argv[:1] == ["pin"]: + from codebud.cli.main import main as run_codebud + + saved, sys.argv = sys.argv, ["codebud", "integrity", "pin", *argv[1:]] + try: + run_codebud() + finally: + sys.argv = saved + return 0 + args, rest = build_parser().parse_known_args(argv) + if args.command in SESSION_COMMANDS: + return _session(args, rest) + if rest: + return _fail(f"unknown arguments: {' '.join(rest)}") + return _evidence(args) diff --git a/src/launcher.in b/src/launcher.in index 29e3a88..1f271ad 100644 --- a/src/launcher.in +++ b/src/launcher.in @@ -1,10 +1,10 @@ #!/bin/sh -# codebud — command installed by `make install` (GNU Autotools build). +# @COMMAND@ — command installed by `make install` (GNU Autotools build). # -# Runs the codebud package with the Python it was configured for. The +# Runs the @MODULE@ package with the Python it was configured for. The # package directory is put on PYTHONPATH because a --prefix install (e.g. # /usr/local) is not on every system Python's default module path. PYTHONPATH="@pythondir@${PYTHONPATH:+:$PYTHONPATH}" export PYTHONPATH -exec "@PYTHON@" -m codebud "$@" +exec "@PYTHON@" -m @MODULE@ "$@" diff --git a/tests/test_codebud_sec.py b/tests/test_codebud_sec.py new file mode 100644 index 0000000..79363df --- /dev/null +++ b/tests/test_codebud_sec.py @@ -0,0 +1,256 @@ +""" +tests/test_codebud_sec.py + +The codebud-sec command (phase 10, step 5 plan, PR 7): a session opens +the engagement -- scope guard and network fence active, every step +recorded in its encrypted evidence log -- and hands over to codebud with +a security profile; evidence add/verify/show/close keep the log; pin is +codebud integrity pin. +""" + +import pytest + +from codebud import isolation, safety +from codebud_sec import cli, evidence + +SCOPE = """\ +client = "ACME" +ticket = "PT-42" +in_scope = ["10.0.0.0/16"] +""" + + +@pytest.fixture +def case(tmp_path, monkeypatch, fast_passphrase): + from codebud import crypto + + crypto.create_key("pw") + crypto.lock() + monkeypatch.setenv("CODEBUD_PASSPHRASE", "pw") + monkeypatch.setattr(safety, "_STEP_CHECKS", []) + monkeypatch.setattr(safety, "_STEP_NOTICES", []) + monkeypatch.setattr(safety, "_STEP_RECORDERS", []) + monkeypatch.setattr(isolation, "_FENCES", []) + monkeypatch.chdir(tmp_path) + (tmp_path / "acme.toml").write_text(SCOPE) + return tmp_path + + +@pytest.fixture +def core(monkeypatch): + """codebud's own main, replaced: it records what it was started with, + what was active, and runs one step through the recorders.""" + seen = {} + + def main(): + import sys + + seen["argv"] = list(sys.argv) + seen["engagement"] = safety.provided("engagement") + seen["fenced"] = bool(isolation._FENCES) + step = { + "id": "step_0", + "tool": "command", + "description": "scan", + "args": {"cmd": "nmap 10.0.0.5"}, + } + assert safety.record_step("start", step) is None + safety.record_step("done", step, {"stdout": "80/tcp open", "stderr": "", "returncode": 0}) + if seen.get("exit"): + raise SystemExit(seen["exit"]) + + monkeypatch.setattr("codebud.cli.main.main", main) + return seen + + +def _log(case): + return cli.evidence_path(case / "acme.toml", None) + + +def _entries(case): + from codebud import crypto + + crypto.ensure_unlocked() + return evidence.entries(_log(case)) + + +def test_a_session_runs_codebud_inside_the_engagement(case, core): + status = cli.main(["chat", "--engagement", "acme.toml", "--resume"]) + assert status == 0 + assert core["argv"] == ["codebud", "--profile", "security-pentest", "chat", "--resume"] + assert core["engagement"] and core["fenced"] + # Everything is taken down afterwards. + assert not safety.provided("engagement") and isolation._FENCES == [] + assert safety._STEP_RECORDERS == [] + + +def test_every_step_is_in_the_encrypted_evidence_log(case, core): + cli.main(["run", "--engagement", "acme.toml", "scan the web tier"]) + log = _log(case) + assert "nmap" not in log.read_text() # sealed + entries = _entries(case) + kinds = [(e["kind"], e["data"].get("event") or e["data"].get("session")) for e in entries] + assert kinds == [ + ("open", None), + ("note", "start"), + ("step", "start"), + ("step", "done"), + ("note", "end"), + ] + assert entries[0]["data"]["engagement_sha256"] + assert entries[3]["data"]["result"]["stdout"] == "80/tcp open" + assert evidence.verify(log).ok + + +def test_later_sessions_append_to_the_same_log(case, core): + cli.main(["run", "--engagement", "acme.toml", "one"]) + cli.main(["run", "--engagement", "acme.toml", "two"]) + entries = _entries(case) + assert [e["kind"] for e in entries].count("open") == 1 + assert len(entries) == 9 + + +def test_the_profile_and_log_can_be_chosen(case, core, tmp_path_factory): + elsewhere = tmp_path_factory.mktemp("cases") / "ir.jsonl" + argv = ["chat", "--engagement", "acme.toml", "--profile", "security-ir"] + cli.main([*argv, "--evidence", str(elsewhere)]) + assert core["argv"][:3] == ["codebud", "--profile", "security-ir"] + assert elsewhere.exists() and not _log(case).exists() + + +def test_codebuds_exit_status_is_kept_and_recorded(case, core): + core["exit"] = 3 + assert cli.main(["run", "--engagement", "acme.toml", "x"]) == 3 + assert _entries(case)[-1]["data"] == {"session": "end", "status": 3} + + +def test_long_output_is_kept_as_its_hash(case, core, monkeypatch): + long = "x" * (cli._TEXT_LIMIT + 1) + recorder = cli.EvidenceRecorder(evidence.EvidenceLog.create(case / "l.jsonl", encrypt=False)) + recorder("done", {"id": "s", "tool": "command", "args": {"cmd": "cat big"}}, {"stdout": long}) + stdout = evidence.entries(case / "l.jsonl")[-1]["data"]["result"]["stdout"] + assert stdout["bytes"] == len(long) and len(stdout["sha256"]) == 64 + + +@pytest.mark.parametrize( + ("argv", "why"), + [ + (["chat", "--engagement", "missing.toml"], "no engagement file"), + (["chat"], "no engagement given"), + ], +) +def test_a_session_needs_an_engagement(case, core, argv, why): + with pytest.raises(SystemExit) if argv == ["chat"] else _nothing(): + assert cli.main(argv) == 2, why + assert "argv" not in core + + +class _nothing: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def test_a_session_needs_a_key(case, core, monkeypatch): + from codebud import crypto + + monkeypatch.setattr(crypto, "has_key", lambda: False) + assert cli.main(["chat", "--engagement", "acme.toml"]) == 2 + assert "argv" not in core + + +def test_evidence_add_verify_show_close(case, capsys): + (case / "disk.raw").write_bytes(b"\0" * 64) + assert ( + cli.main( + [ + "evidence", + "add", + "disk.raw", + "-m", + "image of the web server", + "--engagement", + "acme.toml", + ] + ) + == 0 + ) + assert evidence.file_sha256(case / "disk.raw") in capsys.readouterr().out + assert cli.main(["evidence", "verify", "--files", "--engagement", "acme.toml"]) == 0 + assert "chain intact" in capsys.readouterr().out + assert cli.main(["evidence", "show", "--engagement", "acme.toml"]) == 0 + assert "image of the web server" in capsys.readouterr().out + assert cli.main(["evidence", "close", "--engagement", "acme.toml"]) == 0 + # Closed: nothing more is added. + assert cli.main(["evidence", "add", "disk.raw", "--engagement", "acme.toml"]) == 2 + + +def test_verify_reports_a_changed_file(case, capsys): + (case / "disk.raw").write_bytes(b"\0" * 64) + cli.main(["evidence", "add", "disk.raw", "--engagement", "acme.toml"]) + (case / "disk.raw").write_bytes(b"\1" * 64) + assert cli.main(["evidence", "verify", "--files", "--engagement", "acme.toml"]) == 2 + assert "changed" in capsys.readouterr().err + + +def test_pin_is_codebud_integrity_pin(monkeypatch): + seen = {} + + def main(): + import sys + + seen["argv"] = list(sys.argv) + + monkeypatch.setattr("codebud.cli.main.main", main) + assert cli.main(["pin", "--command", "nmap"]) == 0 + assert seen["argv"] == ["codebud", "integrity", "pin", "--command", "nmap"] + + +def test_a_scope_refusal_gets_its_own_hint(): + """Not the allow-list advice: no allow-list widens an engagement.""" + from codebud.cli.errors import explain + + error = ( + "The safety policy refused step 1 (nmap 192.168.1.1): engagement scope: " + "nmap: 192.168.1.1 is not in the engagement scope (acme.toml)" + ) + explanation, fix = explain(error) + assert "engagement" in explanation and "harmless_commands" not in fix + + +# --- review of #23 ----------------------------------------------------------------- + + +def test_the_log_is_kept_where_steps_cannot_write(case, core): + """In codebud's data directory: bwrap hides it from steps, and the file + tools are confined to the project.""" + from codebud import confine, paths + + cli.main(["run", "--engagement", "acme.toml", "x"]) + log = _log(case) + assert log.exists() and paths.data_dir() in log.parents + with pytest.raises(confine.ConfinementError): + confine.check_write(str(log)) + + +@pytest.mark.parametrize("inside", ["case.jsonl", "logs/case.jsonl", "."]) +def test_a_log_inside_the_project_is_refused(case, core, inside): + assert cli.main(["run", "--engagement", "acme.toml", "--evidence", inside, "x"]) == 2 + assert "argv" not in core + + +def test_a_changed_engagement_file_is_refused_for_an_existing_log(case, core, capsys): + cli.main(["run", "--engagement", "acme.toml", "x"]) + (case / "acme.toml").write_text(SCOPE.replace("10.0.0.0/16", "0.0.0.0/0")) + core.clear() + assert cli.main(["run", "--engagement", "acme.toml", "x"]) == 2 + assert "argv" not in core and "new log" in capsys.readouterr().err + assert cli.main(["evidence", "add", "acme.toml", "--engagement", "acme.toml"]) == 2 + + +def test_a_message_codebud_exits_with_is_shown(case, core, capsys): + core["exit"] = "codebud: the integrity manifest does not match" + assert cli.main(["run", "--engagement", "acme.toml", "x"]) == 1 + assert "manifest does not match" in capsys.readouterr().err diff --git a/tests/test_layers.py b/tests/test_layers.py index 531f4d9..46bfc0a 100644 --- a/tests/test_layers.py +++ b/tests/test_layers.py @@ -15,9 +15,12 @@ # The core modules codebud_sec may import. CORE_API = { + "codebud.cli.main", # codebud-sec hands its sessions over to codebud (PR 7) + "codebud.confine", # the evidence log is never inside the project (review of #23) "codebud.crypto", # encryption at rest: the evidence log (D14b) "codebud.fsutil", "codebud.isolation", # the scope guard needs isolated steps (review of #18) + "codebud.paths", # the evidence log's default place, codebud's data dir (review of #23) "codebud.privacy", # the scope guard refuses an engagement at privacy trusted "codebud.safety", # the scope guard registers its step check (D14i, PR 4) } diff --git a/tests/test_step_checks.py b/tests/test_step_checks.py index 7813300..65c6573 100644 --- a/tests/test_step_checks.py +++ b/tests/test_step_checks.py @@ -121,3 +121,80 @@ def test_the_profile_key_is_read(tmp_path): (tmp_path / "q.toml").write_text('engagement = "yes"\n') with pytest.raises(profiles.ProfileError): profiles._load_file("q", tmp_path / "q.toml") + + +# --- step recorders (step 5 plan, PR 7: the evidence log) ----------------------------- + + +def _executor(tool=None): + from codebud.executor import Executor + + class Registry: + def get(self, name): + return tool + + class Memory: + def add_snapshot(self, step, result): + pass + + return Executor(Registry(), None, Memory(), safety=_engine()) + + +class _Tool: + def __init__(self, returncode=0): + self.ran = 0 + self.returncode = returncode + + def run(self, args): + self.ran += 1 + return {"stdout": "out", "stderr": "", "returncode": self.returncode} + + +def _plan(cmd): + return {"plan": [{"id": "step_0", "tool": "command", "description": "d", "args": {"cmd": cmd}}]} + + +@pytest.fixture +def recorded(monkeypatch): + events = [] + monkeypatch.setattr(safety, "_STEP_RECORDERS", []) + safety.add_step_recorder( + lambda event, step, result: events.append((event, step["args"]["cmd"], result)) + ) + return events + + +@pytest.mark.parametrize("returncode", [0, 1]) +def test_a_step_is_recorded_before_and_after_it_runs(recorded, returncode): + tool = _Tool(returncode) + _executor(tool).execute_plan(_plan("ls")) + assert [e[0] for e in recorded] == ["start", "done"] + assert recorded[1][2]["returncode"] == returncode and recorded[1][2]["stdout"] == "out" + + +def test_a_refused_step_is_recorded(recorded): + _executor(_Tool()).execute_plan(_plan("rm -rf /")) + assert [e[0] for e in recorded] == ["refused"] + assert recorded[0][2]["error"] + + +def test_a_step_that_cannot_be_recorded_does_not_run(monkeypatch): + monkeypatch.setattr(safety, "_STEP_RECORDERS", []) + + def broken(event, step, result): + raise OSError("disk full") + + safety.add_step_recorder(broken) + tool = _Tool() + result = _executor(tool).execute_plan(_plan("ls")) + assert tool.ran == 0 + assert result["status"] == "step_refused" and "disk full" in result["error"] + + +def test_a_crashing_tool_is_recorded(recorded): + class Crash: + def run(self, args): + raise RuntimeError("boom") + + _executor(Crash()).execute_plan(_plan("ls")) + assert [e[0] for e in recorded] == ["start", "done"] and "boom" in recorded[1][2]["error"]