bloodhound.nix (17781B)
1 # modules/features/pentest/bloodhound.nix — both BloodHound viewers, each with 2 # a single command that brings up everything it needs. 3 # 4 # bloodhound ce postgres + neo4j + the CE API, then the URL to open 5 # bloodhound legacy neo4j + the archived 4.3.1 GUI 6 # bloodhound status what is up, and where 7 # bloodhound down stop all of it 8 # bloodhound creds the CE admin password (printed on first start) 9 # 10 # The two are NOT interchangeable, and this is the thing that wastes an hour: 11 # 12 # bloodhound-ce reads CE-format JSON. Collect with `rusthound-ce` or 13 # SharpHound CE ($PAYLOADS/windows/amd64/ad). 14 # bloodhound-legacy reads the OLD format, which is what 15 # `bloodhound-python` 1.9 and SharpHound v1 produce. CE 16 # refuses it, which is the only reason this is still here. 17 # nixpkgs dropped it (archived upstream, Electron 11) — 18 # lab data only. 19 # 20 # Nothing starts at boot: neo4j is a JVM that wants a gigabyte, and the API 21 # follows it. `bloodhound ce` is how you pay for it, when you want it. 22 # 23 # Why trust auth and no neo4j password: both databases listen on localhost 24 # only and exist to hold one lab's graph. The alternative is a secret to 25 # manage for no gain. The CE admin password IS generated, because it guards a 26 # web UI; it is written once to the state directory. 27 { lib, self, ... }: 28 { 29 imports = [ 30 ((import ./_sets.nix { inherit lib; }) { 31 name = "bloodhound"; 32 description = "BloodHound CE and Legacy, with neo4j and postgresql"; 33 34 packages = 35 pkgs: 36 let 37 extra = import ./_pkgs/default.nix { inherit lib pkgs; }; 38 in 39 [ 40 pkgs.bloodhound-ce # the CE API + web graph viewer 41 pkgs.bloodhound-py # legacy-format collector 42 pkgs.rusthound-ce # CE-format collector 43 # Neo4j 4.4, NOT nixpkgs' 2026.x: BloodHound CE's migration calls 44 # `db.indexes`, removed in Neo4j 5. See _pkgs/default.nix. 45 extra.neo4j44 # `neo4j`, `cypher-shell`, `neo4j-admin` 46 extra.bloodhoundLegacy 47 ]; 48 49 expectedBins = [ 50 "bloodhound-ce" 51 "bloodhound-legacy" 52 "cypher-shell" 53 "neo4j" 54 "bloodhound-python" 55 "rusthound-ce" 56 ]; 57 58 extraConfig = 59 { pkgs, lib, config, ... }: 60 let 61 stateDir = "/var/lib/bloodhound"; 62 configFile = "${stateDir}/bhapi.json"; 63 port = 8080; 64 systemctl = "/run/current-system/sw/bin/systemctl"; 65 sudo = "/run/wrappers/bin/sudo"; 66 dbUnits = "neo4j.service postgresql.service"; 67 68 extra = import ./_pkgs/default.nix { inherit lib pkgs; }; 69 neo4j44 = extra.neo4j44; 70 71 # Neo4j's scripts expect a writable NEO4J_HOME. Build one per boot: 72 # lib/ and bin/ come from the store, everything it writes is real. 73 neo4jSetup = pkgs.writeShellScript "neo4j-setup" '' 74 set -euo pipefail 75 PATH=${lib.makeBinPath [ pkgs.coreutils ]}:$PATH 76 H=${stateDir}/neo4j 77 mkdir -p $H/{conf,data,logs,run,import,plugins,certificates} 78 ln -sfn ${neo4j44}/share/neo4j/lib $H/lib 79 ln -sfn ${neo4j44}/share/neo4j/bin $H/bin 80 cat > $H/conf/neo4j.conf <<'EOF' 81 # One lab graph on loopback. Auth is off because BloodHound needs 82 # credentials either way and a password here guards nothing. 83 dbms.security.auth_enabled=false 84 dbms.default_listen_address=127.0.0.1 85 dbms.connector.bolt.enabled=true 86 dbms.connector.bolt.listen_address=127.0.0.1:7687 87 dbms.connector.http.enabled=true 88 dbms.connector.http.listen_address=127.0.0.1:7474 89 dbms.connector.https.enabled=false 90 dbms.memory.heap.initial_size=512m 91 dbms.memory.heap.max_size=1G 92 dbms.memory.pagecache.size=512m 93 dbms.jvm.additional=-XX:+UseG1GC 94 EOF 95 ${pkgs.gnused}/bin/sed -i 's/^ //' $H/conf/neo4j.conf 96 ''; 97 98 # The config is written at START, not by Nix: it carries a generated 99 # JWT signing key, and a secret in the Nix store is world-readable. 100 mkConfig = pkgs.writeShellScript "bloodhound-ce-config" '' 101 set -euo pipefail 102 PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.openssl pkgs.jq ]}:$PATH 103 mkdir -p ${stateDir}/work 104 [ -s ${stateDir}/jwt.key ] || { openssl rand -base64 48 > ${stateDir}/jwt.key; chmod 600 ${stateDir}/jwt.key; } 105 [ -s ${stateDir}/admin.pw ] || { openssl rand -base64 18 > ${stateDir}/admin.pw; chmod 600 ${stateDir}/admin.pw; } 106 107 jq -n \ 108 --arg jwt "$(cat ${stateDir}/jwt.key)" \ 109 --arg pw "$(cat ${stateDir}/admin.pw)" \ 110 '{ 111 version: 1, 112 bind_addr: "127.0.0.1:${toString port}", 113 root_url: "http://127.0.0.1:${toString port}/", 114 metrics_port: ":2112", 115 work_dir: "${stateDir}/work", 116 log_level: "INFO", 117 graph_driver: "neo4j", 118 collectors_base_path: "${pkgs.bloodhound-ce.collectors}", 119 database: { 120 addr: "127.0.0.1:5432", 121 database: "bloodhound", 122 username: "bloodhound", 123 secret: "trust" 124 }, 125 neo4j: { 126 addr: "127.0.0.1:7687", 127 database: "neo4j", 128 username: "neo4j", 129 secret: "neo4j" 130 }, 131 crypto: { 132 jwt: { signing_key: $jwt }, 133 argon2: { memory_kibibytes: 1048576, num_iterations: 1, num_threads: 4 } 134 }, 135 default_admin: { 136 principal_name: "admin", 137 password: $pw, 138 email_address: "admin@bloodhound.lab", 139 first_name: "Admin", 140 last_name: "User", 141 expire_now: false 142 }, 143 enable_startup_wait_period: false, 144 enable_api_logging: true, 145 enable_cypher_mutations: true, 146 disable_cypher_complexity_limit: true 147 }' > ${configFile} 148 chmod 600 ${configFile} 149 ''; 150 151 bloodhound = pkgs.writeShellScriptBin "bloodhound" '' 152 set -uo pipefail 153 PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.curl pkgs.systemd pkgs.gnugrep ]}:$PATH 154 155 wait_for() { # wait_for <url> <seconds> <label> 156 local i 157 for i in $(seq 1 "$2"); do 158 curl -fsS -o /dev/null "$1" 2>/dev/null && return 0 159 sleep 1 160 done 161 echo "bloodhound: $3 did not answer within $2s" >&2 162 return 1 163 } 164 165 start_dbs() { 166 echo "bloodhound: starting ${dbUnits} (neo4j is a JVM — give it ~20s)" 167 ${sudo} -n ${systemctl} start ${dbUnits} || return 1 168 wait_for http://127.0.0.1:7474 90 "neo4j" || { 169 echo " journalctl -u neo4j" >&2; return 1; } 170 echo "bloodhound: neo4j up (browser: http://127.0.0.1:7474)" 171 } 172 173 case "''${1:-status}" in 174 ce) 175 start_dbs || exit 1 176 echo "bloodhound: starting the CE API" 177 ${sudo} -n ${systemctl} start bloodhound-ce.service || { 178 echo " journalctl -u bloodhound-ce" >&2; exit 1; } 179 if wait_for http://127.0.0.1:${toString port}/ui/login 120 "the CE API"; then 180 echo "" 181 echo " BloodHound CE: http://127.0.0.1:${toString port}/" 182 echo " user: admin" 183 echo " password: run 'bloodhound creds'" 184 echo "" 185 echo " Upload CE-format zips only (rusthound-ce / SharpHound CE)." 186 else 187 echo " journalctl -u bloodhound-ce" >&2; exit 1 188 fi ;; 189 190 legacy) 191 # Legacy talks straight to neo4j; it needs no API server. 192 start_dbs || exit 1 193 echo "bloodhound: launching the archived 4.3.1 GUI" 194 echo " connect to bolt://127.0.0.1:7687 (auth is disabled)" 195 echo " feed it LEGACY-format zips (bloodhound-python / SharpHound v1)" 196 exec bloodhound-legacy ;; 197 198 creds) 199 # Read through sudo, not with a `[ -r ]` guard first: the file 200 # belongs to the bloodhound service user and is mode 600, so 201 # it is deliberately unreadable to you directly — the guard 202 # was always false and this never printed anything. 203 pw=$(${sudo} -n ${pkgs.coreutils}/bin/cat ${stateDir}/admin.pw 2>/dev/null || true) 204 if [ -n "''${pw:-}" ]; then 205 echo "user: admin" 206 echo "pass: $pw" 207 else 208 echo "bloodhound: no credentials yet — run 'bloodhound ce' once" >&2 209 exit 1 210 fi ;; 211 212 down) 213 ${sudo} -n ${systemctl} stop bloodhound-ce.service 2>/dev/null || true 214 ${sudo} -n ${systemctl} stop ${dbUnits} || true 215 echo "bloodhound: stopped" ;; 216 217 status) 218 for u in neo4j.service postgresql.service bloodhound-ce.service; do 219 printf ' %-24s %s\n' "$u" "$(systemctl is-active "$u" 2>/dev/null || echo inactive)" 220 done 221 curl -fsS -o /dev/null http://127.0.0.1:${toString port}/ui/login 2>/dev/null \ 222 && echo " CE UI: http://127.0.0.1:${toString port}/" \ 223 || echo " CE UI: not answering" ;; 224 225 -h|--help) echo "usage: bloodhound ce | legacy | creds | status | down" ;; 226 *) echo "usage: bloodhound ce | legacy | creds | status | down" >&2; exit 2 ;; 227 esac 228 ''; 229 in 230 { 231 # NOT services.neo4j: that module is pinned to nixpkgs' neo4j 232 # 2026.09, which BloodHound CE cannot talk to (it calls `db.indexes`, 233 # gone in Neo4j 5). This is the same shape, on 4.4. 234 # 235 # Neo4j wants a WRITABLE home: the distribution lives in the store, 236 # so the pre-start builds one under the state directory with lib/ 237 # symlinked in and real conf/data/logs/run dirs. 238 systemd.services.neo4j = { 239 description = "Neo4j 4.4 (for BloodHound)"; 240 wantedBy = [ ]; # a JVM wanting ~1 GB; started by `bloodhound` 241 serviceConfig = { 242 Type = "simple"; 243 User = "bloodhound"; 244 Group = "bloodhound"; 245 StateDirectory = "bloodhound"; 246 StateDirectoryMode = "0750"; 247 Environment = [ 248 "NEO4J_HOME=${stateDir}/neo4j" 249 "NEO4J_CONF=${stateDir}/neo4j/conf" 250 ]; 251 ExecStartPre = "${neo4jSetup}"; 252 ExecStart = "${lib.getExe neo4j44} console"; 253 Restart = "no"; 254 TimeoutStartSec = "180"; 255 }; 256 }; 257 258 services.postgresql = { 259 enable = true; 260 ensureDatabases = [ "bloodhound" ]; 261 ensureUsers = [ 262 { 263 name = "bloodhound"; 264 ensureDBOwnership = true; 265 } 266 ]; 267 # BloodHound connects over TCP with a password (its DSN is 268 # postgresql://user:secret@addr/db), and NixOS' ensureUsers sets no 269 # password. Trust for this one role, on this one database, from 270 # loopback only. 271 authentication = lib.mkAfter '' 272 host bloodhound bloodhound 127.0.0.1/32 trust 273 host bloodhound bloodhound ::1/128 trust 274 ''; 275 }; 276 277 systemd.services.bloodhound-ce = { 278 description = "BloodHound CE API server"; 279 wantedBy = [ ]; # started by `bloodhound ce` 280 after = [ "neo4j.service" "postgresql.service" ]; 281 requires = [ "neo4j.service" "postgresql.service" ]; 282 serviceConfig = { 283 Type = "simple"; 284 ExecStartPre = "${mkConfig}"; 285 ExecStart = "${lib.getExe pkgs.bloodhound-ce} -configfile ${configFile}"; 286 User = "bloodhound"; 287 Group = "bloodhound"; 288 StateDirectory = "bloodhound"; 289 StateDirectoryMode = "0750"; 290 WorkingDirectory = stateDir; 291 Restart = "no"; 292 # It only needs loopback and its own state. 293 PrivateTmp = true; 294 ProtectSystem = "strict"; 295 ProtectHome = true; 296 NoNewPrivileges = true; 297 ReadWritePaths = [ stateDir ]; 298 }; 299 }; 300 301 users.users.bloodhound = { 302 isSystemUser = true; 303 group = "bloodhound"; 304 home = stateDir; 305 description = "BloodHound CE and its neo4j"; 306 }; 307 users.groups.bloodhound = { }; 308 309 environment.systemPackages = [ bloodhound ]; 310 311 # Scoped as fan-cli.nix does: wheel, no password, these units only. 312 security.sudo.extraRules = [ 313 { 314 groups = [ "wheel" ]; 315 commands = [ 316 { command = "${systemctl} start ${dbUnits}"; options = [ "NOPASSWD" ]; } 317 { command = "${systemctl} stop ${dbUnits}"; options = [ "NOPASSWD" ]; } 318 { command = "${systemctl} start bloodhound-ce.service"; options = [ "NOPASSWD" ]; } 319 { command = "${systemctl} stop bloodhound-ce.service"; options = [ "NOPASSWD" ]; } 320 { command = "${pkgs.coreutils}/bin/cat ${stateDir}/admin.pw"; options = [ "NOPASSWD" ]; } 321 ]; 322 } 323 ]; 324 }; 325 }) 326 327 # The deliverable is "one command brings BloodHound up", so the test boots 328 # a VM and drives that command — a binary-resolution check cannot see 329 # whether neo4j actually starts or the API actually answers. 330 { 331 perSystem = 332 { pkgs, ... }: 333 { 334 checks.pentest-bloodhound-vm = pkgs.testers.runNixOSTest { 335 name = "pentest-bloodhound"; 336 337 nodes.machine = { pkgs, ... }: { 338 imports = [ 339 self.nixosModules.pentest-options 340 self.nixosModules.pentest-bloodhound 341 ]; 342 daemon.pentest = { 343 enable = true; 344 bloodhound.enable = true; 345 }; 346 virtualisation.memorySize = 4096; # neo4j is a JVM 347 virtualisation.diskSize = 4096; 348 _module.args.user = "daemonsec"; 349 users.users.daemonsec = { 350 isNormalUser = true; 351 extraGroups = [ "wheel" ]; 352 }; 353 }; 354 355 testScript = '' 356 machine.wait_for_unit("multi-user.target") 357 358 def as_user(cmd): 359 return f"su -l daemonsec -c {cmd!r}" 360 361 # neo4j must NOT be running at boot: that is the point of 362 # holding its wantedBy empty — it is a JVM wanting a gigabyte. 363 machine.fail("systemctl is-active neo4j.service") 364 machine.fail("systemctl is-active bloodhound-ce.service") 365 366 # One command brings up both databases and the API, as the user, 367 # with no password. 368 machine.succeed(as_user("bloodhound ce"), timeout=300) 369 machine.wait_for_unit("neo4j.service") 370 machine.wait_for_unit("postgresql.service") 371 machine.wait_for_unit("bloodhound-ce.service") 372 373 # neo4j answers, and auth really is disabled (BloodHound has no 374 # credentials for it). 375 machine.wait_for_open_port(7474) 376 machine.succeed("curl -fsS http://127.0.0.1:7474 >/dev/null") 377 378 # The CE API serves its login page — this is what was previously 379 # only asserted in a comment. 380 machine.wait_for_open_port(8080) 381 machine.succeed("curl -fsS http://127.0.0.1:8080/ui/login >/dev/null") 382 383 # The generated config and credentials exist and are not world 384 # readable, and `bloodhound creds` can read them back. 385 machine.succeed("test -s /var/lib/bloodhound/bhapi.json") 386 machine.succeed("test \"$(stat -c %a /var/lib/bloodhound/bhapi.json)\" = 600") 387 machine.succeed(as_user("bloodhound creds") + " | grep -q '^user: admin'") 388 machine.succeed(as_user("bloodhound creds") + " | grep -qE '^pass: .+'") 389 390 # The database BloodHound expects exists and it owns it. 391 machine.succeed( 392 "sudo -u postgres psql -tAc " 393 "\"select 1 from pg_database where datname='bloodhound'\" | grep -q 1" 394 ) 395 396 machine.succeed(as_user("bloodhound status") + " | grep -q 'CE UI'") 397 398 # Down stops all three. 399 machine.succeed(as_user("bloodhound down")) 400 machine.fail("systemctl is-active bloodhound-ce.service") 401 machine.fail("systemctl is-active neo4j.service") 402 403 # The passwordless grant is scoped to these units only. 404 machine.fail(as_user("sudo -n /run/current-system/sw/bin/systemctl start sshd.service")) 405 ''; 406 }; 407 }; 408 } 409 ]; 410 }