NixDaemon

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

_htbbox.py (27122B)


      1 """htbbox -- one directory and one box.json per HTB machine.
      2 
      3     htbbox new Sauna 10.10.10.175 windows easy      positional, any order
      4     htbbox new Sauna 10.10.10.175 win easy sauna.htb dc01.sauna.htb
      5     htbbox new                                      prompt for everything
      6     htbbox use sauna                                switch box (+ $TARGET, /etc/hosts)
      7     htbbox ls | info | path | json                  look around
      8     htbbox set ip 10.10.10.176                      change a field
      9     htbbox host dc01.sauna.htb                      add hostnames (-> /etc/hosts)
     10     htbbox cred fsmith 'Thestrokes23' kerberoast    record a credential
     11     htbbox flag user 3f4e...                        record a flag (status -> user/root)
     12     htbbox note "WinRM open, fsmith in Remote Mgmt" timestamped note
     13     htbbox ports                                    import open ports from recon/*.xml
     14 
     15 Every subcommand that acts on a box takes `-b <box>`; without it, the current
     16 box ($BOX, set by `new`/`use`) is used.
     17 
     18 box.json is the single source of truth for a box. Other tools read it with jq
     19 (`htbbox json ip`, `jq .creds "$BOXDIR/box.json"`). writeup.md is rendered once
     20 from the vault's Templater template (or a built-in skeleton) and never
     21 rewritten afterwards -- it is prose you own.
     22 
     23 Stdlib only. Nix wraps this with gum on PATH (boxes.nix); without gum the
     24 prompts fall back to input().
     25 """
     26 
     27 import datetime as dt
     28 import ipaddress
     29 import json
     30 import os
     31 import re
     32 import shutil
     33 import subprocess
     34 import sys
     35 import xml.etree.ElementTree as ET
     36 
     37 SCHEMA = 2
     38 STATE = os.path.join(
     39     os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), "htb"
     40 )
     41 ROOT = os.environ.get("HTB_ROOT") or os.path.expanduser("~/htb")
     42 VAULT = os.environ.get("NETRUNNER_VAULT") or os.path.expanduser("~/git/NetrunnerVault")
     43 TEMPLATE = os.path.join(VAULT, "00Meta/Templates/daemon-sec-htb-post.md")
     44 SUBDIRS = ["recon", "enum", "creds", "loot", "exploit", "serve", "casts"]
     45 
     46 DIFFICULTIES = ["easy", "medium", "hard", "insane"]
     47 # alias -> canonical display form (the website's frontmatter reads the latter)
     48 OSES = {
     49     "windows": "Windows", "win": "Windows",
     50     "linux": "Linux", "lin": "Linux",
     51     "freebsd": "FreeBSD", "bsd": "FreeBSD",
     52     "openbsd": "OpenBSD",
     53     "android": "Android",
     54     "other": "Other",
     55 }
     56 OS_CHOICES = ["windows", "linux", "freebsd", "openbsd", "android", "other"]
     57 STATUSES = ["active", "user", "root", "retired", "paused"]
     58 SETTABLE = ["name", "ip", "os", "difficulty", "status", "domain", "notes_url"]
     59 
     60 USAGE = """\
     61 usage: htbbox <command> [args] [-b box]
     62 
     63   new [name] [ip] [os] [difficulty] [host...]   scaffold a box (any order; prompts for the rest)
     64       -n name  -i ip  -o os  -d difficulty  -H host   (flags, if you prefer)
     65   use <box>                     make <box> current: $BOX, $TARGET, /etc/hosts
     66   ls                            all boxes, newest first (* = current)
     67   info [box]                    the box card: target, flags, creds, ports, notes
     68   path [box]                    the directory   (or just: cd $BOXDIR)
     69   json [box] [key]              raw box.json, or one key (dotted: flags.user)
     70   set <key> <value>             key: name ip os difficulty status domain
     71   host <name...>                add hostnames; re-points /etc/hosts if current
     72   cred [user secret [note...]]  add a credential, or list them
     73   flag <user|root> <value>      record a flag; status follows
     74   note [text...]                add a timestamped note, or list them
     75   ports [file.xml]              import open ports from the newest recon/*.xml
     76 
     77   os:          windows linux freebsd openbsd android other   (win, lin ok)
     78   difficulty:  easy medium hard insane
     79 """
     80 
     81 
     82 # ── output ──────────────────────────────────────────────────────────────────
     83 
     84 def _color_on(stream):
     85     return stream.isatty() and "NO_COLOR" not in os.environ
     86 
     87 
     88 def paint(text, code, stream=sys.stdout):
     89     return f"\033[{code}m{text}\033[0m" if _color_on(stream) else text
     90 
     91 
     92 def die(msg, code=2):
     93     print(f"htbbox: {msg}", file=sys.stderr)
     94     sys.exit(code)
     95 
     96 
     97 def info(msg):
     98     print(msg, file=sys.stderr)
     99 
    100 
    101 # ── validation ──────────────────────────────────────────────────────────────
    102 
    103 def is_ipv4(s):
    104     try:
    105         return isinstance(ipaddress.ip_address(s), ipaddress.IPv4Address)
    106     except ValueError:
    107         return False
    108 
    109 
    110 HOST_RE = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")
    111 
    112 
    113 def is_hostname(s):
    114     return bool(HOST_RE.match(s)) and not is_ipv4(s)
    115 
    116 
    117 def norm_os(s):
    118     v = OSES.get(s.lower())
    119     if not v:
    120         die(f"os must be one of {', '.join(OS_CHOICES)} (got: {s})")
    121     return v
    122 
    123 
    124 def norm_difficulty(s):
    125     if s.lower() not in DIFFICULTIES:
    126         die(f"difficulty must be easy, medium, hard or insane (got: {s})")
    127     return s.lower()
    128 
    129 
    130 def norm_ip(s):
    131     if s and not is_ipv4(s):
    132         die(f"not an IPv4 address: {s}")
    133     return s
    134 
    135 
    136 def slugify(s):
    137     return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
    138 
    139 
    140 def now():
    141     return dt.datetime.now().replace(microsecond=0).isoformat()
    142 
    143 
    144 # ── state ───────────────────────────────────────────────────────────────────
    145 
    146 def current():
    147     try:
    148         with open(os.path.join(STATE, "box")) as fh:
    149             return fh.read().strip() or None
    150     except OSError:
    151         return None
    152 
    153 
    154 def set_current(box):
    155     os.makedirs(STATE, exist_ok=True)
    156     with open(os.path.join(STATE, "box"), "w") as fh:
    157         fh.write(box + "\n")
    158 
    159 
    160 def boxdir(box):
    161     return os.path.join(ROOT, box)
    162 
    163 
    164 def resolve(box):
    165     """Box name -> directory name, or die with the command that fixes it."""
    166     box = box or current()
    167     if not box:
    168         die("no box given and none current -- htbbox new, or htbbox use <box>")
    169     if box.startswith("htb-"):
    170         box = box[4:]
    171     if not re.match(r"^[a-z0-9][a-z0-9-]*$", box):
    172         box = slugify(box)
    173     if not os.path.isdir(boxdir(box)):
    174         die(f"no such box: {box} (htbbox ls)")
    175     return box
    176 
    177 
    178 def load(box):
    179     path = os.path.join(boxdir(box), "box.json")
    180     try:
    181         with open(path) as fh:
    182             data = json.load(fh)
    183     except FileNotFoundError:
    184         data = {"name": box, "slug": f"htb-{box}"}
    185     except json.JSONDecodeError as e:
    186         die(f"{path} is not valid JSON ({e}) -- fix it by hand", 1)
    187     # Upgrade older manifests in place: missing keys get defaults.
    188     defaults = {
    189         "ip": "", "os": "Other", "difficulty": "", "hostnames": [], "domain": "",
    190         "platform": "HTB-Labs", "status": "active", "created": dt.date.today().isoformat(),
    191         "updated": now(), "flags": {"user": None, "root": None},
    192         "creds": [], "ports": [], "notes": [], "tags": [],
    193     }
    194     for k, v in defaults.items():
    195         data.setdefault(k, v)
    196     data["schema"] = SCHEMA
    197     return data
    198 
    199 
    200 def save(box, data):
    201     data["updated"] = now()
    202     path = os.path.join(boxdir(box), "box.json")
    203     tmp = path + ".tmp"
    204     with open(tmp, "w") as fh:
    205         json.dump(data, fh, indent=2)
    206         fh.write("\n")
    207     os.replace(tmp, path)
    208 
    209 
    210 def point_target(data):
    211     """Point $TARGET (and /etc/hosts, when there are names) at this box."""
    212     if not data.get("ip"):
    213         return
    214     exe = "/run/current-system/sw/bin/htbtarget"
    215     if not os.access(exe, os.X_OK):
    216         exe = shutil.which("htbtarget")
    217     if not exe:
    218         info("htbbox: htbtarget not found -- $TARGET not set")
    219         return
    220     r = subprocess.run([exe, data["ip"], *data.get("hostnames", [])],
    221                        stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
    222     if r.returncode != 0:
    223         info(r.stderr.rstrip() or "htbbox: htbtarget failed")
    224 
    225 
    226 # ── prompts ─────────────────────────────────────────────────────────────────
    227 
    228 def interactive():
    229     return sys.stdin.isatty() and sys.stderr.isatty()
    230 
    231 
    232 def ask(prompt, placeholder=""):
    233     if shutil.which("gum"):
    234         r = subprocess.run(["gum", "input", "--prompt", f"{prompt} > ", "--placeholder", placeholder],
    235                            stdout=subprocess.PIPE, text=True)
    236         if r.returncode != 0:
    237             sys.exit(1)
    238         return r.stdout.strip()
    239     return input(f"{prompt} [{placeholder}]: ").strip()
    240 
    241 
    242 def choose(header, options):
    243     if shutil.which("gum"):
    244         r = subprocess.run(["gum", "choose", "--header", header, *options],
    245                            stdout=subprocess.PIPE, text=True)
    246         if r.returncode != 0:
    247             sys.exit(1)
    248         return r.stdout.strip()
    249     while True:
    250         v = input(f"{header} ({'/'.join(options)}): ").strip().lower()
    251         if v in options or v in OSES:
    252             return v
    253 
    254 
    255 # ── argument helpers ────────────────────────────────────────────────────────
    256 
    257 def pop_box(args):
    258     """Remove `-b X` / `--box X` from anywhere in args; return (box, rest)."""
    259     box, rest, it = None, [], iter(args)
    260     for a in it:
    261         if a in ("-b", "--box"):
    262             box = next(it, None) or die("-b needs a box name")
    263         elif a.startswith("--box="):
    264             box = a.split("=", 1)[1]
    265         else:
    266             rest.append(a)
    267     return box, rest
    268 
    269 
    270 # ── commands ────────────────────────────────────────────────────────────────
    271 
    272 def cmd_new(args):
    273     name = ip = os_ = diff = None
    274     hosts, tags = [], []
    275     it = iter(args)
    276     for a in it:
    277         flagval = lambda: next(it, None) or die(f"{a} needs a value")  # noqa: E731
    278         if a in ("-n", "--name"):
    279             name = flagval()
    280         elif a in ("-i", "--ip"):
    281             ip = flagval()
    282         elif a in ("-o", "--os"):
    283             os_ = flagval()
    284         elif a in ("-d", "--difficulty"):
    285             diff = flagval()
    286         elif a in ("-H", "--host"):
    287             hosts.append(flagval())
    288         elif a in ("-t", "--tag"):
    289             tags.append(flagval())
    290         elif a in ("-h", "--help"):
    291             print(USAGE, end="")
    292             return
    293         elif a.startswith("-"):
    294             die(f"unknown option: {a}")
    295         # Positional: classify by shape, so the order does not matter.
    296         elif is_ipv4(a) and not ip:
    297             ip = a
    298         elif a.lower() in DIFFICULTIES and not diff:
    299             diff = a
    300         elif a.lower() in OSES and not os_:
    301             os_ = a
    302         elif is_hostname(a):
    303             hosts.append(a)
    304         elif not name:
    305             name = a
    306         else:
    307             die(f"don't know what '{a}' is (name already '{name}'). Use -n/-i/-o/-d to be explicit.")
    308 
    309     # Ask for what is missing -- only on a terminal, so scripts fail fast.
    310     tty = interactive()
    311     if not name:
    312         name = ask("machine name", "Sauna") if tty else None
    313     if not name:
    314         die("a machine name is required\n" + USAGE)
    315     if ip is None and tty:
    316         ip = ask("target ip", "10.10.10.175 (blank: later)")
    317     if not diff:
    318         diff = choose("difficulty", DIFFICULTIES) if tty else die("difficulty is required (easy|medium|hard|insane)")
    319     if not os_:
    320         os_ = choose("operating system", OS_CHOICES) if tty else die("os is required (windows|linux|...)")
    321 
    322     ip = norm_ip(ip or "")
    323     diff = norm_difficulty(diff)
    324     os_ = norm_os(os_)
    325     for h in hosts:
    326         if not is_hostname(h):
    327             die(f"not a hostname: {h}")
    328     box = slugify(name)
    329     if not box:
    330         die(f"name has no usable characters: {name}")
    331 
    332     d = boxdir(box)
    333     existed = os.path.exists(os.path.join(d, "box.json"))
    334     for sub in SUBDIRS:
    335         os.makedirs(os.path.join(d, sub), exist_ok=True)
    336 
    337     # Re-running `new` on a box updates the fields given and keeps everything
    338     # else (creds, flags, notes) -- it never starts the manifest over.
    339     data = load(box)
    340     data.update({"name": name, "slug": f"htb-{box}", "os": os_, "difficulty": diff})
    341     if ip:
    342         data["ip"] = ip
    343     data["hostnames"] = sorted(set(data["hostnames"]) | set(hosts), key=len, reverse=True)
    344     if hosts and not data["domain"]:
    345         data["domain"] = guess_domain(data["hostnames"])
    346     data["tags"] = sorted(set(data["tags"]) | set(tags))
    347     save(box, data)
    348     render_writeup(d, data)
    349 
    350     set_current(box)
    351     point_target(data)
    352     info(paint(f"{'updated' if existed else 'new box'} {name}", "1;35", sys.stderr)
    353          + f"  {data['ip'] or '(no ip)'}  {os_}  {diff}"
    354          + (f"  {' '.join(data['hostnames'])}" if data["hostnames"] else ""))
    355     info(paint("  cd $BOXDIR  ·  htbbox info  ·  htbpaths", "2", sys.stderr))
    356     print(d)
    357 
    358 
    359 def guess_domain(hosts):
    360     # Shortest name with exactly one dot is usually the AD domain (sequel.htb).
    361     two = [h for h in hosts if h.count(".") == 1]
    362     return min(two, key=len) if two else ""
    363 
    364 
    365 def cmd_use(args):
    366     box, rest = pop_box(args)
    367     box = resolve(box or (rest[0] if rest else None))
    368     data = load(box)
    369     set_current(box)
    370     point_target(data)
    371     info(f"current box: {data['name']}  {data['ip'] or '(no ip)'}")
    372     print(boxdir(box))
    373 
    374 
    375 def cmd_ls(_args):
    376     if not os.path.isdir(ROOT):
    377         print("no boxes yet -- htbbox new")
    378         return
    379     cur = current()
    380     entries = []
    381     for b in os.listdir(ROOT):
    382         d = boxdir(b)
    383         if not os.path.isdir(d):
    384             continue
    385         j = os.path.join(d, "box.json")
    386         entries.append((os.path.getmtime(j if os.path.exists(j) else d), b))
    387     if not entries:
    388         print("no boxes yet -- htbbox new")
    389         return
    390     rows = []
    391     for _, b in sorted(entries, reverse=True):
    392         if os.path.exists(os.path.join(boxdir(b), "box.json")):
    393             x = load(b)
    394             rows.append(("*" if b == cur else " ", b, x["ip"] or "-", x["os"], x["difficulty"] or "-",
    395                          x["status"], x["created"]))
    396         else:
    397             rows.append(("*" if b == cur else " ", b, "(no manifest)", "", "", "", ""))
    398     head = ("", "BOX", "IP", "OS", "DIFF", "STATUS", "CREATED")
    399     widths = [max(len(r[i]) for r in rows + [head]) for i in range(len(head))]
    400     fmt = lambda r: "  ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip()  # noqa: E731
    401     out = [paint(fmt(head), "2")]
    402     for r in rows:
    403         line = fmt(r)
    404         out.append(paint(line, "1;35") if r[0] == "*" else line)
    405     print("\n".join(out))
    406 
    407 
    408 def cmd_info(args):
    409     box, rest = pop_box(args)
    410     box = resolve(box or (rest[0] if rest else None))
    411     x = load(box)
    412     d = boxdir(box)
    413     k = lambda s: paint(f"{s:<11}", "2")  # noqa: E731
    414     flag = lambda f: paint("✓ " + (f.get("at", "")[:16]), "32") if f else paint("·", "2")  # noqa: E731
    415     lines = [
    416         paint(f"{x['name']}", "1;35") + paint(f"  {x['slug']}" + ("  (current)" if box == current() else ""), "2"),
    417         f"{k('ip')}{x['ip'] or '-'}",
    418         f"{k('os')}{x['os']}",
    419         f"{k('difficulty')}{x['difficulty'] or '-'}",
    420         f"{k('status')}{x['status']}",
    421     ]
    422     if x["domain"]:
    423         lines.append(f"{k('domain')}{x['domain']}")
    424     if x["hostnames"]:
    425         lines.append(f"{k('hostnames')}{' '.join(x['hostnames'])}")
    426     lines.append(f"{k('flags')}user {flag(x['flags'].get('user'))}   root {flag(x['flags'].get('root'))}")
    427     lines.append(f"{k('created')}{x['created']}   updated {x['updated'][:16]}")
    428     lines.append(f"{k('path')}{d}")
    429     if x["ports"]:
    430         lines.append("")
    431         lines.append(paint("ports", "1"))
    432         for p in x["ports"]:
    433             svc = " ".join(s for s in (p.get("service"), p.get("product"), p.get("version")) if s)
    434             lines.append(f"  {str(p['port']) + '/' + p.get('proto', 'tcp'):<10} {svc}")
    435     if x["creds"]:
    436         lines.append("")
    437         lines.append(paint("creds", "1"))
    438         for c in x["creds"]:
    439             lines.append(f"  {c['user']:<20} {c['secret']:<28} {paint(c.get('note', ''), '2')}")
    440     if x["notes"]:
    441         lines.append("")
    442         lines.append(paint("notes", "1"))
    443         for n in x["notes"][-8:]:
    444             lines.append(f"  {paint(n['at'][5:16], '2')}  {n['text']}")
    445         if len(x["notes"]) > 8:
    446             lines.append(paint(f"  … {len(x['notes']) - 8} older (htbbox note)", "2"))
    447     lines.append("")
    448     counts = "  ".join(f"{s} {len(os.listdir(os.path.join(d, s))) if os.path.isdir(os.path.join(d, s)) else 0}"
    449                        for s in SUBDIRS)
    450     lines.append(paint(counts, "2"))
    451     print("\n".join(lines))
    452 
    453 
    454 def cmd_path(args):
    455     box, rest = pop_box(args)
    456     print(boxdir(resolve(box or (rest[0] if rest else None))))
    457 
    458 
    459 def cmd_json(args):
    460     box, rest = pop_box(args)
    461     # `htbbox json ip` (key on the current box) vs `htbbox json sauna`.
    462     key = None
    463     if rest and not box and os.path.isdir(boxdir(slugify(rest[0]))) and rest[0] not in load_keys():
    464         box, rest = rest[0], rest[1:]
    465     if rest:
    466         key = rest[0]
    467     x = load(resolve(box))
    468     if key:
    469         for part in key.split("."):
    470             x = x.get(part) if isinstance(x, dict) else None
    471         if isinstance(x, (dict, list)):
    472             print(json.dumps(x, indent=2))
    473         elif x is not None:
    474             print(x)
    475         return
    476     print(json.dumps(x, indent=2))
    477 
    478 
    479 def load_keys():
    480     return {"name", "slug", "ip", "os", "difficulty", "hostnames", "domain", "platform", "status",
    481             "created", "updated", "flags", "creds", "ports", "notes", "tags", "schema"}
    482 
    483 
    484 def cmd_set(args):
    485     box, rest = pop_box(args)
    486     if len(rest) < 2:
    487         die(f"usage: htbbox set <key> <value>   keys: {' '.join(SETTABLE)}")
    488     key, value = rest[0], " ".join(rest[1:])
    489     box = resolve(box)
    490     x = load(box)
    491     if key == "ip":
    492         value = norm_ip(value)
    493     elif key == "os":
    494         value = norm_os(value)
    495     elif key == "difficulty":
    496         value = norm_difficulty(value)
    497     elif key == "status" and value not in STATUSES:
    498         die(f"status must be one of {', '.join(STATUSES)}")
    499     elif key not in SETTABLE:
    500         die(f"can't set '{key}' -- settable: {' '.join(SETTABLE)} (or edit box.json)")
    501     x[key] = value
    502     save(box, x)
    503     if key == "ip" and box == current():
    504         point_target(x)
    505     info(f"{x['name']}: {key} = {value}")
    506 
    507 
    508 def cmd_host(args):
    509     box, rest = pop_box(args)
    510     if not rest:
    511         die("usage: htbbox host <name...>")
    512     for h in rest:
    513         if not is_hostname(h):
    514             die(f"not a hostname: {h}")
    515     box = resolve(box)
    516     x = load(box)
    517     x["hostnames"] = sorted(set(x["hostnames"]) | set(rest), key=len, reverse=True)
    518     if not x["domain"]:
    519         x["domain"] = guess_domain(x["hostnames"])
    520     save(box, x)
    521     if box == current():
    522         point_target(x)
    523     info(f"{x['name']}: hostnames {' '.join(x['hostnames'])}")
    524 
    525 
    526 def cmd_cred(args):
    527     box, rest = pop_box(args)
    528     box = resolve(box)
    529     x = load(box)
    530     if not rest:
    531         if not x["creds"]:
    532             print("no creds yet -- htbbox cred <user> <secret> [note]")
    533         for c in x["creds"]:
    534             print(f"{c['user']}\t{c['secret']}\t{c.get('note', '')}")
    535         return
    536     if len(rest) < 2:
    537         die("usage: htbbox cred <user> <secret> [note...]")
    538     user, secret, note = rest[0], rest[1], " ".join(rest[2:])
    539     kind = "hash" if re.fullmatch(r"[0-9a-fA-F]{32}(:[0-9a-fA-F]{32})?", secret) else "password"
    540     x["creds"].append({"user": user, "secret": secret, "type": kind, "note": note, "at": now()})
    541     save(box, x)
    542     # Keep a plain user/pass list beside it, for nxc/hydra -U/-P style use.
    543     with open(os.path.join(boxdir(box), "creds", "creds.txt"), "a") as fh:
    544         fh.write(f"{user}:{secret}\n")
    545     info(f"{x['name']}: cred {user} ({kind}) -- {len(x['creds'])} total")
    546 
    547 
    548 def cmd_flag(args):
    549     box, rest = pop_box(args)
    550     if len(rest) != 2 or rest[0] not in ("user", "root"):
    551         die("usage: htbbox flag <user|root> <value>")
    552     which, value = rest
    553     if not re.fullmatch(r"[0-9a-fA-F]{32}", value):
    554         info("htbbox: note -- that is not a 32-char hex flag; saved anyway")
    555     box = resolve(box)
    556     x = load(box)
    557     x["flags"][which] = {"value": value, "at": now()}
    558     if which == "root" or x["status"] == "active":
    559         x["status"] = which
    560     save(box, x)
    561     info(paint(f"{x['name']}: {which} flag recorded", "1;32", sys.stderr))
    562 
    563 
    564 def cmd_note(args):
    565     box, rest = pop_box(args)
    566     box = resolve(box)
    567     x = load(box)
    568     if not rest:
    569         for n in x["notes"]:
    570             print(f"{n['at'][:16]}  {n['text']}")
    571         if not x["notes"]:
    572             print("no notes yet -- htbbox note <text>")
    573         return
    574     x["notes"].append({"at": now(), "text": " ".join(rest)})
    575     save(box, x)
    576     info(f"{x['name']}: note added ({len(x['notes'])})")
    577 
    578 
    579 def cmd_ports(args):
    580     box, rest = pop_box(args)
    581     box = resolve(box)
    582     d = boxdir(box)
    583     if rest:
    584         xml = rest[0]
    585     else:
    586         found = []
    587         for root, _, files in os.walk(os.path.join(d, "recon")):
    588             found += [os.path.join(root, f) for f in files if f.endswith(".xml")]
    589         if not found:
    590             die("no nmap XML in recon/ -- scan with -oA recon/<name> (or -oX), then rerun")
    591         xml = max(found, key=os.path.getmtime)
    592     try:
    593         tree = ET.parse(xml)
    594     except (ET.ParseError, OSError) as e:
    595         die(f"can't read {xml}: {e}", 1)
    596     ports = {}
    597     x = load(box)
    598     for p in x["ports"]:
    599         ports[(p["port"], p.get("proto", "tcp"))] = p
    600     for port in tree.iter("port"):
    601         st = port.find("state")
    602         if st is None or st.get("state") != "open":
    603             continue
    604         svc = port.find("service")
    605         entry = {"port": int(port.get("portid")), "proto": port.get("protocol", "tcp")}
    606         if svc is not None:
    607             for a in ("name", "product", "version"):
    608                 if svc.get(a):
    609                     entry["service" if a == "name" else a] = svc.get(a)
    610         ports[(entry["port"], entry["proto"])] = entry
    611     x["ports"] = sorted(ports.values(), key=lambda p: (p["proto"], p["port"]))
    612     save(box, x)
    613     info(f"{x['name']}: {len(x['ports'])} open ports (from {os.path.relpath(xml, d)})")
    614     for p in x["ports"]:
    615         print(f"{p['port']}/{p['proto']}\t{p.get('service', '')}\t{p.get('product', '')} {p.get('version', '')}".rstrip())
    616 
    617 
    618 # ── writeup ─────────────────────────────────────────────────────────────────
    619 
    620 def render_writeup(outdir, x):
    621     """Render writeup.md once. Never overwrites: it is your prose after this."""
    622     writeup = os.path.join(outdir, "writeup.md")
    623     if os.path.exists(writeup):
    624         return
    625     today = dt.date.today().isoformat()
    626     body = None
    627     if os.path.exists(TEMPLATE):
    628         with open(TEMPLATE) as fh:
    629             body = fh.read()
    630         # Drop the Templater header: <%* ... -%> (the JS that prompts in Obsidian).
    631         body = re.sub(r"^<%\*.*?-%>\n", "", body, count=1, flags=re.S)
    632         subs = {
    633             "<% yaml(machine) %>": json.dumps(x["name"]),
    634             "<% yaml(slug) %>": json.dumps(x["slug"]),
    635             "<% yaml(targetOS) %>": json.dumps(x["os"]),
    636             "<% yaml(difficulty) %>": json.dumps(x["difficulty"]),
    637             '<% tp.date.now("YYYY-MM-DD") %>': today,
    638             "<% targetOS %>": x["os"],
    639             "<% difficulty %>": x["difficulty"],
    640             "<% tp.file.cursor() %>": "",
    641         }
    642         for token, value in subs.items():
    643             body = body.replace(token, value)
    644         body = body.replace('ip: ""', f"ip: {json.dumps(x['ip'])}")
    645         # A leftover <% ... %> means the vault template grew a token this
    646         # renderer does not know. Fail loudly rather than publish Templater source.
    647         leftover = re.findall(r"<%.*?%>", body, flags=re.S)
    648         if leftover:
    649             die(f"template has tokens this renderer does not handle: {leftover[:3]} -- update _htbbox.py", 1)
    650     if body is None:
    651         body = SKELETON.format(
    652             name=json.dumps(x["name"]), slug=json.dumps(x["slug"]), os=json.dumps(x["os"]),
    653             difficulty=json.dumps(x["difficulty"]), today=today, ip=json.dumps(x["ip"]),
    654             ip_raw=x["ip"], os_raw=x["os"], difficulty_raw=x["difficulty"],
    655             hosts=" ".join(x["hostnames"]) or "-",
    656         )
    657         info("htbbox: vault template not found, used the built-in skeleton")
    658     with open(writeup, "w") as fh:
    659         fh.write(body)
    660 
    661 
    662 SKELETON = """\
    663 ---
    664 title: {name}
    665 slug: {slug}
    666 type: writeup
    667 site: daemon-sec
    668 category: ctf
    669 platform: HTB-Labs
    670 machine: {name}
    671 target_os: {os}
    672 difficulty: {difficulty}
    673 author: DAEMON
    674 excerpt: ""
    675 status: active
    676 publish_status: draft
    677 creation_date: {today}
    678 published_at: ""
    679 updated_at: ""
    680 ip: {ip}
    681 tools_used: []
    682 techniques: []
    683 bannerImage: ""
    684 tags:
    685   - HTB
    686   - HTB/Labs
    687 cssclasses:
    688   - editorial
    689   - note-banner
    690 ---
    691 
    692 ```dataviewjs
    693 await dv.view("00Meta/Views/NoteBanner");
    694 ```
    695 
    696 ## Explain like I'm new
    697 
    698 ## Attack path
    699 
    700 ## Target details
    701 
    702 | Field | Value |
    703 | --- | --- |
    704 | IP Address | {ip_raw} |
    705 | Hostnames | {hosts} |
    706 | Operating system | {os_raw} |
    707 | Difficulty | {difficulty_raw} |
    708 
    709 ## Reconnaissance
    710 
    711 ## Enumeration
    712 
    713 ## Initial access
    714 
    715 ## Privilege escalation
    716 
    717 ## Credentials and flags
    718 
    719 ## Operator notes
    720 
    721 ## Lessons learned
    722 
    723 ## References
    724 """
    725 
    726 
    727 # ── main ────────────────────────────────────────────────────────────────────
    728 
    729 COMMANDS = {
    730     "new": cmd_new, "use": cmd_use, "switch": cmd_use,
    731     "ls": cmd_ls, "list": cmd_ls,
    732     "info": cmd_info, "show": cmd_info,
    733     "path": cmd_path, "json": cmd_json, "set": cmd_set,
    734     "host": cmd_host, "hosts": cmd_host,
    735     "cred": cmd_cred, "creds": cmd_cred,
    736     "flag": cmd_flag, "note": cmd_note, "notes": cmd_note,
    737     "ports": cmd_ports,
    738 }
    739 
    740 
    741 def main(argv):
    742     if not argv:
    743         argv = ["info"] if current() and os.path.isdir(boxdir(current())) else ["ls"]
    744     cmd, args = argv[0], argv[1:]
    745     if cmd in ("-h", "--help", "help"):
    746         print(USAGE, end="")
    747         return
    748     fn = COMMANDS.get(cmd)
    749     if not fn:
    750         print(USAGE, end="", file=sys.stderr)
    751         die(f"unknown command: {cmd}")
    752     fn(args)
    753 
    754 
    755 if __name__ == "__main__":
    756     try:
    757         main(sys.argv[1:])
    758         sys.stdout.flush()
    759     except BrokenPipeError:
    760         # `htbbox ls | grep -q x` closes the pipe early; that is success, not
    761         # an error. Point stdout at devnull so the interpreter's own flush at
    762         # exit does not raise a second time.
    763         os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
    764         sys.exit(0)
    765     except KeyboardInterrupt:
    766         sys.exit(130)