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 }