NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

casts.nix (9805B)


      1 # modules/features/pentest/casts.nix — record the terminal, as raw material.
      2 #
      3 #   htbcast                       record into the current box, auto-named
      4 #   htbcast -n foothold           name it yourself
      5 #   htbcast -b sauna -n privesc   record into a box that is not the current one
      6 #   htbcast ls [box]              recordings, newest first, with durations
      7 #   htbcast play <name>           replay it in the terminal
      8 #   htbcast txt <name>            plain-text transcript -> casts/<name>.txt
      9 #   htbcast gif <name>            animated GIF -> casts/<name>.gif
     10 #
     11 # Several at once is the normal case: one terminal running a scan, another
     12 # working the shell. Each gets its own cast, so the default name carries a
     13 # counter and a timestamp rather than needing a flag.
     14 #
     15 # Why asciinema and not a screen recorder: a .cast is JSON lines of
     16 # {time, "o", bytes}, so the whole session is machine-readable. That is the
     17 # point -- these are the input an agent reads to write the write-up, and the
     18 # `txt` form is what you paste into a prompt.
     19 #
     20 # IMPORTANT, and the reason `gif` is not the deliverable: the website's
     21 # terminal GIFs are generated from a spec, never from a recording
     22 # (~/git/daemon-sec/AGENTS.md, "ASCII terminal clips"), because a real session
     23 # carries typos, dead waits and a scrollback that disagrees with the prose.
     24 # So `htbcast gif` is for previewing and for judging what to cut; the published
     25 # clip is still written as script/clips/specs/<name>.json.
     26 #
     27 # Version note: nixpkgs' asciinema is 3.x and writes asciicast v3. The renderer
     28 # is `asciinema-agg` (bin/agg), NOT `pkgs.agg`, which is AntiGrain Geometry, a
     29 # C++ graphics library with no agg binary at all. agg 1.9 does read v3 -- this
     30 # was checked by recording a cast and rendering it, not assumed.
     31 { lib, ... }:
     32 let
     33   # Hoisted out of the module so perSystem.checks can build the same script the
     34   # system installs. A check against a different derivation is not a check.
     35   mkHtbcast = pkgs: pkgs.writeShellScriptBin "htbcast" ''
     36         set -uo pipefail
     37         PATH=${lib.makeBinPath [
     38           pkgs.coreutils pkgs.gnused pkgs.gnugrep pkgs.jq
     39           pkgs.asciinema pkgs.asciinema-agg
     40         ]}:$PATH
     41         STATE="''${XDG_STATE_HOME:-$HOME/.local/state}/htb"
     42         ROOT="$HOME/htb"
     43 
     44         # printf, not a heredoc -- see the note in boxes.nix: an indented
     45         # terminator inside a Nix string never terminates.
     46         usage() {
     47           printf '%s\n' \
     48             'usage:' \
     49             '  htbcast [-b box] [-n name]     start recording' \
     50             '  htbcast ls [box]               list recordings' \
     51             '  htbcast play <name> [-b box]   replay' \
     52             '  htbcast txt  <name> [-b box]   plain-text transcript' \
     53             '  htbcast gif  <name> [-b box]   animated GIF (preview only, see header)'
     54         }
     55 
     56         current() { [ -s "$STATE/box" ] && cat "$STATE/box"; }
     57 
     58         # Resolve the box directory, or fail with the command that fixes it.
     59         boxdir() {
     60           local b="$1"
     61           [ -n "$b" ] || b=$(current)
     62           if [ -z "$b" ]; then
     63             echo "htbcast: no box given and none current — run 'htbbox new' first" >&2
     64             return 2
     65           fi
     66           case "$b" in *[!A-Za-z0-9_.-]*|.*) echo "htbcast: bad box name: $b" >&2; return 2 ;; esac
     67           if [ ! -d "$ROOT/$b" ]; then
     68             echo "htbcast: no such box: $b (htbbox ls)" >&2
     69             return 2
     70           fi
     71           mkdir -p "$ROOT/$b/casts"
     72           printf '%s\n' "$ROOT/$b/casts"
     73         }
     74 
     75         sub="''${1:-rec}"
     76         case "$sub" in ls|play|txt|gif) shift ;; rec) ;; -h|--help) usage; exit 0 ;; *) sub=rec ;; esac
     77 
     78         box="" name=""
     79         # `play`/`txt`/`gif` take the name positionally; everything else by flag.
     80         case "$sub" in
     81           play|txt|gif) name="''${1:-}"; [ "$#" -gt 0 ] && shift ;;
     82           ls) case "''${1:-}" in -*|"") ;; *) box="$1"; shift ;; esac ;;
     83         esac
     84         while [ "$#" -gt 0 ]; do
     85           case "$1" in
     86             -b|--box)  box="''${2:-}"; shift 2 ;;
     87             -n|--name) name="''${2:-}"; shift 2 ;;
     88             -h|--help) usage; exit 0 ;;
     89             *) echo "htbcast: unknown option: $1" >&2; usage >&2; exit 2 ;;
     90           esac
     91         done
     92 
     93         dir=$(boxdir "$box") || exit $?
     94 
     95         case "$sub" in
     96           rec)
     97             if [ -z "$name" ]; then
     98               # Counter + time, so two terminals never collide and the order is
     99               # obvious later. date alone is not enough: two shells started in
    100               # the same second would overwrite each other.
    101               n=$(( $(ls -1 "$dir"/*.cast 2>/dev/null | wc -l) + 1 ))
    102               name=$(printf '%02d-%s' "$n" "$(date +%H%M%S)")
    103             fi
    104             case "$name" in *[!A-Za-z0-9_.-]*|.*) echo "htbcast: bad name: $name" >&2; exit 2 ;; esac
    105             out="$dir/$name.cast"
    106             [ -e "$out" ] && { echo "htbcast: $out exists — pick another -n" >&2; exit 2; }
    107             echo "htbcast: recording to $out — exit the shell (or ctrl-d) to stop"
    108             # --title so `ls` and the website spec have something to read back.
    109             exec asciinema rec --title "$(basename "$(dirname "$dir")") $name" "$out"
    110             ;;
    111 
    112           ls)
    113             # One write then exit 0: `htbcast ls | grep -q x` closes the pipe on
    114             # its first match and EPIPE would otherwise set a failing status.
    115             out=""
    116             for c in $(ls -1t "$dir"/*.cast 2>/dev/null); do
    117               secs=$(jq -rs 'map(select(type=="array") | .[0]) | if length > 0 then (max | floor | tostring) + "s" else "?" end' "$c" 2>/dev/null || echo "?")
    118               out="$out$(printf '  %-28s %-6s %s' "$(basename "$c" .cast)" "$secs" "$(du -h "$c" | cut -f1)")
    119 "
    120             done
    121             [ -n "$out" ] || out="  no recordings yet — htbcast
    122 "
    123             printf '%s' "$out" || true
    124             exit 0 ;;
    125 
    126           play|txt|gif)
    127             [ -n "$name" ] || { echo "htbcast: $sub needs a recording name (htbcast ls)" >&2; exit 2; }
    128             c="$dir/$name.cast"
    129             [ -s "$c" ] || { echo "htbcast: no such recording: $c" >&2; exit 2; }
    130             case "$sub" in
    131               play) exec asciinema play "$c" ;;
    132               txt)  asciinema convert --output-format txt "$c" "$dir/$name.txt" && echo "$dir/$name.txt" ;;
    133               gif)  agg "$c" "$dir/$name.gif" && echo "$dir/$name.gif" ;;
    134             esac
    135             ;;
    136         esac
    137   '';
    138 in
    139 {
    140   flake.nixosModules.pentest-casts =
    141     { config, pkgs, lib, ... }:
    142     {
    143       config = lib.mkIf config.daemon.pentest.enable {
    144         environment.systemPackages = [
    145           (mkHtbcast pkgs)
    146           pkgs.asciinema
    147           pkgs.asciinema-agg
    148         ];
    149       };
    150     };
    151 
    152   # Exercises the whole chain on a real recording: record -> ls -> txt -> gif.
    153   # The gif step is the one that matters most: nixpkgs' asciinema is 3.x and
    154   # writes asciicast v3, and agg is 1.9. If a future bump breaks that pairing
    155   # this check goes red instead of `htbcast gif` failing mid-write-up.
    156   perSystem =
    157     { pkgs, ... }:
    158     {
    159       checks.pentest-casts = pkgs.runCommand "pentest-casts-check"
    160         {
    161           nativeBuildInputs = [ (mkHtbcast pkgs) pkgs.asciinema pkgs.asciinema-agg pkgs.jq ];
    162           # agg rasterises text, so it needs a font to exist. The build sandbox
    163           # has no fontconfig at all ("Error: no faces matching font family
    164           # options"), while the real system does -- so the font is pinned here
    165           # rather than in htbcast, which should use whatever the host has.
    166           # DejaVu Sans Mono is in agg's default family list.
    167           FONTCONFIG_FILE = pkgs.makeFontsConf { fontDirectories = [ pkgs.dejavu_fonts ]; };
    168         }
    169         ''
    170           export HOME=$(mktemp -d)
    171           box="$HOME/htb/sauna"
    172           mkdir -p "$box/casts"
    173 
    174           # A recording made the way htbcast makes one. --command works without
    175           # an interactive tty, which is why this can run in a derivation.
    176           asciinema rec --command 'printf "whoami\nnt authority\\system\n"' \
    177             "$box/casts/foothold.cast" >/dev/null 2>&1
    178           test -s "$box/casts/foothold.cast" || { echo "no cast produced"; exit 1; }
    179           grep -q '"version":3' "$box/casts/foothold.cast" \
    180             || { echo "expected an asciicast v3 header"; exit 1; }
    181 
    182           # ls must name it, and must not be killed by a reader closing the pipe.
    183           htbcast ls -b sauna | grep -q foothold || { echo "ls did not list the cast"; exit 1; }
    184           htbcast ls -b sauna | head -1 >/dev/null || { echo "ls died on EPIPE"; exit 1; }
    185 
    186           # txt: the transcript an agent is handed.
    187           htbcast txt foothold -b sauna >/dev/null
    188           grep -q 'nt authority' "$box/casts/foothold.txt" \
    189             || { echo "transcript missing the session output"; exit 1; }
    190 
    191           # gif: asciinema 3 cast through agg 1.9.
    192           htbcast gif foothold -b sauna >/dev/null
    193           test -s "$box/casts/foothold.gif" || { echo "agg produced no gif"; exit 1; }
    194           head -c6 "$box/casts/foothold.gif" | grep -q GIF89a \
    195             || { echo "output is not a gif"; exit 1; }
    196 
    197           # A box that does not exist must fail, and say how to list them.
    198           # Captured rather than piped: the stdenv builder runs with pipefail,
    199           # so htbcast's intended non-zero exit would fail the pipeline even
    200           # when grep matches.
    201           msg=$(htbcast ls -b nosuchbox 2>&1 || true)
    202           printf '%s' "$msg" | grep -q 'htbbox ls' \
    203             || { echo "unknown box should point at htbbox ls, got: $msg"; exit 1; }
    204           htbcast ls -b nosuchbox >/dev/null 2>&1 \
    205             && { echo "unknown box must exit non-zero"; exit 1; } || true
    206 
    207           echo "record -> ls -> txt -> gif all pass" > $out
    208         '';
    209     };
    210 }