NixDaemon

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

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 }