NoiseHound
Detection-aware Active Directory attack-path scoring. DreadHost Research | companion to OffsetInspect (PowerShell) and OffsetScan (Rust)
BloodHound (and PlumHound on top of it) finds a path to the objective. NoiseHound ingests the same graph data and re-ranks paths by expected detection cost instead of hop count, so an operator can ask "what is the quietest way to Domain Admin" instead of just "what is a way".
Project status (v1.0): stable and tested on real BloodHound data across multiple domains. 30 of 57 corpus edges are now lab-measured across four detection tiers (audit, Defender for Endpoint, Elastic SIEM, and MDI posture) - shipped as drop-in profiles in
profiles/, with closed-loop proof that they change path rankings (docs/VALIDATION.md). The remaining ~28 edges still carry expert estimates; the calibration harness (noisehound-calibrate,docs/CALIBRATION.md) is how they, and your own environment, get measured. Treat uncalibrated rankings as well-reasoned guidance, not ground truth.
For authorized engagements only. This tool scores attack paths for OPSEC planning against systems you have written permission to test.
NoiseHound is an independent community project. It is not affiliated with, endorsed by, or associated with SpecterOps or the BloodHound project; it consumes BloodHound's open data format.
How it works
- Ingest a BloodHound CE export (
.zip), a raw JSON file, or a directory of exports into an internal graph. A normalised{nodes, edges}JSON format is also accepted for offline analysis and tests. AD CS ESC1-8 escalation edges are synthesised at load time from the certificate-template and CA facts BloodHound collects (see below). - Annotate every edge from the edge-telemetry corpus, attaching an
effective_noise_score(0-100). Where several rights connect the same pair of nodes, the quietest is chosen. Edge types absent from the corpus default to a conservative score (60) so gaps fail safe rather than under-reporting. An optional environment profile adjusts scores for the target's declared detection posture (see below). - Solve for the quietest paths. Because the path score is a bottleneck plus mean (not a simple sum), it cannot be optimised directly by Dijkstra. The solver combines a threshold sweep (for each distinct noise level, the quietest route that stays under it) with a bounded k-shortest-by-weight pass, then re-ranks the union by the real path score. The threshold sweep is the correctness backstop: it surfaces a long-but-uniformly-quiet path that a pure summed-weight search would rank below a short-but-loud one.
- Report as text, JSON (interoperable with the OffsetInspect result schema), or a self-contained HTML report styled to match the toolset.
Path scoring
Path noise is deliberately not a simple sum. Tripping the same detection twice is not twice as loud (SOC triage, not raw event count). NoiseHound uses:
path_score = max(edge_scores) * 0.6 + mean(edge_scores) * 0.4
This weights toward the loudest single step (one bad step often burns the whole
op) while still accounting for cumulative exposure. The weights are configurable
(--max-weight / --mean-weight) so they can be tuned empirically once real
detection data is available from an APT29/Caldera lab.
Every path also reports a detection probability - the chance it trips a
correlated alert - blending the loudest edge with the cumulative noisy-OR of all
edges (tuned by --correlation). It answers a different question than the noise
score: a short but loud path can have a lower overall probability of being
caught than a long but quiet one. Rank by it with --rank-by probability.
Two-tier engine (DeadAir)
For large graphs the solve is dispatched to DeadAir, a companion Rust engine (the OffsetScan-to-OffsetInspect tier). NoiseHound stays the feature-rich frontend - ingestion, corpus, environment/Sigma, constraints, reporting - and hands the prepared graph to whichever engine solves it, so results are identical either way.
--engine auto(default): DeadAir when its binary is found and the graph is large (>= 5000 nodes); the built-in Python solver otherwise.--engine python: force the built-in solver (no binary needed).--engine rust: force DeadAir (errors if the binary is missing).
DeadAir is found via $NOISEHOUND_DEADAIR, then PATH, then the sibling
../deadair/target/{release,debug}/ build. It is 10-100x faster on large graphs
(a 250k-node graph solves in ~2s vs ~30s in Python) while producing byte-identical
rankings. The output records which engine ran.
Multi-objective and constrained pathing
Noise, hop count, and detection probability pull in different directions, so
--pareto returns the Pareto frontier - every path that no other beats on
all three at once - instead of forcing a single winner. And real operations
have constraints: --avoid NODE keeps a path off a specific host (an
EDR-monitored jump box, a honeypot), and --avoid-edge TYPE refuses a technique
(e.g. --avoid-edge DCSync). Both are repeatable and re-solve on the fly.
python -m noisehound -i export.zip -s jdoe -o "Domain Admins" --pareto
python -m noisehound -i export.zip -s jdoe -o "Domain Admins" --avoid FILESERVER01 --avoid-edge HasSession
How NoiseHound compares
Weighted BloodHound pathfinding is not new, so here is the honest positioning:
- BloodHound / BloodHound CE find a path by unweighted hop count. No noise model.
- GoodHound assigns edge costs and finds cheapest paths, but its cost model is exploitation difficulty and business risk, not detection noise.
- PlumHound / ImproHound do reporting and tier-violation analysis; neither re-solves for a quietest-to-objective path.
- Detection mappings (Sigma, DeTT&CT, event-ID-to-ATT&CK references) are rich but human-oriented and not keyed to BloodHound edge kinds.
NoiseHound's contribution is the combination: a machine-readable BloodHound-edge-to-detection-telemetry corpus, a noise-weighted re-solve framed as operator OPSEC ("quietest way to DA"), an environment model that adapts to a target's declared posture, and a calibration loop that turns lab detections into measured scores. The graph math is commodity; the corpus and the framing are the point. Its value is only as good as the corpus, which is why calibration and community contribution are first-class - see below.
Install
cd NoiseHound
python -m pip install -r requirements.txt # networkx>=3.0
# optional editable install to get the `noisehound` command on PATH:
python -m pip install -e .
Requires Python 3.10+.
Usage
# Text summary (default)
python -m noisehound --input export.zip --objective "Domain Admins" --source jdoe
# JSON, for downstream tooling / correlation across the DreadHost suite
python -m noisehound -i export.zip -o "Domain Admins" -s jdoe -f json --out paths.json
# Self-contained HTML report
python -m noisehound -i export.zip -o "Domain Admins" -s jdoe -f html --out report.html
First, sanity-check the parser on your export (histograms + corpus coverage, no pathing) - the fastest way to validate NoiseHound on real-world data:
noisehound-inspect -i export.zip
Live BloodHound CE / Neo4j
Instead of a zip, point --input at the Neo4j database BloodHound CE populates
and NoiseHound reads the (already analysed) graph directly over Bolt:
pip install 'noisehound[neo4j]'
export NEO4J_PASSWORD=bloodhoundcommunityedition # match your BHCE compose
noisehound-inspect -i bolt://localhost:7687
python -m noisehound -i bolt://localhost:7687 -s jdoe -o "Domain Admins"
Stand up BloodHound CE (bundles Neo4j on 7687) with its official compose:
curl -L https://ghst.ly/getbhce | docker compose -f - up.
Try it against the bundled samples:
python -m noisehound -i samples/sample_graph.json -s jdoe -o "Domain Admins" -d CONTOSO.LOCAL
python -m noisehound -i samples/sample_bloodhound_ce.zip -s jdoe -o "Domain Admins"
python -m noisehound -i samples/sample_adcs_ce.zip -s jdoe -o "Domain Admins" # ADCS ESC1
# Full-spectrum export exercising every edge family (sessions, delegation, DCSync, ADCS, trusts):
python -m noisehound -i samples/sample_fullspectrum_ce.zip -s ALICE -o "Domain Admins" -d CONTOSO.LOCAL -k 3
That last one is the clearest demo of the thesis: the quietest route to Domain Admins is the 4-hop session path, ranked above the 3-hop RDP path and the 1-hop ADCS ESC1 - most hops, least noise.
The sample demonstrates the core value: the quietest route is a 4-hop session path (score 19.9), ranked above a 2-hop ForceChangePassword shortcut (36.4). Fewer hops does not mean quieter.
Blue-team detection-gap mode
The quietest path is where detection is weakest, so add --defensive to flip
the output for defenders: it flags the edges that are quiet only because their
telemetry is off or absent, maps each to the control that would catch it, and
ranks those controls by how much they raise the quietest-path score.
python -m noisehound -i export.zip -s jdoe -o "Domain Admins" --defensive
On the full-spectrum sample it finds that the quietest path to Domain Admins
hinges on undetected LSASS access (HasSession, 20 -> 65 if instrumented) and
recommends deploying Sysmon Event 10 - closing that one gap lifts the quietest
path from 19.9 to 48.4. See docs/ROADMAP.md for where this
and the rest of the model are headed.
Key options
| Option | Meaning |
|---|---|
--input, -i |
BloodHound .zip, .json, or directory of exports |
--source, -s |
Starting principal (jdoe or an object id) |
--objective, -o |
Target node (Domain Admins or an object id) |
--paths, -k |
Number of quietest paths to return (default 5) |
--format, -f |
text (default), json, or html |
--defensive |
Blue-team view: detection gaps on the quietest paths + fixes |
--rank-by |
noise (default) or probability (P of a correlated alert) |
--correlation |
SOC correlation coefficient for P(detected), 0..1 (default 0.5) |
--pareto |
Return the Pareto frontier over noise/hops/P(detect) |
--engine |
auto (default), python, or rust (the DeadAir engine) |
--avoid NODE |
Exclude a node from all paths (repeatable) |
--avoid-edge TYPE |
Exclude an edge type from all paths (repeatable) |
--corpus |
Override the edge-mapping corpus directory |
--environment, -e |
Operator-declared target posture JSON (adjusts scores) |
--max-weight / --mean-weight |
Scoring weights (must sum to 1.0) |
--default-noise |
Score for edge types absent from the corpus (default 60) |
Environment profiles
A static corpus score cannot know whether a given target has 4662 object auditing on, ships Sysmon, or runs an ITDR like MDI - yet those move an edge's real noise enormously (DCSync is near-silent without 4662 auditing and near-certain-detection with it). Instead of pretending one number fits every environment, declare the target's posture in a small JSON file and NoiseHound adjusts scores transparently against the corpus's own telemetry annotations:
{
"name": "CONTOSO.LOCAL-prod",
"object_auditing_4662": true,
"ds_change_auditing_5136": false,
"edr": "MDI",
"sysmon": true,
"powershell_logging_4104": true,
"adjustments": { "HasSession": 65 }
}
Adjustments only ever raise a score toward a detection floor implied by the
declared posture. adjustments are hard per-edge overrides - the place to
record values you have calibrated against your own lab. This is operator-supplied,
not measured; it does not replace Phase 2 live validation, but it turns the
static corpus from "one number for all environments" into "the number for the
environment you are actually in". On the bundled sample, declaring the profile
above flips the quietest route from the LSASS-dump session path to a
directory-write path - which is the correct call once host telemetry is live.
python -m noisehound -i samples/sample_graph.json -s jdoe -o "Domain Admins" \
-e samples/env_profile.example.json
Score precedence: static -> environment-adjusted -> live (Phase 2).
Calibration harness
Environment profiles are only as good as the numbers you put in them.
noisehound-calibrate closes the loop: run the techniques in a detection lab,
record what fired, and it emits a calibrated environment profile.
This has been done. profiles/ ships three measured profiles from
a real Hyper-V Vulnerable-AD range - audit, EDR (Defender for Endpoint), and Elastic
SIEM tiers, 30 edges - produced by the automated harness (lab/) and this tool. Use
them directly, or measure your own:
# Use a shipped measured profile:
noisehound -i export.zip -s jdoe -o "Domain Admins" -e profiles/vulnad-hyperv-audit.json
# Or measure your own lab:
# 1. noisehound-calibrate --plan -o plan.json (per-edge detection events)
# 2. lab/Invoke-NoiseHoundCalibration.ps1 (run + auto-count -> lab_detections.json)
noisehound-calibrate -i lab_detections.json -o env.calibrated.json
noisehound -i export.zip -s jdoe -o "Domain Admins" -e env.calibrated.json
The score model is a shrinkage estimator, honest about sample size:
p = detections / runs (detection probability)
lab_score = p * severity_loudness + (1 - p) * residual
w = runs / (runs + smoothing) (confidence in the lab)
calibrated = w * lab_score + (1 - w) * corpus_static
lab_score is the expected detection cost - the SOC severity when it fires,
a small residual when it does not. The weight w keeps a single run from
overriding the corpus while letting a well-sampled result dominate. On the
example, HasSession climbs from a static 20 to 52 (the lab caught the LSASS
dump 4 of 5 runs) while Kerberoast drops from 60 to 34 (it never fired). Use
--merge existing.json to overlay new calibration onto a profile while keeping
its posture flags, and --smoothing / --residual to tune the model.
Score against deployed detections (Sigma)
Environment profiles and calibration are self-declared. noisehound-sigma
scores against the detections a defender has actually written: point it at a
Sigma rule set and it works out which corpus edges each rule would fire on
(matching the edge's telemetry event IDs and ATT&CK technique), then emits an
environment profile that raises the covered edges - a drop-in for --environment.
noisehound-sigma -r ./sigma-rules/ -o env.sigma.json
python -m noisehound -i export.zip -s jdoe -o "Domain Admins" -e env.sigma.json --defensive
Matching is deliberately conservative so it never hides a gap: a rule only
counts if it references an event ID the edge generates and, when the rule is
ATT&CK-tagged, its technique agrees - so a DS-Access (4662) DCSync rule is not
miscredited with covering LAPS reads that merely share the event ID. The report
lists both what your rules cover and, more usefully, the attack edges no rule
covers. Combined with --defensive, this answers "given the detections I have
deployed, where is my quietest attack path still invisible?"
AD CS ESC1-8
At load time NoiseHound performs a focused version of BloodHound's ADCS
post-processing, synthesising escalation edges from the retained template/CA
facts. An Enroll right on a vulnerable template becomes a direct
ADCSESCn edge from the principal to the domain's Domain Admins group (RID 512),
so certificate escalation is pathable and scored like any other edge:
| Edge | Condition |
|---|---|
ADCSESC1 |
Enrollee-supplies-subject + client-auth EKU, no approval/RA sigs |
ADCSESC2 |
Any-Purpose / SubCA EKU, no approval |
ADCSESC3 |
Enrollment-agent template + an auth template on the same CA |
ADCSESC4 |
Dangerous write control over a published template |
ADCSESC5 |
Control of the CA object or its hosting computer |
ADCSESC6 |
CA has EDITF_ATTRIBUTESUBJECTALTNAME2 set |
ADCSESC7 |
ManageCA / ManageCertificates on the CA |
ADCSESC8 |
Vulnerable HTTP web-enrollment endpoint (coerce + NTLM relay) |
Try it: python -m noisehound -i samples/sample_adcs_ce.zip -s jdoe -o "Domain Admins".
Documented simplifications (they fail safe toward showing more paths): CA-level enrollment restrictions are not modelled (template enrollment is treated as sufficient); ESC5 covers the CA object and its host, not every PKI container; ESC9/10/13 are out of scope. Synthetic ESC edges already present in a post-processed export are preserved.
The corpus (edge_mappings/)
The edge-telemetry corpus is the actual IP of this tool; the code is
comparatively simple graph math on top of it. Each edge_mappings/<Edge>.json
maps a BloodHound edge type to its detection surface:
- expected telemetry sources (Windows Security Event IDs, Sysmon Event IDs, network, EDR/ITDR heuristics) with per-source reliability and whether the relevant auditing is on by default
- a static noise score (0-100)
- MITRE technique, prerequisite privilege, the usual abuse primitive, and notes
{
"edge_type": "DCSync",
"static_noise_score": 85,
"telemetry": [
{"source": "windows_security", "event_id": 4662,
"detail": "Directory Service Access - requires object auditing (default OFF)",
"reliability": "high_if_auditing_enabled", "default_enabled": false},
{"source": "edr_heuristic", "product_class": "MDI",
"detail": "Non-DC hosts issuing DRSGetNCChanges - high fidelity",
"reliability": "high"}
],
"mitre_technique": "T1003.006",
"notes": "Assumes default audit policy (mostly OFF) but high EDR/MDI coverage."
}
Extending the corpus is where most ongoing effort should go. Add a new JSON file, keep the schema (validated at load time), and it is picked up automatically. v0.3 ships 43 edge types covering the ACL-abuse, Kerberos, delegation, ADCS ESC1-8, LAPS/gMSA, GPO, trust, and access-right edges that matter for path-finding.
Scores are seeded from the DreadHost Red Team Operator Playbook noise matrix (Windows/Sysmon event mappings, technique noise ratings) and standard AD detection facts. Tune them against your own lab detection data.
Roadmap
- Calibration - done for 30/57 edges, continuing. Three measured profiles ship
in
profiles/(audit / EDR / Elastic tiers) with closed-loop validation. Remaining: the other ~28 edges (coercion/relay, ADCS ESC2-13, CanRDP), the MDI runtime-alert tier (posture works; the alert path needs a bare-metal/ Ludus DC - seedocs/CALIBRATION.md), and a selectable tooling-profile axis (docs/TOOLING_AXIS.md) so scores reflect off-the-shelf vs native tradecraft. - Phase 2 - live detection validation. Replace static scores with
live_noise_scorepulled from a real target's Defender/Sysmon/audit config, reusing OffsetInspect's detection-boundary logic.annotate()already accepts alive_scoresoverride; the CLI hook lands in Phase 2. - Live Neo4j ingestion over Bolt against the same DB SharpHound populates (offline zip ingestion ships now).
- ADCS ESC9/10/13 synthesis (ESC1-8 ship now).
- Rust port of the scoring engine (petgraph), mirroring the OffsetInspect -> OffsetScan pattern, once the data model is proven.
Contributing
The corpus is community-extensible and that is where contributions matter most. Adding an edge is one JSON file validated on load and in CI:
noisehound-validate # schema + consistency checks over the corpus
python -m pytest tests/ -q
See CONTRIBUTING.md for the schema, the scoring guide, and PR
guidance, and docs/edge_schema.json for the formal
edge schema.
Calibrating against a lab
30 edges are measured (see profiles/); the rest are estimates until
you measure them, and every environment differs. docs/CALIBRATION.md
is a full playbook: lab topology, the exact Windows audit policy and Sysmon
config to make the corpus's event IDs fire, a per-edge exercise runbook, an
APT29-via-Caldera realism layer, and how to compile results into a calibrated
profile. Start with noisehound-calibrate --template -o lab_detections.json.
The lab/ kit automates the detection instrumentation:
Enable-Telemetry.ps1 turns on the audit policy, script-block logging, DCSync
SACL, and Sysmon; Collect-Detections.ps1 tallies what fired in a window. It
leans on GOAD or
Vulnerable-AD (or your CRTP/CRTO
lab) for the vulnerable domain itself rather than reimplementing them.
Responsible use
NoiseHound is for authorized security testing, purple-team exercises, detection engineering, and research. It reads BloodHound data you already collected and computes rankings; it executes nothing against a target. Use it only where you have explicit written authorization. Contributions must not include target-specific or real-engagement data.
Testing
python -m pytest tests/ # with pytest
python tests/test_noisehound.py # dependency-light smoke run
Layout
noisehound/ engine: schema, corpus, ingest, adcs, annotate, environment,
solver, report, cli, calibrate
edge_mappings/ the telemetry corpus (one JSON per edge type) - the IP
samples/ sample_graph.json, sample_bloodhound_ce.zip, sample_adcs_ce.zip,
sample_fullspectrum_ce.zip, env_profile.example.json,
lab_detections.example.json, sample_report.html
tests/ unit + end-to-end tests
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file noisehound-1.0.0.tar.gz.
File metadata
- Download URL: noisehound-1.0.0.tar.gz
- Upload date:
- Size: 87.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c8444445ef922e27c9cbdd3e1d49cc945c98e6dd50c7a56a615db2fff4d5ee55
|
|
| MD5 |
fdd3ed123b95c5ffddc73221e56aa6c5
|
|
| BLAKE2b-256 |
d233da8652a2a4e76bb6ba26c1fb123dbfde40ccf3718149d42c9c05d2a01699
|
Provenance
The following attestation bundles were made for noisehound-1.0.0.tar.gz:
Publisher:
publish.yml on warpedatom/NoiseHound
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
noisehound-1.0.0.tar.gz -
Subject digest:
c8444445ef922e27c9cbdd3e1d49cc945c98e6dd50c7a56a615db2fff4d5ee55 - Sigstore transparency entry: 2413636204
- Sigstore integration time:
-
Permalink:
warpedatom/NoiseHound@f3d90f3597fdb81fc37f5428e19578b6e137f1c3 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/warpedatom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f3d90f3597fdb81fc37f5428e19578b6e137f1c3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file noisehound-1.0.0-py3-none-any.whl.
File metadata
- Download URL: noisehound-1.0.0-py3-none-any.whl
- Upload date:
- Size: 103.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0b68cd593ac3e4a5a0396ac3fa96f3e381422cb15dfdc7fc4ca204a526a6760f
|
|
| MD5 |
a82edfbddf859422caf9f115f8b5d6ca
|
|
| BLAKE2b-256 |
a2f090f1bfe95ca2f084af89563fe51b872c80e9427e8f08e4ea559668a91de1
|
Provenance
The following attestation bundles were made for noisehound-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on warpedatom/NoiseHound
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
noisehound-1.0.0-py3-none-any.whl -
Subject digest:
0b68cd593ac3e4a5a0396ac3fa96f3e381422cb15dfdc7fc4ca204a526a6760f - Sigstore transparency entry: 2413636424
- Sigstore integration time:
-
Permalink:
warpedatom/NoiseHound@f3d90f3597fdb81fc37f5428e19578b6e137f1c3 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/warpedatom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f3d90f3597fdb81fc37f5428e19578b6e137f1c3 -
Trigger Event:
push
-
Statement type: