ConfigManBearPig brings Microsoft Configuration Manager (formerly SCCM) attack paths to BloodHound using OpenGraph. It is a Python rewrite of ConfigManBearPig, the PowerShell SCCM collector by Chris Thompson (@_Mayyhem) at SpecterOps. It is written on top of the OpenHound collector framework by SpecterOps.
Where the PowerShell tool was a single self-contained script, this version runs on the OpenHound framework's three-stage pipeline (collect → preprocess → convert), producing an OpenGraph dataset you can upload to BloodHound's File Ingest.
Questions? Reach out on the BloodHound Slack (@Mayyhem), on Twitter (@_Mayyhem), or open an issue.
- Quick Start
- Collection Overview
- System Requirements
- Assumptions
- Limitations
- Command Line Options
- Automating the Upload
- Graph Model
- Node Reference
- AD node naming
- Seed principals: what this collector creates vs. what it expects from AD collection
- Computer
- Container
- Group
- User
- SCCM_AdminUser
- SCCM_ClientDevice
- SCCM_Collection
- SCCM_SecurityRole
- SCCM_Site
- MSSQL_Database
- MSSQL_DatabaseRole
- MSSQL_DatabaseUser
- MSSQL_Login
- MSSQL_Server
- MSSQL_ServerRole
- Edge Reference
- Entity-panel help properties
- GenericAll
- HasSession
- MemberOf
- SCCM_AdminsReplicatedTo
- SCCM_AllPermissions
- SCCM_ApplicationAdministrator
- SCCM_ApplicationAuthor
- SCCM_AssignAllPermissions
- SCCM_CoerceAndRelayToAdminService
- SCCM_CoerceAndRelayToSMB
- SCCM_ComplianceSettingsManager
- SCCM_Contains
- SCCM_FullAdministrator
- SCCM_HasClient
- SCCM_HasMember
- SCCM_HasPrimaryUser / SCCM_HasCurrentUser / SCCM_HasADLastLogonUser
- SCCM_HasStoredAccount
- SCCM_IsAssigned
- SCCM_IsMappedTo
- SCCM_LocalAdminRequired
- SCCM_OperationsAdministrator
- SCCM_OSDManager
- SCCM_SameHostAs
- SCCM_SecurityAdministrator
- MSSQL_CoerceAndRelayToMSSQL
- MSSQL_Contains
- MSSQL_ControlDB
- MSSQL_ControlServer
- MSSQL_ExecuteOnHost
- MSSQL_GetAdminTGS
- MSSQL_GetTGS
- MSSQL_HasLogin
- MSSQL_HostFor
- MSSQL_IsMappedTo
- MSSQL_MemberOf
- MSSQL_ServiceAccountFor
- Attack path example — SQL sysadmin to SCCM site (mayyhem.com lab)
- Attack path example — Full Administrator to client device (mayyhem.com lab)
- Understanding the Codebase
- Testing Changes
- Contributing
- Work in Progress
- Reference Key
The collector is an OpenHound extension: it plugs into the openhound CLI as
openhound collect sccm.
Check this first: you want the PostgreSQL graph backend. Switching to PostgreSQL is the only supported way to get Search and Pathfinding working with OpenGraph data — so on a Neo4j-backed instance the
SCCM_*andMSSQL_*kinds will not turn up in the search box or in pathfinding results. Cypher still works either way, so a Neo4j instance can query this collector's graph; you are just limited to writing the queries yourself (the saved queries below help). https://bloodhound.specterops.io/get-started/custom-installation#postgresql
No SCCM lab handy?
sample_data/holds four real collections from the mayyhem.com test lab — privileged and unprivileged, each with and without--disable-possible-edges— as upload-ready zips, plus the full DEBUG log for each run. Enough to see the graph and read a collection end-to-end before building a hierarchy of your own.
uv tool install openhound --with configmanbearpig --prerelease=allowThat is the whole install. It pulls the OpenHound framework, this collector, and the
runtime dependencies (ldap3, impacket, dnspython, and — on Windows — pywin32 and
winkerberos), and puts openhound on your PATH.
Prefer pip? pip install openhound configmanbearpig into a virtualenv, with no extra flag.
(--prerelease=allow is a uv requirement, not a broken package — see Limitations.)
Working on the collector rather than using it? Clone this repository and run
uv sync --group dev from its root; every uv run openhound … below then exercises your
checkout instead of the installed release. See Testing Changes.
--run-all chains all three stages — collect, preprocess, convert — in one command. Run it from a
domain-joined Windows host and the domain and domain controller are auto-detected:
uv run openhound collect sccm .\out --run-all -vOn Linux/macOS, or to authenticate as someone other than the current user, supply them explicitly:
uv run openhound collect sccm .\out --run-all -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\lowpriv" -p "Passw0rd!" -vThat writes everything into .\out:
| Path | What it is |
|---|---|
sccm\<table>\*.jsonl |
The raw collected tables, one directory per table |
lookup.duckdb |
The DuckDB lookup database convert reads |
graph\sccm_nodes-*.json, graph\sccm_edges-*.json |
SCCM payload — every SCCM_* node, and every edge with at least one SCCM endpoint (including SCCM↔MSSQL) |
graph\mssql_nodes-*.json, graph\mssql_edges-*.json |
MSSQL payload — every MSSQL_* node, and every edge whose endpoints are both MSSQL nodes |
graph\ad_nodes-*.json, graph\ad_edges-*.json |
AD payload — Computer / User / Group / Container nodes and every edge touching one (including AD↔MSSQL) |
graph\configmanbearpig_collection_<timestamp>.zip |
Bundle — every graph .json above, flat-zipped for one-shot upload (written by --run-all only) |
collect_full_<timestamp>.log |
The complete DEBUG trace, grouped host-by-host and resource-by-resource |
collect_issues_<timestamp>.log |
Warnings and errors with tracebacks — absent from a clean run |
Upload all three payloads together; they are three parts of one graph — or upload the single
configmanbearpig_collection_<timestamp>.zip bundle in their place, which contains the same files
flat-zipped for one-shot upload. --run-all prints that bundle's full path as the last line of
the run, so you can copy it straight out of the terminal:
--run-all complete. Output files:
Output directory: C:\...\out
Raw data (JSONL): C:\...\out\sccm
Full log: C:\...\out\collect_full_20260801_193720.log
Issues log: C:\...\out\collect_issues_20260801_193720.log (9 warning(s)/error(s), with tracebacks)
Lookup DB: C:\...\out\lookup.duckdb
OpenGraph files (6):
C:\...\out\graph\ad_edges-1.json
...
Upload to BloodHound: C:\...\out\graph\configmanbearpig_collection_20260801_193720.zip
Re-running into a directory that already holds a collection merges the two collections — pass
--clean to start fresh, and see Limitations for why that matters.
If preprocess or convert fails, the raw data in .\out is left intact and the exact resume commands are
logged, so you never have to recollect. To run the three stages yourself, see
Running the stages separately.
Three uploads, all drag-and-drop in the BloodHound UI. Only the middle one repeats per run — and do the
schemas first, because ingesting before the kinds are registered leaves the SCCM_* and MSSQL_*
nodes unstyled and missing from the search and pathfinding menus.
| # | What | Where in the UI | How often |
|---|---|---|---|
| 1 | The two schema files | Administration → OpenGraph Management → Upload File | Once per BloodHound instance |
| 2 | The graph payloads from .\out\graph |
Administration → Data Collection → File Ingest → Upload File(s) | Every run |
| 3 | The saved Cypher queries | Explore → Cypher → Saved Queries | Once |
1. Register the schemas. schema_SCCM.json covers every SCCM_* node and edge kind;
schema_MSSQL.json covers the MSSQL_* kinds this collector also emits (site-server SQL topology), so
uploading only the SCCM one leaves those unrenderable. Upload both under Administration → OpenGraph
Management → Upload File (docs).
Download them straight from this repository — schema_SCCM.json and schema_MSSQL.json — or take them from the copy that shipped with your install, which is the one guaranteed to match the version you are running:
# From a checkout: the files are in src\openhound_sccm\
# From an installed copy: ask the interpreter that has the package where it lives
uv run python -c "import openhound_sccm, pathlib; print(pathlib.Path(openhound_sccm.__file__).parent)"2. Ingest the graph. Drag the contents of .\out\graph into File Ingest (or zip it first — it
accepts .zip as well as loose .json). Upload both payloads together: the sccm_* files carry the
SCCM and MSSQL kinds, and the ad_* files carry the AD nodes those attach to.
3. Load the saved queries. cypher_queries/ holds 24 ready-made queries for this
graph — the TAKEOVER/CRED/ELEVATE attack chains, SCCM admin and infrastructure inventories, and the
site-database-service-account path. Drag the JSON files (or a zip of them) onto Saved Queries;
BloodHound validates each one and reports any it rejects.
Check out the introductory blog post for how to use each query.
Scripting this instead — for CI, or to re-upload queries across instances — is in Automating the Upload.
collect runs in two stages, defined in collect_sccm and source.py. These two collection stages come from the per-host collection framework plan, docs/superpowers/plans/2026-06-03-per-host-collection-framework.md (walkthrough: docs/per-host-collection-framework-tour.md). They are a different numbering from the graph-pipeline stages referred to elsewhere in this README — see the Reference key.
openhound collect sccm
│
┌──────────────────────────────┴─────────────────────────────┐
│ Stage 1 — Discovery (runs once) │
│ LDAP · Local · DNS │
│ → seeds the work queue with candidate hosts │
└──────────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────────┴─────────────────────────────┐
│ Stage 2 — Per-host phases (worker pool) │
│ RemoteRegistry · MSSQL · AdminService · WMI · HTTP · SMB │
│ gated by --collection-methods │
│ loops as new hosts are discovered │
└──────────────────────────────┬─────────────────────────────┘
│
raw JSONL on disk
│
preprocess → DuckDB lookup database
│
convert → OpenGraph nodes + edges
These resources run a single time per collection and seed the per-host work queue. Each is gated by --collection-methods (LDAP, DNS, Local), so --dc-only (which forces LDAP,DNS) runs LDAP and DNS discovery while skipping local collection and every per-host phase.
| Discovery resource | What it does | Status |
|---|---|---|
| LDAP (collectors/ldap.py) | Queries the AD System Management container for SCCM sites (mSSMSSite), management points (mSSMSManagementPoint), the container DACL, network-boot servers, devices with the CmRcService SPN, and computers whose names/descriptions match SCCM naming patterns (sccm, mecm, sms, …). Registers discovered site systems as per-host targets. |
✅ Implemented |
| Local (collectors/local.py) | When run on an SCCM client: reads the root\CCM WMI namespace (SMS_Authority, SMS_LookupMP, CCM_Client) and parses CCM client logs to find management points / distribution points and the local client's SMSID. Windows-only. |
✅ Implemented (Windows) |
| DNS (collectors/dns.py) | For each discovered site code, resolves the _mssms_mp_<sitecode>._tcp.<domain> SRV record (with an ADIDNS/LDAP fallback) to find management points published to DNS. |
✅ Implemented |
Each discovered (or --computers-supplied) host runs through the ordered per-host phases in per_host_phases.py, concurrently across a worker pool (--threads). A phase only runs for a host when its name is enabled by --collection-methods (method_enabled in context.py).
| Phase | What it does | Status |
|---|---|---|
| RemoteRegistry (collectors/registry.py) | Binds the remote registry over SMB (impacket rrp) to read SCCM keys under HKLM\SOFTWARE\Microsoft\SMS — site codes, component servers/roles, current users, and SQL/MSSQL settings. Retries the initial bind to absorb the RemoteRegistry trigger-start race. |
✅ Implemented |
| MSSQL (collectors/mssql.py) | Connects to the host's SQL Server (TCP/1433) and probes its Extended Protection for Authentication (EPA) enforcement using clients/mssql_epa.py. | ✅ Implemented |
| AdminService (collectors/privileged.py) | Queries the SCCM AdminService REST API (https://<provider>/AdminService/wmi/...) over Negotiate and collects the site hierarchy, site definitions, reserved accounts, devices, users, security groups (SMS_R_UserGroup — each group's name and SID, used to resolve SecurityGroupName memberships to Group nodes offline), collections, security roles, admins, and site-system roles into raw adminservice_* tables, which preprocess reads to build the site, client-device, collection, admin-user and security-role families. |
✅ Implemented |
| WMI (collectors/privileged.py) | Fallback for AdminService. Shares the same collection helpers as the AdminService phase (one set, parameterized per transport in privileged.py); when AdminService is unreachable on a host, it reads the same SMS Provider classes directly in the root\SMS\site_<code> WMI namespace (over DCOM via impacket, or pywin32 for the current Windows user) and writes the matching wmi_* tables. Runs only on hosts AdminService did not already collect — gated by should_run_phase reading TargetEntry.completed_phases. |
✅ Implemented |
| HTTP (collectors/http.py) | Unauthenticated probing of the SCCM web endpoints over http then https — SMS_MP/.sms_aut (MPKEYINFORMATION/MPLIST/SMSTRC/MPLIST1), SMS_DP_SMSPKG$, AdminService/wmi/SMS_Identification, and the site-signing certificate — to identify Management Point, Distribution Point, SMS Provider, and Site Server roles from the 401/403/200 status codes. Enumerates and registers sibling MPs and the site server as new probe targets; writes raw http_* role tables. On a confirmed Management Point, also fetches /CCM_Client/ccmsetup.exe (still unauthenticated) and regexes the embedded version string out of the binary to fingerprint the site's SCCM build (the SCCMVersionGuesser technique) — feeds SCCM_Site.version/versionCVEs when privileged collection found no version, which in turn gates the SCCM_CoerceAndRelayToAdminService edge. Bandwidth/OPSEC note: v1 downloads the entire ccmsetup.exe (multiple MB) rather than a bounded/Range fetch, which is a bigger and noisier footprint than every other probe in this row (those read a few KB of XML/JSON at most); a bounded fetch is a future optimization. Skipped on hosts AdminService/WMI already collected. |
✅ Implemented |
| SMB (collectors/smb.py) | An unauthenticated SMB2-negotiate signing-required check (via clients/smb.py), then authenticated share enumeration (NetShareEnum) that classifies SCCM-specific shares — SMS_SITE/SMS_<code> (Site Server), SMS_DP$ (Distribution Point), REMINST (PXE), SCCMContentLib$/SMSPKG (content library) — into site-system roles and a site code. Writes raw smb_computers / smb_sites tables. Skipped on hosts AdminService/WMI already collected. |
✅ Implemented |
| DHCP | Accepted as a --collection-methods token, but the per-host collector is not yet ported. |
🚧 Not yet ported |
Quick Start uses --run-all, which chains the three stages in one process. Run them
individually when you want to re-derive the graph from data you already collected — the usual reason is
iterating on preprocess/convert without re-touching the network:
uv run openhound collect sccm .\out -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\lowpriv" -p "Passw0rd!"
uv run openhound preprocess sccm .\out .\out\lookup.duckdb
uv run openhound convert sccm .\out\sccm .\out\graph --lookup-file .\out\lookup.duckdbThose three are exactly what --run-all does. preprocess loads the raw JSONL into DuckDB and builds
the derived tables convert reads (site hierarchies, SID resolution, role mappings, and so on);
convert turns those tables into the OpenGraph payloads. Because preprocess and convert only read
what collect already wrote, re-running the last two over a cached output directory is the cheapest way
to test a graph change — it holds collection constant, so the only thing that can move is your change.
Scoping a collection to specific phases and hosts is also useful on its own:
# Only check the PS1 site database server for Extended Protection for Authentication
uv run openhound collect sccm .\out -d mayyhem.com -m RemoteRegistry,MSSQL -c ps1-db.mayyhem.com -vDC-only recon — map SCCM from AD without touching any host:
uv run openhound collect sccm .\out --dc-only --run-all -d mayyhem.comCollects only LDAP + DNS from the domain controller, then preprocesses and converts that discovery data
into a graph (sites, management points, discovered computers, and LDAP-sourced edges such as
GenericAll on the System Management container) — with no connection to any SCCM site system or client.
Host running the collector:
- Python 3.13 or 3.14 (
requires-python = ">=3.13,<3.15") anduv. - The OpenHound framework (
openhound>=0.2.12, resolved from PyPI as a declared dependency — no separate install step). - Network line of sight to a domain controller and to the SCCM systems you target.
- Active Directory context. On Windows, the domain and a domain controller are auto-detected from the current user's context (
USERDNSDOMAIN, then a DNS SRV lookup). On Linux/macOS you must pass-d/--domain(and-u/-pfor any phase that authenticates);--dcis still resolved via DNS SRV if omitted. - Windows-only features: local WMI/log collection, and current-user integrated authentication (Kerberos via
winkerberos, NTLM via SSPI/pywin32). On Linux, integrated auth needsgssapi[kerberos]installed separately; explicit credentials work everywhere.
Privileges needed per phase:
| Phase | Minimum privilege |
|---|---|
| LDAP discovery | Any authenticated domain user |
| DNS discovery | None — a plain SRV query, no AD authentication at all |
| HTTP | None — every SCCM web-endpoint probe (including the site-signing-certificate probe) runs anonymous; no domain credentials are ever presented |
| RemoteRegistry | Depends on the key — most of the SCCM data needs only a domain user. The HKLM\SOFTWARE\Microsoft\SMS keys the phase relies on for discovery — the site code (Triggers), the site-system roles (COMPONENTS\SMS_SITE_COMPONENT_MANAGER\Component Servers and Multisite Component Servers), and the logged-on user (CurrentUser) — are readable by any authenticated domain user with SMB access to the host. Local administrator is required only for the host-hardening values: SMB signing (SYSTEM\CurrentControlSet\Services\LanManServer\Parameters), the NTLM restrictions (SYSTEM\CurrentControlSet\Control\Lsa and Lsa\MSV1_0), and SQL Server's SuperSocketNetLib encryption settings. A non-admin run collects the former and skips the latter rather than failing the phase, reporting each host's refused reads as a single warning — see What a low-privilege run looks like. Note that the SMS keys are readable by a plain domain user by default; a host with tightened ACLs can refuse those too, in which case the site code is unknown and the rest of the phase is skipped for that host. See collectors/registry.py. |
| MSSQL (EPA detection) | Any domain user can probe a reachable SQL Server; reading the setting via RemoteRegistry instead needs local admin on the DB host. The registry route covers named instances as well as the default one — it reads SQL Server's own instance inventory (SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL) and derives each instance's settings path from it, so an instance like CONFIGMGRSEC is found rather than missed |
| SMB | The signing-required check is unauthenticated (anyone with TCP/445 line of sight); share enumeration needs an authenticated SMB session (any domain user — current Windows user via SSPI, or -u/-p, --nt-hash, --ticket) |
See Collection privilege tiers under Assumptions for what a given combination of these phases actually builds in the graph.
BloodHound side:
- BloodHound with OpenGraph support.
- A PostgreSQL graph backend — the only supported way to get Search and Pathfinding working with OpenGraph data. Cypher works against the SCCM kinds on Neo4j too, but they will not surface in search or pathfinding there: https://bloodhound.specterops.io/get-started/custom-installation#postgresql
A note on uv and Python on Windows: pyproject.toml sets python-preference = "only-system". uv-managed (python-build-standalone) builds ship a libcrypto without the OPENSSL_Applink cross-CRT shim, which aborts TLS handshakes mid-flight — so on Windows the collector deliberately prefers an official/system Python.
The collector relies on these assumptions about the target environment and how its output is consumed. They hold for the overwhelming majority of real deployments, but violating one can corrupt the graph (see Limitations).
- Site codes are unique within an organization. A site code is only three characters and SCCM provides no globally unique hierarchy identifier, so the collector uses the (hierarchy-root) site code as the
environmentidfor SCCM-native nodes — those with no AD-domain home (SCCM_Sitetoday, and the other SCCM-specific kinds as the port adds them). If two distinct hierarchies in the same organization reuse a site code, their SCCM environments collide and merge. Microsoft likewise recommends against reusing site codes within a forest: https://learn.microsoft.com/en-us/intune/configmgr/core/servers/deploy/install/prepare-to-install-sites#bkmk_sitecodes - One organization per graph. Do not load data collected from two different organizations into the same BloodHound graph. Because site codes are not globally unique across organizations, a shared site code would merge the two organizations' SCCM environments. Collect and ingest each organization into its own graph.
AD-native nodes (
Computer,User,Group) are unaffected by the above: they use their AD domain SID asenvironmentid, so they merge with existing SharpHound data by SID rather than by site code.
Everything the collector emits falls into one of two buckets: confirmed (built straight from observed
data — a probed setting, an ACL, a resource table) or assumed (templated/inferred, the way
ConfigManBearPig's own post-processing fills gaps it can't directly observe). Assumed nodes and edges stay
traversable — BloodHound's attack-path engine still follows them — but they carry a machine-readable
provenance stamp so an operator can tell the two apart: assumed = true and a human-readable
assumptionBasis string. A confirmed node/edge simply omits both (they show as absent/null in
BloodHound's entity panel, not false).
collectionSourcenames the collection phase, not the assumption. Most assumed families also fold anAssumed-<Family>tag intocollectionSource. The MSSQL site-database scaffolding no longer does: as of 2026-07-31 itscollectionSourcecarries the originating collection phase instead —RemoteRegistry-MultisiteComponentServers,AdminService-SiteDefinition,LDAP-MSSQLSvcSPN,MSSQL-ScanForEPA— so a reader can see which transport and therefore which privilege level produced a node, which the old staticSCCM-SiteDBDefaultSchematag could not. Nothing is lost: assumedness still rides onassumedandassumptionBasisfor that family exactly as for every other.
The table below is the full catalog — one row per assumed family — with its inference rule, the data it's
built from, whether --disable-possible-edges removes it, and the caveat that makes it a false-positive
risk:
| Family | Assumed because… | Built from | Removed by --disable-possible-edges? |
False-positive caveat |
|---|---|---|---|---|
SCCM_ClientDevice (possible, is_confirmed_active_client = false) + SCCM_SameHostAs + SCCM_HasClient to it |
A CmRcService SPN in AD is treated as evidence of an enrolled client even with no confirmed SCCM enrollment record |
LDAP (ldap_cmrc_devices) |
Yes — not emitted at all | The SPN can linger in AD after a client was decommissioned, or belong to a device enrolled in a different hierarchy |
site_hierarchy root, when 2+ untyped sites exist and none was observed as a CAS / RootSiteCode / parentless Primary |
Picking one of several untyped sites as the hierarchy root is a guess, made alphabetically | LDAP ldap_sites, RemoteRegistry, HTTP, SMB, DNS (every source that reports only a bare site code, no type) |
Yes — the root is left unresolved instead of guessed (SCCM-native node ids lose their @<root> scope) |
The wrong root anchors every SCCM-native node id minted in the run; a single untyped site is deduction, not a guess, and is unaffected by the flag |
MSSQL site-database identity via the SPN+SCCM basis — feeds MSSQL_Server.SCCMSite/.SCCMInfra/.databases, MSSQL_Database, MSSQL_ServerRole, MSSQL_DatabaseRole, MSSQL_Login, MSSQL_DatabaseUser, their Contains/MemberOf/HasLogin/IsMappedTo/Control* edges, and MSSQL_GetTGS/MSSQL_GetAdminTGS off that login |
A host with an MSSQLSvc SPN that is also SCCM-related (carries an SMS role, or sccm_infra) is treated as the site database, not merely a co-located SQL Server |
AD (MSSQLSvc SPN, via mssql_server_instances.has_mssql_spn) + node_computer site-system role tags |
Yes — the whole basis is dropped; a confirmed site database (RemoteRegistry / AdminService / WMI) keeps its full scaffolding in both modes with no stamp, since the schema SCCM requires there follows from the confirmed fact, not a guess | Nothing confirms this SQL Server is this site's database rather than an unrelated one that merely happens to sit on an SCCM-tagged host |
SCCM_AssignAllPermissions (Computer SMS Provider → SCCM_Site) |
Hosting the SMS Provider role is templated as implying hierarchy-wide RBAC control, not read from an actual grant | RemoteRegistry / HTTP / LDAP role tag + site hierarchy | No — CMBP itself emits this family under its own -DisablePossibleEdges |
A role tag attributed to the wrong site would overstate an admin's real reach |
SCCM_LocalAdminRequired |
Two rules, neither reading a local-group membership list. Within a site: co-location as site systems of the same site is templated as mutual local-administrator rights. Parent primary → child secondary: Microsoft's documented setup prerequisite (the parent primary's computer account is added to the secondary site server's Administrators group) is templated as still being in force | Role tags (RemoteRegistry/HTTP/LDAP) + site hierarchy; the cross-site rule additionally needs a positively known siteType = 1 on the child |
No | An admin who removed the default grant during a hardening pass, or a role tag attributed to the wrong site, makes this overstate real access. The cross-site rule is silent where secondary-ness could not be established, so at low privilege it emits nothing rather than guessing |
SCCM_CoerceAndRelayToAdminService |
An unset (null) inbound-NTLM restriction on the SMS Provider is treated as vulnerable — this is the Windows default (0 = allow all inbound NTLM), so it is a measured fact about the default rather than a guess |
RemoteRegistry NTLM setting on the Provider + role/site topology | No — the "unset = vulnerable" reasoning holds in both modes | Suppressed on a site confirmed to run SCCM 2509+ (rejects NTLM); an unknown/unparseable version fails open and may false-positive |
SCCM_CoerceAndRelayToSMB |
Same NTLM reasoning as above; SMB signing itself is never assumed — it must still be confirmed not required | RemoteRegistry/SMB-negotiate signing check + NTLM setting + role/site topology | No | Same NTLM caveat as above |
MSSQL_CoerceAndRelayToMSSQL |
NTLM uses the same unconditional "unset = vulnerable" rule as the other two relay families. Extended Protection is different: a null/uncollected EPA setting is also treated as vulnerable by default (an actual assumption, not a Windows-default fact) | RemoteRegistry/MSSQL EPA probe (EPA) + RemoteRegistry NTLM (coerced host) | Partially — only the EPA half: with the flag, EPA must be explicitly Off |
assumed/assumptionBasis are stamped per row, only when EPA was never measured — a measured Off is evidence, not an assumption, so those rows are unstamped even by default |
Known gap: the Database→Site configuration of
SCCM_AssignAllPermissions(a site database asserting control of its own site, see Edge Reference) is built from the samenode_mssql_databaserow that carries theSPN+SCCM/confirmed basis, but the edge builder does not currently copy that row'sassumed/assumptionBasisonto the edge — so this specific edge shape never carries the stamp today, unlike its sibling SMS-Provider→Site configuration above. The underlying node it hangs off still reflects the correct confirmed/assumed status.
Because site_hierarchy is now fed from every site-code source the collector has — not just
AdminService/WMI — a real graph builds up incrementally as more privilege becomes available, rather than
jumping from "almost nothing" to "everything" the moment AdminService is reachable:
| Privilege level | What it adds | Example flags |
|---|---|---|
| Anonymous — no domain credentials at all (HTTP + DNS, both unauthenticated) | A site code; Management Point / Distribution Point / SMS Provider / Site Server role tags from the unauthenticated HTTP probes (including the site-signing-certificate probe, the only credential-free way to identify the Site Server); DNS-discovered Management Points; the relay-feasibility edges those roles and the site hierarchy alone support (SCCM_LocalAdminRequired, SCCM_CoerceAndRelayToAdminService). Adding the SMB phase's signing-required check (also unauthenticated — -m HTTP,DNS,SMB) additionally confirms which of those site systems don't require SMB signing, which is what SCCM_CoerceAndRelayToSMB needs (that check is the only thing this edge never assumes). |
-m HTTP,DNS --sc <site_code> -c <known_mp_or_site_server> |
| + domain user — any authenticated account, no local-admin/AdminService rights | The LDAP-derived hierarchy with real type/parent/true root (LDAP management-point capabilities); the Fallback Status Point role; SMB share-based role/site discovery; RemoteRegistry-confirmed roles plus the current-user HasSession edge on any host the account happens to be a local admin on; the System Management container's Container/GenericAll/nested MemberOf graph; MSSQLSvc-SPN-based MSSQL_Server/Computer nodes (confirmed the moment the SPN exists, even with 1433 filtered) and the low-priv MSSQL_ServiceAccountFor/HasSession/MSSQL_GetTGS/MSSQL_GetAdminTGS edges off the SPN holder |
-m LDAP,RemoteRegistry,SMB,MSSQL,HTTP,DNS (or All) |
| + AdminService/WMI — SMS Provider or SCCM RBAC read access | Everything above, plus the SCCM-admin-only families below that no lower privilege level can produce at all | -m All (the default) |
Requires SCCM admin access. Two groups need SMS Provider or SCCM RBAC read access, because nothing at a lower privilege level can produce them at all.
RBAC families —
SCCM_FullAdministrator,SCCM_ApplicationAuthor,SCCM_ApplicationAdministrator,SCCM_ComplianceSettingsManager,SCCM_OSDManager,SCCM_OperationsAdministrator,SCCM_SecurityAdministrator,SCCM_AllPermissions,SCCM_IsAssigned,SCCM_IsMappedTo,SCCM_HasMember, and theSCCM_AdminUser/SCCM_SecurityRole/SCCM_Collectionnodes themselves have no AD/LDAP representation, and RemoteRegistry does not expose them either.SCCM_Containsedges that target one of those three node kinds inherit the same requirement — the edge cannot exist without its endpoint.Device inventory —
SCCM_HasPrimaryUser,SCCM_HasCurrentUserandSCCM_HasADLastLogonUsercome from the SCCM device record (user-device affinity, current logon user, AD last-logon user), which lives only in the site database. A domain user can enumerate the computer from AD but never SCCM's view of who uses it.Their absence from a low-privilege run is correct behavior, not a bug: do not expect them until you collect with AdminService/WMI reachable. The fixture suite skips rather than asserts them on such a run without you having to say so —
--integration-privilegedefaults toauto, which reads whether AdminService/WMI actually returned any rows.Not everything missing from a low-privilege run belongs here, though.
SCCM_HasClient,SCCM_SameHostAsandHasSessionare intended to work without SCCM admin — if they are absent, that is a collector gap rather than a privilege boundary.
Credential-free example against the mayyhem.com lab (no -u/-p at all — just HTTP and DNS against a
known management point):
uv run openhound collect sccm .\out-anon -d mayyhem.com --dc dc01.mayyhem.com `
-m HTTP,DNS --sc PS1 -c ps1-sms.mayyhem.com --run-all --cleanThis alone yields a PS1 site code, an SMS Site Server@PS1 role on ps1-sms, and the
SCCM_LocalAdminRequired / SCCM_CoerceAndRelayToAdminService edges built off that role and site — with
zero domain credentials presented anywhere in the run.
- Installing with
uvneeds--prerelease=allow. The collector requiresldap3>=2.10.2rc4. That release candidate is the first to exportENCRYPTandTLS_CHANNEL_BINDINGand to acceptsession_securityon aConnection— the three things LDAP sign-and-seal and channel binding are built on, and therefore what it takes to bind to a domain controller that enforces signing or channel binding (increasingly the default). ldap3 has never shipped them in a final release: its latest stable is 2.9.1, and 2.10.2 exists only as release candidates.pipallows a pre-release automatically when the requirement itself names one;uvasks you to opt in. Without the flag,uv tool installreports "only ldap3<2.10.2rc4 is available", which looks like a broken package and is not. (Inside this project —uv sync,uv run— no flag is needed: a project is allowed its own declared pre-release. See Testing Changes.) - Re-running into the same output directory silently merges two collections.
collect(via the underlyingdltframework) appends a new load package beside any already in the output directory rather than overwriting it, andpreprocessthen reads every package and UNIONs their rows into one graph. Nothing looks wrong when this happens — the command exits 0 andgraph\gets fresh timestamps — but a table this run finds empty silently keeps the previous run's rows, so a decommissioned site system, a stale role, or a removed client can linger in the graph indefinitely. Pass--cleanto removeout\sccm,out\graph, andout\lookup.duckdbbefore collecting (timestamped logs and integration/compare reports are always kept). Without the flag, a used directory still produces a loud console andcollect_issues_*.logwarning — naming how many prior load packages are present and when the oldest was written — but the collection proceeds and nothing is cleaned up for you. See--clean. - Graph output covers graph-pipeline Stages 1–6 plus the low-privilege additions. (Those are the increments of the preprocess/convert port — plans
2026-06-16-sccm-preproc-convert-stage0.md…2026-07-01-sccm-preproc-convert-stage7.md; see the Reference key.)convertnow emits fifteen node kinds and thirty-eight edge kinds (see the Node Reference and Edge Reference). Richer edges from NAA secrets await the NAA-secret collector. - An AD node with no resolvable domain FQDN ships without a name.
Computer/User/Group/Containernodes merge into BloodHound's native AD graph by id, so anynamethis collector emits overwrites SharpHound's. Rather than risk replacing a correct label with a partial one, a node whose SharpHound-format name cannot be built is emitted with nonameordisplayname, and BloodHound displays its object id instead. In a standalone graph (no SharpHound data ingested) those nodes read as raw SIDs or GUIDs. Backfilled endpoint stubs are always in this state by design. Both are visible in the preprocess log assharphound_name: N <table> row(s) could not be given a SharpHound-format name. See AD node naming. - The synthetic Authenticated Users node carries no membership. It is seeded so the coerce-and-relay edges have a traversable start, but no
MemberOfedges point into it — enumerating a domain's principals is an AD collection's job. Paths from Authenticated Users onward work standalone; paths into it from an arbitrary user require a SharpHound collection ingested alongside.EveryoneandDomain Computersare deliberately not seeded at all. See Seed principals. - MSSQL logins, database users, and roles are inferred from SCCM topology, not enumerated from SQL. The
MSSQL_LoginandMSSQL_DatabaseUsernodes (and thesysadmin/db_ownerrole nodes) are built from SCCM's knowledge of which computers are Primary Site Servers or SMS Providers for a given site — the same inference CMBP makes. No live SQL connection is opened duringpreprocessorconvert; the collector's MSSQL phase only probes EPA. This means logins/users/roles are only created for SCCM-linked SQL servers, and only for the machine accounts SCCM architecturally grantssysadminaccess. - Non-SCCM SQL servers appear as bare
MSSQL_Servernodes. SQL servers discovered by the EPA scan or RemoteRegistry that are not referenced by any SCCM site produce anMSSQL_Servernode (withMSSQL_HostFor/MSSQL_ExecuteOnHostedges) but noMSSQL_Database,MSSQL_Login, or role nodes — CMBP likewise skips these and the collector follows suit. - MSSQL nodes and MSSQL-only edges get their own
source_kind. The six MSSQL node kinds are written tomssql_nodes-*.json/mssql_edges-*.json(taggedsource_kind = "MSSQL", matchingschema_MSSQL.json), together with every edge whose endpoints are both MSSQL nodes (MSSQL_Contains,MSSQL_ControlServer,MSSQL_ControlDB,MSSQL_MemberOf,MSSQL_IsMappedTo). Edges that touch an AD node —MSSQL_HostFor,MSSQL_ExecuteOnHost,MSSQL_HasLogin,MSSQL_GetTGS,MSSQL_ServiceAccountFor,MSSQL_GetAdminTGS, andMSSQL_CoerceAndRelayToMSSQL— stay inad_edges-*.json; the MSSQL↔SCCM edgeSCCM_AssignAllPermissions(database → site) stays insccm_edges-*.json. Upload all three file sets together. - Some node properties are deferred to later collectors or stages. The following properties appear in ConfigManBearPig but are not yet emitted because the required collector does not exist or the data is coupled to a later pipeline stage:
- DHCP/PXE fields on
Computer(pxe_vendor_class,pxe_next_server,pxe_boot_file,tftp_reachable,is_dhcp_server) — blocked on a DHCP/PXE collector (gtk ticketsOpe-o6bh/Ope-gqwo). The collector can detect whether a host is PXE-enabled (SMBREMINSTshare →SCCMIsPXESupportEnabled) but not the DHCP/PXE configuration parameters. - NAA flag on
User(is_sccm_network_access_account) — requires NAA secret decryption (--enable-bad-opsec) and a dedicated NAA collector, neither of which is implemented yet. - Group DN / SAM account name (
distinguishedNameandSamAccountNameonGroup— note the PascalCaseS, matching ConfigManBearPig's own Group output, unlikeComputer/User) — both fields exist on the model but staynulltoday: groups are built from name-only lists resolved to SIDs, with no LDAP group-object lookup. They will carry a value once such a lookup is added. - (
currentManagementPointandpreviousSMSIDwere in this list previously but are now emitted — see theSCCM_ClientDevicenode reference.distinguishedName,dNSHostName, anddomainwere also in this list previously; they are now emitted too, joined in from the device's underlyingComputernode — see below.)
- DHCP/PXE fields on
- One per-host phase is not yet ported. RemoteRegistry, MSSQL, AdminService, WMI, HTTP, and SMB all collect real data, and all six feed the graph through
preprocess. DHCP is accepted as a--collection-methodstoken but has no collector behind it. - Possible-client nodes are inferred, not confirmed. Devices with a
CmRcServiceSPN in AD but no confirmed SCCM enrollment are emitted asSCCM_ClientDevicenodes withis_confirmed_active_client = false. They will not appear in the ConfigMgr console Devices tab (they were never enrolled — the SPN can linger in AD after a client is removed, or belong to a machine reporting to another hierarchy). TheirSCCM_HasClientedge starts from a Primary site (never the CAS). Pass--disable-possible-edgesat collection time to suppress them (the flag is persisted in thecollection_settingstable and gated in preprocess). See Assumed vs. confirmed graph content for the full catalog of assumed families, not just this one. SCCM_ClientDevice's AD-attribute properties (CN,DNSHostName,distinguishedName,domain,objectClass,samAccountName,servicePrincipalName) mirror the underlyingComputernode, not a second lookup. They're joined in fromnode_computerbyADDomainSIDduring preprocess, so they're only populated when that computer independently resolved those attributes (see the AD-resolution caveat underComputer) — a client device whose underlying computer was never itself AD-resolved during the run staysnullin all seven, exactly as it did before this join existed.MemberOfcovers direct memberships only, except for the System Management container's own DACL groups, whose full nested membership chain is captured (seeMemberOfbelow). Everywhere else, SCCM'ssecurity_group_namefield carries only direct groups; merge with a SharpHound collection for full nested-group paths elsewhere (the Group nodes key on AD SID, so the two datasets join cleanly).SCCM_AdminsReplicatedToneeds a typed CAS, and a CAS has no management point of its own. Only LDAP management-point capabilities and AdminService/WMI ever report a site's type directly, and a Central Administration Site has no MP to report capabilities from — so the collector infers a CAS from being the parent of a site otherwise typed Primary (and, symmetrically, a Secondary from being the child of a Primary). This is a deduction, not a guess, and applies in both flag modes. It still has a blind spot: aSEC-style site (a Secondary with no parent ever recorded in this run) stays untyped rather than being misclassified, which means it also stays outsideSCCM_AdminsReplicatedTo's Primary↔Secondary edge until a source reports its parent.- Site code is used as the site identity. A
SCCM_Sitenode's id (andenvironmentid) is the site code (models/sccm_site.py). SCCM hierarchies have no globally unique id, so two distinct hierarchies that happen to reuse the same site code will merge in the graph, producing false positives. Microsoft recommends against reusing site codes within a forest: https://learn.microsoft.com/en-us/intune/configmgr/core/servers/deploy/install/prepare-to-install-sites#bkmk_sitecodes - EPA "Allowed" vs "Required" is indistinguishable under integrated auth. When EPA is detected using the current Windows user (SSPI), Windows always emits the channel-binding and target-name AV pairs, so the collector cannot tell
AllowedfromRequiredand reports the literalAllowed/Required. Explicit-credential and pass-the-hash paths (via impacket) can distinguish them. See clients/mssql_epa.py and the EPA matrix harness described under Understanding the Codebase. extension.yamldescribes the CLI, it does not drive it. Thecredentials/parametersblocks in src/openhound_sccm/extension.yaml accurately document the collector's real flags, but nothing in the collector reads them — OpenHound parses the file for extension metadata only (see tests/extension_metadata_test.py). Supplying values there configures nothing; pass configuration via CLI flags orSOURCES__SCCM__*env vars.--proxycannot tunnel live current-user SSPI / OS-Kerberos, and carries no UDP. Windows SSPI Negotiate and OS-Kerberos make their KDC/DCOM connections inside the OS (LSASS/win32com), not this process, so a userland socket hook cannot pull them through the pivot. Use--ticket(pass-the-ticket, which tunnels completely) or set up OS-level transparent proxying (tun2socks / Proxifier) on the outside box. Separately, DNS is forced onto TCP to ride the tunnel — SOCKS5 CONNECT is TCP-only, so no other UDP traffic is carried. See Proxying / pivoting.
The collector adds CMBP-style flags to the framework's collect command. Every flag also has an environment-variable equivalent (SOURCES__SCCM__<NAME>), and CLI flag values take precedence. Run uv run openhound collect sccm --help for the authoritative list.
uv run openhound collect sccm <output_path> [resources...] [options]
<output_path> (positional, required) is the directory raw JSONL is written to. resources... (optional) limits collection to a subset of resource names.
The flag groups below mirror the panels shown in --help: Authentication, Collection, Performance, Output, Testing, and Logging.
| Option | Description |
|---|---|
-d, --domain |
AD domain (e.g. mayyhem.com). Auto-detected from the Windows current-user context; required on Linux/macOS. |
--dc, --domain-controller |
DC hostname or IP. If omitted, resolved from the domain via DNS SRV (_ldap._tcp.dc._msdcs.<domain>). |
-u, --username |
DOMAIN\user for explicit authentication. Omit to use the current Windows user (integrated auth). |
-p, --password |
Password for explicit authentication. |
--nt-hash |
NT hash for pass-the-hash (bare 32-hex; empty LM half assumed). Used by LDAP, AdminService Kerberos (as the RC4 key) and NTLM, the SMB-based phases (RemoteRegistry, SMB) via impacket, and the MSSQL EPA probe. |
--ticket |
Base64 Kerberos ticket (.kirbi / KRB-CRED) for pass-the-ticket. Kerberos only — no NTLM fallback. Honored by LDAP, AdminService/WMI, and the SMB-based phases (RemoteRegistry, SMB). Not used for the MSSQL EPA probe — pass-the-ticket can't probe channel binding, so a ticket-only run logs a warning and skips EPA (use -p/--nt-hash there). |
--ldap-port |
Pin the LDAP port. Omit to auto-detect (LDAPS:636 → StartTLS:389 → LDAP:389 with sign-and-seal). |
The shared HTTP client (clients/http.py) authenticates to the SCCM AdminService with Negotiate, in this precedence:
- Explicit credentials win —
-uplus one of-p/--nt-hash/--ticket. Kerberos is tried first (a service ticket forHTTP/<fqdn>, built from the password, the NT hash as the RC4 key, or the supplied ticket), with an automatic NTLM fallback on protocol failure. A bare-IP target skips Kerberos (no SPN can be formed) and uses NTLM directly. - Current-user Windows SSO — passwordless SSPI Negotiate, when no credentials are supplied (Windows only).
- Anonymous — no
Authorizationheader. This is what the HTTP role-probe uses, so it can read the unauthenticated401/403that reveal site-system roles.
PKI / HTTPS-only sites are detected (e.g. a 403 on a probe endpoint), not satisfied — OpenHound does not present a client certificate. The KDC reuses --dc; there is no separate --kdc flag.
The WMI fallback (clients/wmi.py) reuses this exact credential precedence (choose_auth), but realizes each rung over a WMI transport: pass-the-ticket / Kerberos / NTLM (incl. pass-the-hash) over DCOM via impacket, current-user SSPI via pywin32, and it skips the anonymous rung (DCOM always requires authentication). So -u/-p/--nt-hash/--ticket and passwordless current-user collection all work identically whether a host answers over AdminService or only over WMI.
The SMB-based phases — RemoteRegistry (collectors/registry.py) and SMB (collectors/smb.py) — authenticate through the shared connect_smb (clients/smb_sso.py), which honors the same credential set over SMB: pass-the-ticket (kerberosLogin with the supplied TGT), pass-the-hash (--nt-hash), explicit password NTLM, current-user SSPI Negotiate, then an anonymous null session. SMB's signing-required check is unauthenticated (negotiate-only) and so works regardless of the credential method.
The LDAP discovery phase (clients/ad.py) honors the same credential set over ldap3 through the shared lockout-safe waterfall: pass-the-ticket (GSSAPI bind via a private ccache) → pass-the-hash (--nt-hash, ldap3 LM:NT NTLM bind) → explicit password NTLM → integrated Kerberos / current-user SSPI → anonymous, each auto-detecting the transport (LDAPS:636 → StartTLS:389 → LDAP:389 sign-and-seal). Only genuine bad-credential result-49 subcodes halt the waterfall, so transport fallbacks never increment badPwdCount.
# Passwordless, as the current domain user (domain-joined collector):
uv run openhound collect sccm ./out -d mayyhem.com -c ps1-sms.mayyhem.com
# Pass-the-hash against a specific SMS provider:
uv run openhound collect sccm ./out -d mayyhem.com -u MAYYHEM\\sccmadmin \
--nt-hash 8846f7eaee8fb117ad06bdd830b7586c -c ps1-sms.mayyhem.com
# Pass-the-ticket (base64 .kirbi):
uv run openhound collect sccm ./out -d mayyhem.com -u MAYYHEM\\sccmadmin \
--ticket "$(base64 -w0 ticket.kirbi)" -c ps1-sms.mayyhem.com
# LDAP-only, pass-the-hash (bind to the DC with an NT hash — no cleartext password):
uv run openhound collect sccm ./out -d mayyhem.com --dc dc.mayyhem.com \
-u MAYYHEM\\domainadmin --nt-hash 8846f7eaee8fb117ad06bdd830b7586c -m LDAP
# LDAP-only, pass-the-ticket (bind with a base64 .kirbi):
uv run openhound collect sccm ./out -d mayyhem.com --dc dc.mayyhem.com \
--ticket "$(base64 -w0 ticket.kirbi)" -m LDAPStatus: the auth client is implemented and unit- and live-validated against the lab AdminService. The AdminService, WMI, and HTTP per-host phases are implemented (collect-only); see
--collection-methods. HTTP uses the client's anonymous mode — it reads the unauthenticated 401/403/200 that reveal site-system roles.
| Option | Description |
|---|---|
-m, --collection-methods |
Comma-separated methods (see the table below). Default All. |
--dc-only |
Recon mode: collect only LDAP + DNS from the domain controller and skip all per-host probing. Mutually exclusive with -m/--collection-methods. |
-c, --computers |
Comma-separated computer targets. |
--cf, --computer-file |
Path to a file of computer targets, one per line. |
--sc, --site-codes |
Site codes for DNS collection (CSV or file path). |
-x, --proxy |
Route all collection traffic (discovery + every per-host protocol) through a SOCKS5 proxy. Forms: socks5://[user:pass@]host:port or bare host:port. Requires --dc or --dns. See Proxying / pivoting. |
--dns, --dns-resolver |
DNS nameserver IP used for all lookups (DC discovery, SRV probes). Omit to use the system default. |
--enable-bad-opsec |
Enable noisy operations (e.g. NAA decryption) likely to trip EDR (consumed by not-yet-ported phases). |
Use -c/--computers <host> to scope a run to specific hosts (e.g. an SMS Provider). -x/--proxy and --dns steer how that collection traffic is routed and resolved, which is why they sit with the other Collection controls.
--collection-methods tokens (case-insensitive; matched in context.py). The Stage 1 / Stage 2 below are the two collection stages — discovery, then per-host phases — from the per-host collection framework plan, docs/superpowers/plans/2026-06-03-per-host-collection-framework.md; see the Reference key:
| Token | Status |
|---|---|
All |
Default — enables every phase |
LDAP, Local, DNS |
✅ Discovery phases (Stage 1) |
RemoteRegistry, MSSQL, AdminService, WMI |
✅ Per-host phases (Stage 2). WMI is the AdminService fallback — it runs on a host only when AdminService could not reach it. |
HTTP |
✅ Per-host phase (Stage 2). Unauthenticated role probing of the SCCM web endpoints; runs on a host only when AdminService/WMI did not already collect it. |
SMB |
✅ Per-host phase (Stage 2). SMB-signing check + SCCM share-role enumeration; runs on a host only when AdminService/WMI did not already collect it. |
DHCP |
🚧 Accepted but not yet ported |
| Option | Description |
|---|---|
-t, --threads |
Per-host worker-pool size. Default 10. |
| Option | Description |
|---|---|
--clean |
Discard a previous collection in OUTPUT_PATH before collecting: removes the sccm/ dataset dir, graph/, and lookup.duckdb. Timestamped per-run logs and integration/compare reports are always kept. See below. |
--run-all |
After collecting, automatically run preprocess and convert in-process, producing the OpenGraph files in a single command. All paths are derived from OUTPUT_PATH: lookup.duckdb, the sccm/ dataset dir, and graph/. The run also writes graph\configmanbearpig_collection_<timestamp>.zip — a single upload-ready archive of the graph .json files for BloodHound File Ingest (the loose files are kept too; <timestamp> matches the run's collect_full_<ts>.log). On completion it logs a consolidated list of the run's output files — raw JSONL, the lookup DB, each OpenGraph JSON, and the collect logs (collect_full_*, and collect_issues_* when a warning/error occurred) — so you don't have to scroll back through the run. The last line of that block is the .zip, the one artifact you upload. Omit the flag to run the three stages manually (the default; a "next steps" hint is printed). |
--progress |
Progress backend. off (default) silences dlt's per-resource progress counters so only the collector's own [target][phase] logs print; pass tqdm, log, or alive_progress to re-enable a live tracker. |
--disable-possible-edges |
Remove or tighten the assumed node/edge families (see Assumed vs. confirmed graph content and below); never removes confirmed data. The flag is persisted at collect time in the collection_settings table and read by preprocess — it has no effect if set after collection. |
--show-cleartext-passwords |
Display cleartext passwords when discovered (consumed by not-yet-ported phases). |
--tables / --columns / --data-type |
DLT schema contracts for new tables / unknown columns / type mismatches. |
collect writes into OUTPUT_PATH through dlt, which appends a new load package beside whatever
is already there rather than overwriting it — and preprocess reads every package in every table
directory, so a second run's rows are UNIONed with the first run's. Measured on a real re-run: 11 of
24 raw tables ended up holding rows from two different collection dates at once. The failure mode that
matters most is silent: a table this run finds empty simply keeps the old rows, so a
decommissioned site system, a role that no longer applies, or a client that was removed can linger in
the graph indefinitely — and nothing about the exit code or graph/'s file timestamps gives it away.
Pass --clean to remove the reusable artifacts (sccm/, graph/, lookup.duckdb) before collecting.
It always keeps the timestamped collect_full_*/collect_issues_* logs and any
integration_results-*/compare-* reports, since those are uniquely named per run and are often what
you want to diff a fresh collection against. Without the flag, collect still warns (naming the load
package count and the oldest package's date) rather than staying silent — but it does not stop or clean
up anything for you. If --clean can't remove something (e.g. lookup.duckdb open in another tool), it
aborts rather than collecting onto stale data.
# Discard whatever was in .\out before this run
uv run openhound collect sccm .\out -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\lowpriv" -p "Passw0rd!" --clean --run-all--disable-possible-edges is tightening-only: every family it affects is an assumed one (see the
full catalog under Assumed vs. confirmed graph content); it never
removes anything the collector actually confirmed. Of the three graph-pipeline Stage 6 coerce-and-relay edges, only one
is actually gated by this flag today:
MSSQL_CoerceAndRelayToMSSQL— its two conditions are treated differently. The NTLM-restriction check on the coerced computer is not gated by the flag at all: an unsetrestrictReceivingNtlmTrafficis Windows's default (0 = allow all inbound NTLM), so it counts as vulnerable in both modes — that is a measured fact about the default, not a guess. The Extended Protection check on the SQL Server is gated: by default a null/uncollected EPA setting is treated as vulnerable (matching ConfigManBearPig); with--disable-possible-edges, EPA must be explicitlyOff.SCCM_CoerceAndRelayToAdminServiceandSCCM_CoerceAndRelayToSMBare not affected by this flag at all. Their NTLM-restriction check uses the same "unset = Windows default = vulnerable" reasoning as above in both modes, andSCCM_CoerceAndRelayToSMB's SMB-signing check has always required a confirmedfalse(no assumed-vulnerable case for signing itself) regardless of the flag. An earlier draft of this README claimed the flag tightened these two — that was wrong, the code never gated them, and it shouldn't: ConfigManBearPig itself emits these same families under its own-DisablePossibleEdgesswitch.
All three families are still tagged assumed = true with a human-readable assumptionBasis (see the
catalog) regardless of whether the flag suppresses anything — they template a relay/permission conclusion
from role topology rather than reading it out of an ACL or RBAC table, which is worth flagging to an
operator even when it isn't removed.
--disable-possible-edges also affects three other families that have nothing to do with relays: the
inferred "possible" SCCM_ClientDevice nodes (+ their SCCM_SameHostAs/SCCM_HasClient edges); the
MSSQL site-database scaffolding that rests on the SPN+SCCM inference rather than a
RemoteRegistry/AdminService/WMI-confirmed site database; and — only in the rare case of 2+ untyped sites
with no observed CAS/root — the site_hierarchy root guess, which the flag declines to make rather than
guessing alphabetically (see the catalog for all three).
Use the default run for a full speculative-path view; use --disable-possible-edges when you want only
confirmed-vulnerable paths:
# Default — collect WITHOUT the flag; speculative relays and SPN+SCCM-inferred site DBs included
uv run openhound collect sccm .\out -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\lowpriv" -p "Passw0rd!" --clean
openhound preprocess sccm .\out .\out\lookup.duckdb
openhound convert sccm .\out\sccm .\graph --lookup-file .\out\lookup.duckdb
# High-confidence — collect WITH --disable-possible-edges; only confirmed relay/site-DB evidence
uv run openhound collect sccm .\out-confirmed -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\lowpriv" -p "Passw0rd!" --disable-possible-edges --clean
openhound preprocess sccm .\out-confirmed .\out-confirmed\lookup.duckdb
openhound convert sccm .\out-confirmed\sccm .\graph-confirmed --lookup-file .\out-confirmed\lookup.duckdbNote:
--disable-possible-edgesis a collect-time flag.collectpersists it to thecollection_settingstable, whichpreprocessreads; it is not accepted bypreprocessorconvert. To tighten an existing raw collection into high-confidence mode without re-collecting, set the same environment variablecollectuses internally for the flag and re-runpreprocess:SOURCES__SCCM__DISABLE_POSSIBLE_EDGES=true openhound preprocess sccm .\out .\out\lookup.duckdbThis override is tightening-only — a truthy value forces possible edges off, but it can never re-enable possible edges that were already disabled at collect time. It has no effect (behavior is unchanged) when unset.
| Option | Description |
|---|---|
--run-integration-tests |
Implies --run-all; asserts the collected graph against the built-in mayyhem lab fixtures — build that lab with Mayyhem/ludus_sccm (see Validate against a real hierarchy). Prints PASS/FAIL/SKIP + summary + coverage, writes integration_results-<ts>.json, exits non-zero on any failure. |
--integration-privilege <auto|high|low> |
Which fixture set --run-integration-tests asserts. Describes the collection, not the fixtures. auto (the default) decides from whether any adminservice_* / wmi_* table actually returned rows this run. low skips the cases marked requires_privilege — the SCCM-admin-only RBAC families (SCCM_FullAdministrator, SCCM_IsAssigned, SCCM_IsMappedTo, SCCM_AllPermissions and the SCCM_AdminUser / SCCM_SecurityRole / SCCM_Collection nodes), which have no AD/LDAP representation and which RemoteRegistry does not expose — see Collection privilege tiers. high asserts every case. Everything outside that set is asserted in all modes, so a low-privilege run stays a real gate. |
--compare-to-zip <path> |
Implies --run-all; deep-diffs this run's graph against an arbitrary node/edge payload (a CMBP zip or another OpenHound run) down to property name/value, with a by-kind rollup. The saved payload is the baseline; this run is the candidate. Writes compare-<ts>.json and exits non-zero if this run lost anything the baseline had. |
Which privilege mode? Normally none —
autoreads this run's own AdminService/WMI row counts and picks. Reach for--integration-privilege lowwhen a run did reach those services but you know the data is unrepresentative, andhighwhen a partially privileged run should still be held to the full fixture set. The verdict and the evidence behind it are logged either way.
Examples (mayyhem.com lab):
# Assert this collection matches the known-good SCCM graph (implies --run-all).
# These credentials are a plain domain user, so AdminService/WMI never collect --
# auto detects that and skips the SCCM-admin-only cases instead of failing them:
uv run openhound collect sccm ./out -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\lowpriv" -p "Passw0rd!" --run-integration-tests
# Hold a partially privileged run to the full fixture set, SCCM-admin-only cases included:
uv run openhound collect sccm ./out -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\sccmadmin" -p "Passw0rd!" --run-integration-tests --integration-privilege high
# Diff this collection against a saved payload. The zip is the BASELINE; this run is the
# candidate. Exits non-zero if this run lost anything the zip had -- which a CMBP zip always
# will, since the two tools emit different sets. That is a true statement about the two
# payloads, not a defect in the run:
uv run openhound collect sccm ./out -d mayyhem.com --dc dc01.mayyhem.com -u "MAYYHEM\lowpriv" -p "Passw0rd!" --compare-to-zip ./bloodhound-sccm-baseline.zip| Option | Description |
|---|---|
-v, --verbose |
Raise the console to VERBOSE (per-resolution / per-node / per-edge traces; PS1 [Verbose] parity). Without it the console is INFO (step summaries). |
--silent |
Silence all console output. The two on-disk logs (collect_full_* = complete DEBUG trace, collect_issues_* = warnings/errors with tracebacks) are still written. Also forces --progress off. |
--debug |
DEBUG level (very chatty; includes dlt and ldap3 internals). Outranks -v. |
Whatever the console level, every run writes both on-disk logs into the output directory.
collect_full_<timestamp>.log is the complete DEBUG trace in human-readable order — grouped
host-by-host for the per-host phases and resource-by-resource for discovery — so the full story is
readable after the fact without re-running. collect_issues_<timestamp>.log holds only warnings and
errors, each with a traceback, and is not created at all by a clean run. --debug additionally folds
the dlt/ldap3 internals into the full log.
Collecting as a plain domain user is a supported, first-class mode — see Collection privilege tiers for what it builds. It is not meant to look like a failing run, so two things that are expected at low privilege are deliberately kept off the console:
-
Refused registry reads. Roughly a dozen reads per site system are admin-gated (the SMB-signing, NTLM and SQL Server keys). Each denied read is logged at VERBOSE into
collect_full_<timestamp>.log, and each host then emits one warning listing the keys it could not read:WARNING [ps1-sms.mayyhem.com][RemoteRegistry] Access denied reading 7 registry key(s): SOFTWARE\Microsoft\SMS\CurrentUser SYSTEM\CurrentControlSet\Services\LanManServer\Parameters SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0 SYSTEM\CurrentControlSet\Control\Lsa SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL SOFTWARE\Microsoft\MSSQLServer\MSSQLServer\SuperSocketNetLib SOFTWARE\Microsoft\SMS\TriggersThe host-hardening and SQL Server keys require local Administrators on the target. Re-running as a local administrator on those hosts is what fills the gap; nothing else is wrong.
-
Skipped preprocess transforms. Most
node_*and edge tables are built from AdminService or WMI data. Without privilege neither transport produces anything, so the transforms that read them are skipped — 106 of them in a nine-host lab run. When no privileged table exists at all this is an unprivileged run behaving correctly, so each skip is logged at DEBUG. If AdminService did run and a single table is still missing, that one stays a WARNING: the transport was up and that query came back empty, which is worth investigating. -
Probes that come back negative. Asking a host "are you an SMS Provider?" and being told no is a discovery result, not a failure. A host that does not serve the endpoint is reported at INFO (
Unable to connect to https://… ; skipping remaining HTTP checks), and an AdminService probe answered with HTTP 404 at VERBOSE — the INFO line right behind it,… is not a reachable AdminService provider; skipping, is the conclusion worth reading. A 401, 403 or 500 is not downgraded: those mean the provider exists and rejected you or broke, which is a finding. -
impacket's Kerberos-cache notice. When the WMI phase tries Kerberos without
--ticket, impacket looks for a credential cache inKRB5CCNAME(a Unix convention, effectively never set on Windows), logsCRITICAL: CCache file is not found. Skipping..., and then requests a fresh ticket with your password and carries on. Only the cache lookup is skipped, never collection, so the collector demotes that record to DEBUG and logs its own VERBOSE line explaining what happened. Any other impacket CRITICAL still reaches you unchanged.
The practical consequence: on a healthy low-privilege run, collect_issues_<timestamp>.log holds the
handful of things you can actually act on, instead of a hundred access-denied lines. An ERROR in that
file means something genuinely went wrong.
Two things about the console stream will surprise you if you try to capture it, and both come from the OpenHound framework's logging setup rather than this collector:
- On a terminal, console logs go to stderr, not stdout. The framework binds its Rich handler to
Console(stderr=True), soopenhound collect sccm ... > out.txtcaptures nothing while the same run prints normally to your screen. Redirect stderr (2>) or both (> out.txt 2>&1). - Redirecting stdout at all silences the console entirely. The framework picks its logging mode
by asking
sys.stdout.isatty(): a TTY gets the Rich console handler, and anything else falls through to service mode, which installs only a file handler. So a redirected or piped run produces zero console output no matter which stream you capture — the handler does not exist.
Set LOG_CONTAINER=1 to force the framework into container mode, which logs to stdout with a
plain StreamHandler and is therefore capturable:
# Nothing captured -- service mode, no console handler at all:
uv run openhound collect sccm ./out -d mayyhem.com > run.log 2>&1 # run.log is empty
# Captured -- container mode logs to stdout:
LOG_CONTAINER=1 uv run openhound collect sccm ./out -d mayyhem.com > run.log 2>&1# PowerShell equivalent
$env:LOG_CONTAINER = "1"
uv run openhound collect sccm .\out -d mayyhem.com *> run.logEither way the on-disk logs in the output directory are always written, so
collect_full_<timestamp>.log remains the most complete record — reach for it before fighting the
console stream. This matters most in CI, where an empty job log next to a non-zero exit code looks
like a crash and is really just service mode.
Run the collector on an outside box and tunnel everything through a SOCKS5 pivot inside the target network:
openhound collect sccm ./out \
-d mayyhem.com --dc dc.mayyhem.com \
-u lowpriv -p 'Password123!' \
--proxy socks5://127.0.0.1:1080All discovery (LDAP/DNS/DC) and every per-host protocol (RemoteRegistry, MSSQL, AdminService, WMI, HTTP, SMB) egress at the proxy. Destination names resolve at the proxy (socks5h); our own DNS lookups are forced onto TCP so they ride the tunnel too.
--dc or --dns is required with --proxy: internal names can't be
resolved from the outside box, so pin the DC (--dc) or point at an internal
resolver reachable through the pivot (--dns).
Authentication through a pivot. Explicit credentials, pass-the-hash
(--nt-hash) and pass-the-ticket (--ticket) tunnel completely — including the
Kerberos KDC exchange, which impacket performs in-process. Live current-user
single sign-on (SSPI) cannot be tunneled by the tool: Windows itself contacts
the KDC, and that traffic never touches our sockets. To use a logged-in identity
through the pivot, export its Kerberos ticket and pass --ticket, or set up
OS-level transparent proxying (tun2socks / Proxifier) on the outside box.
Quick Start uploads through the BloodHound UI, which is the right choice for a one-off assessment. Scripting it instead pays off when you re-run against the same hierarchy, seed several instances, or wire the collector into CI.
The collector itself does not upload — the direct BloodHound CE upload and its sixteen CLI options
were deliberately removed (see the changelog in ARCHITECTURE.md), so --run-all
stops at writing files. Uploading is a separate, explicit step.
Both commands below need a BloodHound URL and a JWT. Take the JWT from your browser session, without the
Bearer prefix.
Each schema file is already in the shape BloodHound's OpenGraph extension endpoint expects (schema,
node_kinds, relationship_kinds, environments), so it uploads as-is — one call per file:
curl -X PUT "$BHURL/api/v2/extensions" -H "Authorization: Bearer $BHTOKEN" \
-H "Content-Type: application/json" --data-binary @schema_SCCM.json
curl -X PUT "$BHURL/api/v2/extensions" -H "Authorization: Bearer $BHTOKEN" \
-H "Content-Type: application/json" --data-binary @schema_MSSQL.jsonIt is an upsert, so re-running it after editing a kind's icon or color updates the existing
registration rather than erroring. BloodHound marks this endpoint experimental — check it against
your version: https://bloodhound.specterops.io/opengraph/schema
The files in cypher_queries/ are already in BloodHound's saved-query format, so the
openhound CLI uploads the whole directory:
uv run openhound searches upload .\cypher_queriesIt reads the target instance from your dlt secrets, ~/.dlt/secrets.toml:
[destination.bloodhound]
url = "https://bloodhound.example.com"
token = "<BloodHound JWT, no 'Bearer' prefix>"A query whose name already exists is skipped by default; pass --strategy overwrite to replace it.
That default is what makes the command safe to re-run — it will not duplicate the library on a second
invocation.
The payloads themselves go through BloodHound's file-upload job API — create a job, upload each file to
it, then close it. See
Create file upload job
and Upload file to job.
Remember to send both the sccm_* and ad_* files.
The collector follows OpenHound's standard three-phase pipeline:
| Phase | Command | Role |
|---|---|---|
| collect | openhound collect sccm |
Talk to LDAP/SCCM/SQL/SMB and write raw JSONL tables. |
| preprocess | openhound preprocess sccm |
Load the JSONL into DuckDB and build lookup + derived tables (transforms.py, lookup.py). |
| convert | openhound convert sccm |
Read the JSONL + DuckDB lookup and emit OpenGraph nodes/edges. |
Node identity. Every node carries a stable string id and an environmentid tying it to its collected environment. AD-native nodes (Computer, User, Group) use the AD SID as the id and the AD domain SID (the S-1-5-21-X-Y-Z prefix stripped of the trailing RID) as environmentid, so they merge with SharpHound data by SID. SCCM_Site uses the site code as both id and environmentid (scoped to the hierarchy root site code). The common node/property base classes live in graph.py (SCCMNode, property dataclasses); node and edge kind strings live in kinds/nodes.py and kinds/edges.py.
Kinds emitted (declared in kinds/nodes.py). convert emits 15 node kinds. Every AD-native node additionally carries the secondary Base label so BloodHound treats it as a first-class principal:
- AD-native:
Computer,User,Group,Container(each also labeledBase) —Containeris a standard BloodHound base kind (System Management container), not inschema_SCCM.json - SCCM:
SCCM_Site,SCCM_ClientDevice,SCCM_Collection,SCCM_AdminUser,SCCM_SecurityRole - MSSQL:
MSSQL_Server,MSSQL_Login,MSSQL_Database,MSSQL_DatabaseUser,MSSQL_ServerRole,MSSQL_DatabaseRole
The synthetic Authenticated Users node added by graph-pipeline Stage 6 — the coerce-and-relay increment of the preprocess/convert port, docs/superpowers/plans/2026-06-30-sccm-preproc-convert-stage6.md — is an instance of the existing Group kind (id UPPER(FQDN)-S-1-5-11), not a 16th kind.
Convert-time enrichment. Nodes are built from coalesced DuckDB tables (node_computer, node_user, node_group, node_site) computed by preprocess. Each table unions multiple raw collected sources (AdminService, WMI, LDAP, RemoteRegistry, SMB, HTTP) into one row per identity, so a node's richness grows as more collection phases come online, without changing the model.
Three output payloads. convert writes the graph as three file sets into the same output directory:
| Files | metadata.source_kind |
Contents |
|---|---|---|
sccm_nodes-*.json, sccm_edges-*.json |
"SCCM" |
SCCM-specific nodes (SCCM_Site, SCCM_Collection, SCCM_AdminUser, SCCM_SecurityRole, SCCM_ClientDevice) and edges where at least one endpoint is an SCCM node. |
mssql_nodes-*.json, mssql_edges-*.json |
"MSSQL" |
MSSQL nodes (MSSQL_Server, MSSQL_Database, MSSQL_ServerRole, MSSQL_DatabaseRole, MSSQL_Login, MSSQL_DatabaseUser) and edges where both endpoints are MSSQL nodes (MSSQL_Contains, MSSQL_ControlServer, MSSQL_ControlDB, MSSQL_MemberOf, MSSQL_IsMappedTo). |
ad_nodes-*.json, ad_edges-*.json |
(none — no metadata block) |
AD-native nodes (Computer, User, Group, Container, and backfill stubs) and every edge where either endpoint is an AD node (AD↔AD, AD↔SCCM, and AD↔MSSQL). |
The AD payload deliberately carries no source_kind so BloodHound merges those nodes into its native AD graph by SID — augmenting existing SharpHound data rather than registering a separate SCCM-owned copy. The MSSQL payload carries its own source_kind="MSSQL" so re-ingesting or deleting the SCCM source never touches SQL topology, even though this collector emits it — the separate MSSQL OpenGraph schema (schema_MSSQL.json) owns it. An AD↔SCCM or AD↔MSSQL edge lives in the AD payload but references an SCCM_* or MSSQL_* node defined in a different payload; BloodHound resolves the reference by id across all ingested files, so upload all three file sets (the whole output directory) to File Ingest.
flowchart LR
subgraph C["collect (per-host phases)"]
direction TB
LDAP[LDAP / AD]
ADM[AdminService / WMI]
RR[RemoteRegistry]
SMB[SMB]
HTTP[HTTP]
SQL[MSSQL]
end
C -->|"~45 raw JSONL tables"| P
subgraph P["preprocess (DuckDB — transforms.py)"]
direction TB
COAL["coalesce → node_* (one row per entity)"]
EDG["build graph_edges (UNION of per-kind SELECTs)"]
COAL --- EDG
end
P -->|"lookup.duckdb"| V
subgraph V["convert (convert_pipeline.py — Convert2-Read-DB)"]
RD["read node_* / graph_edges → typed OpenGraph models"]
end
V --> O1["sccm_nodes/edges-*.json<br/>source_kind = SCCM"]
V --> O3["mssql_nodes/edges-*.json<br/>source_kind = MSSQL"]
V --> O2["ad_nodes/edges-*.json<br/>untagged — merges into AD graph"]
O1 --> BH["BloodHound File Ingest"]
O3 --> BH
O2 --> BH
Representative cross-cluster edges only; the Edge Reference section below carries the exhaustive, per-shape detail for all 38 edge kinds.
flowchart LR
subgraph AD["Active Directory"]
Computer
User
Group
Container
end
subgraph SCCM["SCCM"]
SCCM_Site
SCCM_ClientDevice
SCCM_Collection
SCCM_AdminUser
SCCM_SecurityRole
end
subgraph MSSQL["MSSQL"]
MSSQL_Server
MSSQL_Database
MSSQL_Login
MSSQL_ServerRole
MSSQL_DatabaseRole
MSSQL_DatabaseUser
end
User -->|MemberOf| Group
Group -->|GenericAll| Container
Computer -->|HasSession| User
Group -->|SCCM_IsMappedTo| SCCM_AdminUser
SCCM_AdminUser -->|SCCM_FullAdministrator| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_AllPermissions| SCCM_Site
SCCM_Site -->|SCCM_HasClient| SCCM_ClientDevice
SCCM_ClientDevice -->|SCCM_SameHostAs| Computer
Computer -->|MSSQL_HostFor| MSSQL_Server
MSSQL_Login -->|MSSQL_MemberOf| MSSQL_ServerRole
MSSQL_ServerRole -->|MSSQL_ControlServer| MSSQL_Server
Group -->|SCCM_CoerceAndRelayToAdminService| SCCM_Site
Group -->|MSSQL_CoerceAndRelayToMSSQL| MSSQL_Login
Group -->|SCCM_CoerceAndRelayToSMB| Computer
classDef ad fill:#dae8fc,stroke:#6c8ebf,color:#000;
classDef sccm fill:#d5e8d4,stroke:#82b366,color:#000;
classDef mssql fill:#ffe6cc,stroke:#d79b00,color:#000;
class Computer,User,Group,Container ad;
class SCCM_Site,SCCM_ClientDevice,SCCM_Collection,SCCM_AdminUser,SCCM_SecurityRole sccm;
class MSSQL_Server,MSSQL_Database,MSSQL_ServerRole,MSSQL_DatabaseRole,MSSQL_Login,MSSQL_DatabaseUser mssql;
Every edge kind the collector emits. Node color = cluster (AD / SCCM / MSSQL). Some kinds (
MSSQL_Contains,MSSQL_MemberOf,SCCM_IsAssigned) have more than one endpoint shape; extra shapes are drawn so all node kinds appear.
flowchart LR
%% AD-native
Computer; User; Group; Container
%% SCCM
SCCM_Site; SCCM_ClientDevice; SCCM_Collection; SCCM_AdminUser; SCCM_SecurityRole
%% MSSQL
MSSQL_Server; MSSQL_Database; MSSQL_ServerRole; MSSQL_DatabaseRole; MSSQL_Login; MSSQL_DatabaseUser
SCCM_Site -->|SCCM_AdminsReplicatedTo| SCCM_Site
User -->|SCCM_IsMappedTo| SCCM_AdminUser
SCCM_AdminUser -->|SCCM_IsAssigned| SCCM_SecurityRole
SCCM_AdminUser -->|SCCM_IsAssigned| SCCM_Collection
SCCM_Collection -->|SCCM_HasMember| SCCM_ClientDevice
SCCM_Site -->|SCCM_HasClient| SCCM_ClientDevice
SCCM_ClientDevice -->|SCCM_HasPrimaryUser| User
SCCM_ClientDevice -->|SCCM_HasCurrentUser| User
SCCM_ClientDevice -->|SCCM_HasADLastLogonUser| User
SCCM_Site -->|SCCM_HasStoredAccount| User
User -->|MemberOf| Group
Group -->|GenericAll| Container
Computer -->|HasSession| User
SCCM_Site -->|SCCM_Contains| SCCM_Collection
SCCM_AdminUser -->|SCCM_FullAdministrator| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_ApplicationAuthor| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_ApplicationAdministrator| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_ComplianceSettingsManager| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_OSDManager| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_OperationsAdministrator| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_SecurityAdministrator| SCCM_ClientDevice
SCCM_AdminUser -->|SCCM_AllPermissions| SCCM_Site
Computer -->|SCCM_AssignAllPermissions| SCCM_Site
SCCM_ClientDevice -->|SCCM_SameHostAs| Computer
Computer -->|SCCM_LocalAdminRequired| Computer
MSSQL_Server -->|MSSQL_Contains| MSSQL_Database
MSSQL_Database -->|MSSQL_Contains| MSSQL_DatabaseRole
MSSQL_ServerRole -->|MSSQL_ControlServer| MSSQL_Server
MSSQL_DatabaseRole -->|MSSQL_ControlDB| MSSQL_Database
Computer -->|MSSQL_HostFor| MSSQL_Server
MSSQL_Server -->|MSSQL_ExecuteOnHost| Computer
Computer -->|MSSQL_HasLogin| MSSQL_Login
MSSQL_Login -->|MSSQL_IsMappedTo| MSSQL_DatabaseUser
MSSQL_Login -->|MSSQL_MemberOf| MSSQL_ServerRole
MSSQL_DatabaseUser -->|MSSQL_MemberOf| MSSQL_DatabaseRole
User -->|MSSQL_ServiceAccountFor| MSSQL_Server
User -->|MSSQL_GetAdminTGS| MSSQL_Server
User -->|MSSQL_GetTGS| MSSQL_Login
Group -->|SCCM_CoerceAndRelayToAdminService| SCCM_Site
Group -->|MSSQL_CoerceAndRelayToMSSQL| MSSQL_Login
Group -->|SCCM_CoerceAndRelayToSMB| Computer
classDef ad fill:#dae8fc,stroke:#6c8ebf,color:#000;
classDef sccm fill:#d5e8d4,stroke:#82b366,color:#000;
classDef mssql fill:#ffe6cc,stroke:#d79b00,color:#000;
class Computer,User,Group,Container ad;
class SCCM_Site,SCCM_ClientDevice,SCCM_Collection,SCCM_AdminUser,SCCM_SecurityRole sccm;
class MSSQL_Server,MSSQL_Database,MSSQL_ServerRole,MSSQL_DatabaseRole,MSSQL_Login,MSSQL_DatabaseUser mssql;
Currently emitted: 15 node kinds —
Computer,User,Group,Container,SCCM_Site,SCCM_ClientDevice,SCCM_Collection,SCCM_AdminUser,SCCM_SecurityRole,MSSQL_Server,MSSQL_Database,MSSQL_ServerRole,MSSQL_DatabaseRole,MSSQL_Login, andMSSQL_DatabaseUser. Graph-pipeline Stage 6 — the coerce-and-relay increment of the preprocess/convert port,docs/superpowers/plans/2026-06-30-sccm-preproc-convert-stage6.md; see the Reference key — adds a synthetic Authenticated UsersGroupnode for each domain that produces a coerce-and-relay edge (see theGroupsection).
All AD-native nodes (Computer, User, Group) use the AD SID as the node id and the AD domain SID (S-1-5-21-X-Y-Z) as environmentid. Builtin or well-known SIDs that have no domain part are qualified with a co-occurring domain SID where available; nodes that cannot be placed in a domain environment are dropped and logged. Container is also AD-native but is keyed by objectGUID rather than a SID (it has none) — see its own section below. Property keys use ConfigManBearPig's original casing (camelCase/PascalCase), not snake_case — see graph.py.
The icon under each heading is the glyph BloodHound renders that kind with. For the SCCM_* and MSSQL_* kinds it comes from this collector's hand-maintained schema_SCCM.json / schema_MSSQL.json — change an icon or color there and update the image here in the same commit. The four AD-native kinds use BloodHound's own built-in base-kind icons, which this collector does not define.
The four AD-native kinds are named in SharpHound's own format, uppercase:
| Kind | Format | Example |
|---|---|---|
User |
SAMACCOUNTNAME@DOMAIN.FQDN |
DOMAINUSER@MAYYHEM.COM |
Group |
SAMACCOUNTNAME@DOMAIN.FQDN |
DOMAIN ADMINS@MAYYHEM.COM |
Computer |
HOSTNAME.DOMAIN.FQDN |
PS1-MP.MAYYHEM.COM |
Container |
NAME@DOMAIN.FQDN |
SYSTEM MANAGEMENT@MAYYHEM.COM |
Why this matters, and why a name is sometimes absent. These nodes are emitted untagged (no
source_kind) so BloodHound merges them into its native AD graph by id — SIDs for principals,
objectGUID for the container — rather than shadowing a SharpHound collection with a competing copy.
Merging by id means this collector writes into a node that may already exist and already be correct, so
every property it emits overwrites SharpHound's. A name in any other format (a bare PS1-MP, an SCCM
mayyhem\Domain Admins, a full DN, or the object's own SID) would therefore replace a correct
SharpHound label with a worse one, and the operator's graph would get worse after ingesting SCCM data.
So the rule is: emit the SharpHound form, or emit nothing. When the domain FQDN cannot be resolved, the
name and displayname properties are omitted entirely rather than falling back to a partial name.
BloodHound then displays the object id, and any SharpHound-collected label survives untouched. The same
applies to backfilled stub nodes, which have no name at all by design.
The domain FQDN is resolved from the object's own domain attribute, else from its DN's DC=
components, else from a domain-SID → FQDN map built from every LDAP-resolved principal in the run. That
last fallback is what lets principals discovered from a non-LDAP direction still be named — the SCCM site
database's SQL service account, for instance, arrives from SQL Server as a bare account name plus a SID
and is never looked up in AD, but its SID shares a domain prefix with principals that were.
If a run emits unnamed AD nodes, check the preprocess log for an ad_props is empty or
domain_fqdn_by_sid is empty warning — both mean no LDAP-resolved principal was available to supply
domain context.
SCCM attack paths commonly originate from a well-known group (Authenticated Users → coerce a site
server → relay to a management point). For those paths to be traversable, the group node must exist. The
decision per principal:
| Principal | Seeded by this collector? | Rationale |
|---|---|---|
| Authenticated Users | Yes, synthetically | It is the start of all three coerce-and-relay edge kinds, which this collector is the only source of. Without it those edges dangle and BloodHound drops them, so the collector's headline attack paths would be invisible standalone. One node per domain that actually produces a relay edge, keyed UPPER(FQDN)-S-1-5-11 to merge with SharpHound's. |
| Everyone | No | This collector emits no edge that starts at Everyone. A node with no traversable edge is graph noise: true, but useless for pathfinding, and it would still overwrite SharpHound's label on merge. If a future edge kind starts at Everyone, seed it then, on the same lazy per-domain basis as Authenticated Users. |
| Domain Computers | No | Same reasoning. Domain Computers appears in this graph only as the end of MemberOf edges whose start this collector discovered, and those ends are already covered — either by a real node built from ad_props, or by an endpoint stub. Seeding it eagerly would add a node the collector cannot say anything useful about. |
What a standalone graph therefore does not have: real membership for the seeded Authenticated Users
node. It is emitted with the relay edges that need it, but no MemberOf edges point into it, because
enumerating "every principal in the domain" is an AD collection's job, not an SCCM collector's. Paths
through Authenticated Users from an arbitrary user therefore require a SharpHound collection ingested
alongside this one. Paths from Authenticated Users onward — the coerce-and-relay chain this tool
exists to surface — are fully traversable standalone.
An AD computer account observed in SCCM — collected from AdminService/WMI resource tables, LDAP, RemoteRegistry, SMB, and HTTP sources and coalesced into one row per SID. Model: models/computer.py.
- Node id: the AD SID (uppercased, e.g.
S-1-5-21-11-22-33-1104). environmentid: the AD domain SID (S-1-5-21-11-22-33).- Kinds:
["Computer", "Base"]. name/displayname: SharpHound's form — the uppercase DNS host name, e.g.PS1-MP.MAYYHEM.COM. Omitted entirely if that form cannot be built; see AD node naming.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
SCCMSiteSystemRoles |
list<string> | SCCM site-system roles observed on this host (e.g. SMS Provider, SMS Distribution Point). |
SCCMResourceIDs |
list<string> | SCCM resource IDs in "<id>@<site_code>" format, one per site that enrolled this device. |
SCCMInfra |
bool | true if this computer is an SCCM infrastructure host (site system, server). |
SCCMClientDeviceIdentifier |
string | The SCCM client GUID (sms_unique_identifier / GUID:…). |
SMBSigningRequired |
bool | true if SMB signing is required on this host (from RemoteRegistry or SMB signing-check). |
SCCMHasClientRemoteControlSPN |
bool | true if the host has a CmRcService SPN in AD (LDAP-discovered). |
networkBootServer |
bool | true if the host was discovered as a network boot server in AD. |
disableLoopbackCheck |
bool | true if the loopback check is disabled (RemoteRegistry). |
restrictReceivingNtlmTraffic |
string | NTLM restriction policy value (e.g. Off, Deny_All) from RemoteRegistry. |
SCCMClientCertificateRequired |
bool | true if the host's SCCM site systems require a client certificate (from HTTP probing). |
SCCMHostsContentLibrary |
bool | true if an SCCM content library share was found on this host (SMB). |
SCCMIsPXESupportEnabled |
bool | true if PXE support was found on this host (SMB REMINST share). |
dNSHostName |
string | DNS hostname of this computer (from AdminService resource tables, LDAP, and SMB sources). |
samAccountName |
string | AD sAMAccountName of this computer account (from LDAP and HTTP sources). |
distinguishedName |
string | AD distinguished name (from LDAP and SMB sources). |
Domain |
string | AD domain (e.g. lab.local) this computer's account belongs to; null if the account was never resolved against AD. |
Enabled |
bool | true/false if this computer account is enabled/disabled in AD; null if it was never resolved against AD. |
IsDomainPrincipal |
bool | true if this computer was successfully resolved to a real AD object via LDAP; null if it wasn't (unknown, not "no"). |
Type |
string | AD object type this computer resolved to (e.g. Computer); null if never resolved against AD. |
objectClass |
list<string> | AD objectClass values for this computer's account (e.g. ["top", "person", "computer"]); null if never resolved against AD. |
servicePrincipalName |
list<string> | Kerberos SPNs published on this computer's AD account; null if never resolved against AD. |
CN |
string | AD cn (Common Name) attribute for this computer's account; null if never resolved against AD. |
AD-resolution properties (
Domain,Enabled,IsDomainPrincipal,Type,objectClass,servicePrincipalName,CN). These come from the AD attributes captured whenever this computer was actually resolved against Active Directory during collection — the same LDAP lookups the collector already performs to turn a name/SID/DN into an AD object (finding a site server, an SCCM admin, a device's referenced user, and so on). They're populated only when a lookup like that happened to hit this computer; a computer SCCM knows about that collection never needed to resolve against AD staysnullin all seven fields. See ARCHITECTURE.md for how this cache is captured and joined. The same seven properties, and the same caveat, apply toUserandGroupbelow.
Properties not yet emitted: DHCP/PXE detail fields (
pxe_vendor_class,pxe_next_server,pxe_boot_file,tftp_reachable,is_dhcp_server) — blocked on a DHCP/PXE collector; see Limitations.
The AD System Management container — the object under which SCCM publishes its site/management-point
objects and whose DACL is the "who can control SCCM via AD" attack surface. Sourced from
ldap_system_management_dacl, which the collector always read for its DACL but (before the low-privilege
work) fed no node at all — only the GenericAll edges pointing at it existed. Model:
models/container.py.
Container is a standard BloodHound base kind, not an SCCM-specific one — it is deliberately not
listed in schema_SCCM.json so it composes with a SharpHound collection's own Container node for the
same AD object rather than registering a competing SCCM-owned copy.
- Node id: the container's own
objectGUID, uppercased to match SharpHound's own id form for the same object (so the two merge). environmentid: the AD domain SID, derived from any co-occurringGenericAllprincipal's domain-relative SID (the container itself has no SID of its own to derive a domain from) — the same "co-occurring domain SID" fallbackGroupuses for well-known SIDs above.- Kinds:
["Container", "Base"]. name/displayname: SharpHound's form — uppercaseNAME@DOMAIN.FQDN, e.g.SYSTEM MANAGEMENT@MAYYHEM.COM, built from the DN's leadingCN=and itsDC=components. Omitted entirely if the DN wasn't captured; see AD node naming. (The DN itself remains available as thedistinguishednameproperty below.)
| Property | Type | Description |
|---|---|---|
distinguishedname |
string | The container's AD distinguished name (e.g. CN=System Management,CN=System,DC=mayyhem,DC=com); null if the DN wasn't captured. |
The container carries no SCCM-specific properties — it's a plain BloodHound Container. Note the
lowercase distinguishedname: this is the one node kind whose property key deliberately does not
follow ConfigManBearPig's camelCase, because ConfigManBearPig never emitted a Container node at all (it
only read the container's DACL to discover scan targets), so there is no original casing to match. What
this node does have to match is SharpHound, which it merges with by objectGUID and which writes AD node
properties in lowercase. Cypher property lookups are case-sensitive, so a camelCase key here would be
unreachable from any query written against a SharpHound-collected graph.
Confirmed, both flag modes. This node is built directly from an ACL read off AD, not a template, so it is emitted identically regardless of
--disable-possible-edges.
An AD group observed in SCCM — either named in a device's or user's security_group_name list or present directly in the SCCM admins tables. Model: models/group.py.
security_group_name carries only group names; the SIDs come from the SMS_R_UserGroup resource (AD Security Group Discovery mirrors each group, with its SID, into adminservice_user_group / wmi_user_group). preproc folds those (name, SID) pairs into the principal_by_name lookup, so a name→SID join resolves each membership offline — replacing ConfigManBearPig's live per-name Active Directory lookup. (A name shared by two distinct groups can't be disambiguated from the name alone, so both resolve.)
- Node id: the AD SID (uppercased).
environmentid: the AD domain SID; builtin SIDs use a co-occurring domain SID as a fallback.- Kinds:
["Group", "Base"]. name/displayname: SharpHound's form — uppercaseSAMACCOUNTNAME@DOMAIN.FQDN, e.g.DOMAIN ADMINS@MAYYHEM.COM. Omitted entirely if that form cannot be built; see AD node naming.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
SCCMInfra |
bool | true if this group appears in the SCCM admins tables. |
SCCMResourceIDs |
list<string> | SCCM resource IDs in "<id>@<site_code>" format. |
Domain |
string | AD domain (e.g. lab.local) this group belongs to; null if never resolved against AD. |
Enabled |
bool | Always null in practice — AD groups have no ACCOUNTDISABLE bit — but present for schema symmetry with Computer/User. |
IsDomainPrincipal |
bool | true if this group was successfully resolved to a real AD object via LDAP; null if it wasn't (unknown, not "no"). |
Type |
string | AD object type this group resolved to (e.g. Group); null if never resolved against AD. |
objectClass |
list<string> | AD objectClass values for this group (e.g. ["top", "group"]); null if never resolved against AD. |
servicePrincipalName |
list<string> | Kerberos SPNs published on this group's AD object; null if never resolved against AD (groups rarely carry SPNs, but the field is present for schema symmetry). |
CN |
string | AD cn (Common Name) attribute for this group; null if never resolved against AD. |
AD-resolution properties (
Domain,Enabled,IsDomainPrincipal,Type,objectClass,servicePrincipalName,CN) — populated only for groups the collector actually resolved against AD during this run; see the note underComputerabove for how and why.
Deliberately absent from the table: the
Groupmodel also declaresSamAccountName(PascalCase here, matching ConfigManBearPig's Group output) anddistinguishedName, but both are alwaysnulltoday — see Limitations for why.
Synthetic Authenticated Users nodes (graph-pipeline Stage 6 — see the Reference key). For each domain that produces a coerce-and-relay edge,
preprocesssynthesises oneGroupnode representing the Windows Authenticated Users well-known group for that domain. The node id follows SharpHound's well-known-SID form so it merges with any SharpHound-collected node for the same domain:UPPER(<FQDN>)-S-1-5-11(e.g.MAYYHEM.COM-S-1-5-11). The node is created lazily — only domains that actually have at least one relay edge start node get a node — and it carriescollectionSource = [](the Group model does not populate a collection source for this synthetic node). Because the SIDS-1-5-11has no domain part of its own, theenvironmentidis resolved from a co-occurring domain computer's AD domain SID. These nodes are thestartof all three coerce-and-relay edge kinds (SCCM_CoerceAndRelayToAdminService,MSSQL_CoerceAndRelayToMSSQL,SCCM_CoerceAndRelayToSMB).
An AD user account observed in SCCM — collected from AdminService/WMI user resource tables, admin tables, reserved-account tables, and RemoteRegistry. Model: models/user.py.
- Node id: the AD SID (uppercased).
environmentid: the AD domain SID.- Kinds:
["User", "Base"]. name/displayname: SharpHound's form — uppercaseSAMACCOUNTNAME@DOMAIN.FQDN, e.g.DOMAINUSER@MAYYHEM.COM. Omitted entirely if that form cannot be built; see AD node naming.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
SCCMResourceIDs |
list<string> | SCCM resource IDs in "<id>@<site_code>" format. |
SCCMInfra |
bool | true if this account appears in the SCCM admins tables (an SCCM admin user). |
storedInSCCMSite |
string | Site code of the SCCM site that stores this account as a reserved/stored credential (SMS_SCI_Reserved). |
distinguishedName |
string | AD distinguished name from the SCCM user resource record (SMS_R_User). |
userPrincipalName |
string | AD user principal name (UPN) from the SCCM user resource record. |
samAccountName |
string | AD sAMAccountName (the pre-Windows-2000 logon name, e.g. sqlsccmsvc), camelCase to match Computer.samAccountName. Load-bearing: every edge keyed on a user (HasSession, MSSQL_GetTGS/MSSQL_GetAdminTGS/MSSQL_ServiceAccountFor, SCCM_HasPrimaryUser/SCCM_HasADLastLogonUser/SCCM_IsMappedTo) resolves its User endpoint by this key. |
Domain |
string | AD domain (e.g. lab.local) this user's account belongs to; null if never resolved against AD. |
Enabled |
bool | true/false if this user account is enabled/disabled in AD; null if never resolved against AD. |
IsDomainPrincipal |
bool | true if this user was successfully resolved to a real AD object via LDAP; null if it wasn't (unknown, not "no"). |
Type |
string | AD object type this user resolved to (e.g. User); null if never resolved against AD. |
objectClass |
list<string> | AD objectClass values for this user's account (e.g. ["top", "person", "user"]); null if never resolved against AD. |
servicePrincipalName |
list<string> | Kerberos SPNs published on this user's AD account; null if never resolved against AD. |
CN |
string | AD cn (Common Name) attribute for this user's account; null if never resolved against AD. |
AD-resolution properties (
Domain,Enabled,IsDomainPrincipal,Type,objectClass,servicePrincipalName,CN) — populated only for users the collector actually resolved against AD during this run; see the note underComputerabove for how and why.
Not yet emitted:
is_sccm_network_access_account— this property is set only when NAA secrets are decrypted, which requires the--enable-bad-opsecflag and the NAA-secret collector, neither of which is implemented yet.
An SCCM RBAC administrator — an AD user or group that has been granted SCCM administrative rights. Sourced from SMS_Admin via AdminService/WMI. Model: models/sccm_admin_user.py.
- Node id:
<UPPER_LOGON_NAME>@<root_site_code>(e.g.MAYYHEM\SCCMADMIN@PS1). environmentid: the hierarchy root site code.- Kinds:
["SCCM_AdminUser"]. name/displayname: the logon name / display name from the SCCM admin record.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
adminID |
string | SCCM internal admin ID. |
adminSid |
string | AD SID of this admin account or group. |
distinguishedName |
string | AD distinguished name (if available). |
isGroup |
bool | true if this admin entry is an AD group rather than a user. |
accountType |
int | SCCM account type integer. |
displayName |
string | Display name from the SCCM admin record. |
rootSiteCode |
string | Hierarchy root site code for this admin-user's site hierarchy. |
sourceSiteCode |
string | Site code of the site that owns this admin record. |
createdBy |
string | Logon name of the account that created this admin entry. |
createdDate |
string | Timestamp when this admin entry was created. |
lastModifiedBy |
string | Logon name of the account that last modified this admin entry. |
lastModifiedDate |
string | Timestamp of the last modification to this admin entry. |
collectionIds |
list<string> | Collection node IDs (COLLECTION_ID@SITE) this admin is assigned to (resolved via collection name). |
roleIDs |
list<string> | Raw security role IDs assigned to this admin (e.g. SMS0001R). |
memberOf |
list<string> | Node IDs of the collections this admin is scoped to (derived from SCCM_IsAssigned edges). |
SCCMInfra |
bool | Always true for an admin-user. |
An SCCM-managed client device, sourced from the AdminService or WMI SMS_R_System resource with is_client = True and is_obsolete = False. Coalesced into node_client_device by preprocess. Devices that have a CmRcService SPN in AD but no confirmed SCCM enrollment are emitted as inferred clients (is_confirmed_active_client = false, inferred from ldap_cmrc_devices), unless --disable-possible-edges was set at collection time. When an inferred client shares an ADDomainSID with a confirmed real client, the two are merged in _dedup_client_device (graph-pipeline Stage 4 — see the Reference key) and only the confirmed survivor is kept. Model: models/sccm_client_device.py.
- Node id: the SMSID (uppercased, e.g.
GUID:3F8A...) for confirmed clients (is_confirmed_active_client = true);<UPPER_OBJECT_SID>@<root_site_code>for inferred clients (is_confirmed_active_client = false). environmentid: the hierarchy root site code.- Kinds:
["SCCM_ClientDevice"]. name/displayname: the device name qualified with site code (e.g.WORKSTATION1@PS1).
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
SMSID |
string | The SCCM unique identifier (e.g. GUID:3F8A…). |
resourceID |
string | SCCM resource ID in "<id>@<site_code>" format. |
siteCode |
string | The enrolling site code. |
deviceOS |
string | Operating system string reported by SCCM. |
deviceOSBuild |
string | OS build string. |
isVirtualMachine |
bool | true if SCCM reports this device as a virtual machine. |
coManaged |
bool | true if the device is co-managed with Intune. |
AADDeviceID |
string | Azure AD device ID (if known). |
AADTenantID |
string | Azure AD tenant ID (if known). |
lastReportedMPServerName |
string | Hostname of the management point last reported by this client. |
primaryUser |
string | Primary user name (from SCCM user-device affinity). |
currentLogonUser |
string | Name of the user currently logged on. |
ADLastLogonUser |
string | Name of the last AD-logged-on user. |
is_confirmed_active_client |
bool | true for confirmed real SCCM-managed clients (AdminService/WMI source); false for inferred clients seen only via a CmRcService remote-control SPN. |
ADDomainSID |
string | AD domain SID of the device (used for graph-pipeline Stage 4 SCCM_SameHostAs dedup). |
ADLastLogonTime |
string | Timestamp of the device's last AD logon as reported by SCCM. |
ADLastLogonUserDomain |
string | Domain of the last AD-authenticated user (from UserDomainName in the device resource). |
rootSiteCode |
string | Hierarchy root site code for this device's site hierarchy. |
sourceSiteCode |
string | Site code of the site that enrolled this device. |
primaryUserSID |
string | AD SID of the primary user (resolved from primaryUser via the name lookup). |
currentLogonUserSID |
string | AD SID of the currently logged-on user (resolved from currentLogonUser). |
ADLastLogonUserSID |
string | AD SID of the last AD-authenticated user (resolved from user_name). |
lastReportedMPServerSID |
string | AD SID of the management point host last reported by this client (resolved from last_mp_server_name). |
collectionIds |
list<string> | Raw collection IDs this device belongs to (e.g. SMS00001). |
collectionNames |
list<string> | Display names of the collections this device belongs to. |
lastActiveTime |
string | Timestamp of the device's last active check-in (LastActiveTime). |
lastOnlineTime |
string | Timestamp the device was last seen online (CNLastOnlineTime). |
lastOfflineTime |
string | Timestamp the device last went offline (CNLastOfflineTime). |
SCCMInfra |
bool | true if this device is itself part of the SCCM infrastructure (rare for a client device; usually false). |
currentManagementPoint |
string | Name of the Management Point this device currently uses, from AdminService/WMI or (for the collector's own host) the Local SMS_Authority reading. |
currentManagementPointSID |
string | AD SID of the computer named in currentManagementPoint (resolved). |
previousSMSID |
string | This device's previous SMS unique identifier, if SCCM re-issued it a new one (Local-only; CCM_Client's PreviousClientId). |
previousSMSIDChangeDate |
string | Timestamp SCCM recorded when previousSMSID changed to the current SMSID (Local-only; CCM_Client's ClientIdChangeDate). |
userName |
string | Name of the user Active Directory's lastLogon/lastLogonTimestamp attributes show most recently signed in to this device. Mirrors ADLastLogonUser — CMBP emits the same collected value under both output keys. |
userDomainName |
string | AD domain of the user in userName. Mirrors ADLastLogonUserDomain. |
CN |
string | AD cn of the underlying computer this device runs on (joined by ADDomainSID); null if that computer was never itself AD-resolved during the run. |
DNSHostName |
string | FQDN of the underlying computer, as recorded in AD; null under the same condition as CN. |
distinguishedName |
string | AD distinguished name of the underlying computer; null under the same condition as CN. |
domain |
string | AD domain of the underlying computer. Lowercase domain (not Domain) — unlike Computer/User/Group, CMBP does not capitalize this key on SCCM_ClientDevice. null under the same condition as CN. |
objectClass |
list<string> | AD objectClass values of the underlying computer; null under the same condition as CN. |
samAccountName |
string | Pre-Windows-2000 logon name of the underlying computer (e.g. COMPUTER1$); null under the same condition as CN. |
servicePrincipalName |
list<string> | Kerberos SPNs published on the underlying computer's AD account; null under the same condition as CN. |
The seven properties above mirror the device's underlying
Computernode, not a second AD lookup. They're joined in fromnode_computerbyADDomainSIDduring preprocess (a device whoseADDomainSIDnever resolves to aComputernode — e.g. a possible/inferred client with no matching AD computer, see Limitations) staysnullin all seven.
An SCCM collection — a named set of devices or users used to scope deployments and security assignments. Sourced from SMS_Collection via AdminService/WMI. Model: models/sccm_collection.py.
- Node id:
<COLLECTION_ID>@<root_site_code>(e.g.SMS00001@PS1). environmentid: the hierarchy root site code.- Kinds:
["SCCM_Collection"]. name/displayname: the collection name qualified with root site code.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
collectionID |
string | The collection ID (e.g. SMS00001). |
collectionType |
string | Other, User, or Device (from the integer type field). |
memberCount |
int | Number of members in the collection. |
comment |
string | Collection description. |
isBuiltIn |
bool | true for SCCM built-in collections (e.g. All Systems). |
limitToCollectionID |
string | Collection ID that limits membership for this collection. |
limitToCollectionName |
string | Name of the limiting collection. |
collectionVariablesCount |
int | Number of collection variables defined on this collection. |
rootSiteCode |
string | Hierarchy root site code for this collection's site hierarchy. |
sourceSiteCode |
string | Site code of the site that owns this collection (from SMS_Collection.SourceSite metadata). |
lastChangeTime |
string | Timestamp of the last change to the collection definition. |
lastMemberChangeTime |
string | Timestamp of the last membership change in this collection. |
members |
list<string> | Raw ResourceID@SiteCode keys of the collection's members (faithful — built-in and unresolved members included). |
SCCMInfra |
bool | Always true for a collection. |
An SCCM RBAC security role — defines the set of operations an admin is permitted to perform. Sourced from SMS_Role via AdminService/WMI. Model: models/sccm_security_role.py.
- Node id:
<UPPER_ROLE_ID>@<root_site_code>(e.g.SMS000AR@PS1). environmentid: the hierarchy root site code.- Kinds:
["SCCM_SecurityRole"]. name/displayname: the role name qualified with root site code.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
roleID |
string | SCCM role ID (e.g. SMS000AR). |
roleName |
string | Human-readable role name (e.g. Full Administrator). |
roleDescription |
string | Description of the role's purpose. |
isBuiltIn |
bool | true for SCCM built-in roles. |
isSecAdminRole |
bool | true if this role grants Security Administrator privileges. |
copiedFromID |
string | Role ID this was cloned from (custom roles only). |
numberOfAdmins |
int | Number of admins assigned to this role. |
operations |
list<string> | List of SCCM operation strings granted by this role. |
rootSiteCode |
string | Hierarchy root site code for this role's site hierarchy. |
siteCode |
string | Site code of the site that owns this role (from SMS_Role.SourceSite). |
createdBy |
string | Logon name of the account that created this role. |
createdDate |
string | Timestamp when this role was created. |
lastModifiedBy |
string | Logon name of the account that last modified this role. |
lastModifiedDate |
string | Timestamp of the last modification to this role. |
members |
list<string> | Node IDs of the admin users assigned to this role (derived from SCCM_IsMappedTo edges). |
SCCMInfra |
bool | Always true for a security role. |
A Configuration Manager site, coalesced from AdminService/WMI site tables, site-definition tables, and LDAP mSSMSSite objects. Model: models/sccm_site.py.
- Node id: the site code (e.g.
PS1). environmentid: the hierarchy root site code (e.g.CAS); falls back to the site's own code for standalone deployments with no CAS.- Kinds:
["SCCM_Site"]. name/displayname: the human-readable site name, or the site code when no name is available.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Collection sources that contributed to this node. |
siteCode |
string | The site code (e.g. PS1). |
parentSiteCode |
string | Parent site in the hierarchy; null for the root (CAS) site. |
rootSiteCode |
string | Hierarchy root site code (CAS if present, else the parentless Primary). |
siteType |
string | Primary Site, Central Administration Site, or Secondary Site. |
siteGUID |
string | Site GUID from the site definitions or LDAP mSSMSHealthState. |
siteServerName |
string | Hostname of the primary site server. |
siteServerFQDN |
string | FQDN of the site server, from the resolved site-server computer. |
siteServerDomainSID |
string | Full SID of the site-server computer. |
SQLServerName |
string | Hostname of the SQL Server hosting the site database. |
SQLServerFQDN |
string | FQDN of the site database server, from SMS_SCI_SiteDefinition Props. |
SQLServerDomainSID |
string | Full SID of the SQL-server computer. |
SQLDatabaseName |
string | Site database name (e.g. CM_PS1). |
SQLServiceAccountName |
string | Domain account running the SQL Server service on this site's database server (from SMS_SCI_SysResUse). |
SQLServiceAccountDomainSID |
string | SID of the SQL service account resolved by name. |
SQLServicePort |
string | SQL Server service port from site-definition Props. |
version |
string | Site version string (e.g. 5.00.9106.1000). Privileged-preferred (AdminService/WMI); falls back to the unauthenticated HTTP ccmsetup.exe fingerprint (see Collection Overview) when privileged collection found none. |
buildNumber |
string | Build number (e.g. 9106). |
versionCVEs |
list<string> | CVE identifiers this site's SCCM build is still exposed to, derived from version via the SCCMVersionGuesser build/CVE map (cve_table.lookup_cves). Absent when version is unknown; an empty list when the version is known but fully patched. |
installDir |
string | Site server install directory. |
SCCMInfra |
bool | Always true for a site. |
distinguishedName |
string | AD distinguished name of the mSSMSSite object in the System Management container. |
sourceForest |
string | AD forest the site was published into (from mSSMSSourceForest on the LDAP site object). |
adminUsers |
list<string> | Admin node IDs (DOMAIN\\USER@SITE) for every SCCM admin in the hierarchy. |
storedAccounts |
list<string> | Uppercased AD object SIDs of accounts stored as reserved credentials in SMS_SCI_Reserved. |
siteSystemRoles |
list<string> | One "<dnsHostName>: <role>@<siteCode>" string per computer that hosts a site-system role at this site (e.g. "srv1.corp.local: SMS Site Server@CAS"), aggregated from every Computer.SCCMSiteSystemRoles entry suffixed with this site's code. Distinct from Computer.SCCMSiteSystemRoles, which is the same role data viewed per-host rather than per-site. Always an empty list on Secondary Sites (matching ConfigManBearPig). |
The SCCM site database on an MSSQL_Server (always named CM_<siteCode>). One node per site database, built only for SCCM-linked servers — non-SCCM scan-only servers produce no database node. Model: models/mssql_database.py.
- Node id:
<UPPER_HOST_SID>:<port>\<db_name>(e.g.S-1-5-21-11-22-33-1104:1433\CM_PS1). environmentid: the AD domain SID of the SQL host.- Kinds:
["MSSQL_Database"]. name/displayname: the database name (e.g.CM_PS1).- Note: Emitted to the MSSQL payload (
mssql_nodes-*.json,source_kind = "MSSQL").
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Every collection phase that contributed to the site-database row this is derived from — e.g. ["AdminService-SiteDefinition", "RemoteRegistry-MultisiteComponentServers"]. Not a live SQL read: the schema is templated from SCCM topology, and assumed below records whether that topology was confirmed or inferred. |
isTrustworthy |
bool | Always true — SCCM requires the TRUSTWORTHY database property for CLR execution. |
SCCMInfra |
bool | Always true for an SCCM site database. |
SCCMSite |
string | Site code of the SCCM site (e.g. PS1). |
SQLServer |
string | DNS hostname of the SQL Server hosting this database. |
assumed |
bool | true when this database rests on the SPN+SCCM inference rather than a confirmed site database; omitted (null) otherwise. |
assumptionBasis |
string | Human-readable explanation of the inference; present only when assumed is true. |
The fixed db_owner database role in an MSSQL_Database. One node per SCCM site database. Members are populated from the database users in the database (fix for the same CMBP empty-array scope bug). Model: models/mssql_database_role.py.
- Node id:
db_owner@<UPPER_HOST_SID>:<port>\<db_name>(e.g.db_owner@S-1-5-21-11-22-33-1104:1433\CM_PS1). environmentid: the AD domain SID of the SQL host.- Kinds:
["MSSQL_DatabaseRole"]. name/displayname:db_owner.- Note: Emitted to the MSSQL payload (
mssql_nodes-*.json,source_kind = "MSSQL").
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Every collection phase that contributed to the database this role is templated from — e.g. ["AdminService-SiteDefinition", "RemoteRegistry-MultisiteComponentServers"]. Not a live SQL read: the schema is templated from SCCM topology, and assumed below records whether that topology was confirmed or inferred. |
database |
string | Database name this role belongs to (e.g. CM_PS1). |
isFixedRole |
bool | Always true — db_owner is a SQL Server fixed database role. |
members |
list<string> | DatabaseUser node IDs that are members of this role. |
SCCMSite |
string | Site code of the SCCM site. |
SQLServer |
string | DNS hostname of the SQL Server. |
assumed |
bool | true when this role's database rests on the SPN+SCCM inference; omitted (null) otherwise. |
assumptionBasis |
string | Human-readable explanation of the inference; present only when assumed is true. |
A database user mapped into the SCCM site database. Inferred from SCCM topology. One node per (login, database) pair on the same server — the same machine account that holds the sysadmin SQL login is mapped into the site database as a db_owner database user, following CMBP's inference. Model: models/mssql_database_user.py.
- Node id:
<login_name>@<UPPER_HOST_SID>:<port>\<db_name>(e.g.MAYYHEM\PS1-SMS$@S-1-5-21-11-22-33-1104:1433\CM_PS1). environmentid: the AD domain SID of the SQL host.- Kinds:
["MSSQL_DatabaseUser"]. name/displayname: the database user name (same as the login name).- Note: Emitted to the MSSQL payload (
mssql_nodes-*.json,source_kind = "MSSQL").
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Every collection phase that contributed to the login this database user is mapped from — e.g. ["AdminService-SiteDefinition", "RemoteRegistry-MultisiteComponentServers"]. Not a live SQL read: the schema is templated from SCCM topology, and assumed below records whether that topology was confirmed or inferred. |
database |
string | Database name this user belongs to (e.g. CM_PS1). |
login |
string | Login name this database user is mapped from. |
memberOfRoles |
list<string> | DatabaseRole node IDs this user belongs to (always ["db_owner@<database_id>"]). |
SCCMInfra |
bool | Always true. |
SCCMSite |
string | Site code of the SCCM site. |
SQLServer |
string | DNS hostname of the SQL Server. |
assumed |
bool | true when this database user's login (and its server) rests on the SPN+SCCM inference; omitted (null) otherwise. |
assumptionBasis |
string | Human-readable explanation of the inference; present only when assumed is true. |
Inferred, not enumerated. Same topology-inference caveat as
MSSQL_Loginabove.
A Windows machine-account login on the SCCM site database's SQL Server. Inferred from SCCM topology — not enumerated from SQL. One login is created per (SQL host, sysadmin computer) pair, from two rules. The login name format follows CMBP's convention using the first DNS domain label as the NETBIOS name. Model: models/mssql_login.py.
Same site — the sysadmin computer is a Primary Site Server or SMS Provider for the same site as the SQL host, excluding the SQL host itself.
Parent primary → secondary site database — on a site positively known to be a Secondary, the sysadmin computers are the site servers of its parent primary. Microsoft requires a secondary's database to run on the secondary site server, so the same-site rule can never produce a login there: the site's only Site Server is the SQL host, and the self-exclusion removes it. The prerequisites page names who actually holds sysadmin — the parent primary's computer account, permanently. That grant holds on both install paths: directly when the SQL instance pre-existed, and transitively through BUILTIN\Administrators when setup installed SQL Express, so no discrimination between them is needed.
Two things are deliberately not emitted here. The secondary site server's own machine account gets no login on its own instance: because the database is co-located, that principal authenticates locally as NT AUTHORITY\SYSTEM rather than over the network, and NTLM cannot be reflected back to the same host — it would be a node with no traversable edge. And the broader Express-only fact that every member of the secondary's local Administrators group holds sysadmin is not emitted either; that needs a signal distinguishing the two install paths, tracked separately.
- Node id:
<NETBIOS>\<samAccountName>@<UPPER_HOST_SID>:<port>(e.g.MAYYHEM\PS1-SMS$@S-1-5-21-11-22-33-1104:1433), whereNETBIOS= the first domain label of the sysadmin computer's FQDN (split_part(dnshostname, '.', 2), e.g.PS1SRV.mayyhem.com→MAYYHEM). environmentid: the AD domain SID of the SQL host.- Kinds:
["MSSQL_Login"]. name/displayname: the login name (e.g.MAYYHEM\PS1-SMS$).- Note: Emitted to the MSSQL payload (
mssql_nodes-*.json,source_kind = "MSSQL").
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Every collection phase that contributed to the site database this login maps into — e.g. ["AdminService-SiteDefinition", "RemoteRegistry-MultisiteComponentServers"]. Not a live SQL read: the schema is templated from SCCM topology, and assumed below records whether that topology was confirmed or inferred. |
loginType |
string | Always "Windows" — all inferred logins are Windows machine-account logins. |
memberOfRoles |
list<string> | Server role node IDs this login belongs to (always ["sysadmin@<server_id>"]). |
SCCMInfra |
bool | Always true. |
SCCMSite |
string | Site code of the SCCM site. |
SQLServer |
string | DNS hostname of the SQL Server. |
assumed |
bool | true when this login's server rests on the SPN+SCCM inference; omitted (null) otherwise. |
assumptionBasis |
string | Human-readable explanation of the inference; present only when assumed is true. |
Inferred, not enumerated. These nodes are created from SCCM's architectural grants, not from a live SQL query. They represent the logins SCCM must have granted
sysadminfor the site to function, not a live dump of SQL Server'ssys.server_principals.
A SQL Server instance discovered by the MSSQL EPA scan, RemoteRegistry, or SCCM site processing. Multiple discovery sources are coalesced into one row per host_sid:port — so a server seen by both the EPA scan and the registry produces one node, not two. Non-SCCM SQL servers (not referenced by any site) produce a bare node with SCCMInfra = false and no database/login/role nodes attached. Model: models/mssql_server.py.
- Node id:
<UPPER_HOST_SID>:<port>(e.g.S-1-5-21-11-22-33-1104:1433). environmentid: the AD domain SID of the SQL host computer (S-1-5-21-X-Y-Zstripped from the host SID).- Kinds:
["MSSQL_Server"]. name/displayname: the DNS hostname, or the node id if no hostname is available.- Note: Emitted to the MSSQL payload (
mssql_nodes-*.json,source_kind = "MSSQL").
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Every source that contributed to this node, unioned across its (up to three) discovery arms: MSSQL-ScanForEPA (the EPA scan actually reached the port) or MSSQL-SPN (an MSSQLSvc SPN exists but the port was filtered — decision D2a of the low-privilege assumed-edges plan, docs/superpowers/plans/2026-07-23-low-priv-assumed-edges.md, which resolves the SPN holder when the port itself can't be reached), RemoteRegistry-MSSQL, and — only when this server is also characterized as an SCCM site database — every collection phase that established that (e.g. AdminService-SiteDefinition, RemoteRegistry-MultisiteComponentServers; see assumed below). |
dnsHostName |
string | DNS hostname of the SQL Server host. |
SQLServicePort |
string | TCP port the SQL Server listens on. |
SCCMInfra |
bool | true if this SQL Server hosts an SCCM site database. |
SCCMSite |
string | Site code of the SCCM site whose database this server hosts; null for non-SCCM servers. |
databases |
list<string> | Database names on this server (e.g. CM_PS1). |
forceEncryption |
bool | true if SQL Server has ForceEncryption enabled (from RemoteRegistry). |
extendedProtection |
string | EPA enforcement value (e.g. Off, Allowed, Allowed/Required, Required) from the MSSQL EPA probe or RemoteRegistry; null if the port was filtered and only an MSSQLSvc SPN was found (D2a). |
SQLServiceAccountDomainSID |
string | Full SID of the domain account running the SQL Server service. |
SQLServiceAccountName |
string | Domain account name running the SQL Server service (from SCCM site definitions). |
strictEncryption |
bool | true if TDS 8.0 strict encryption is enforced (from the EPA scan). Port-added — no CMBP key. |
instanceNames |
list<string> | Named SQL instance names from RemoteRegistry. Port-added — no CMBP key. |
assumed |
bool | true when this server was only ever characterized as the SCCM site database (SCCMSite/SCCMInfra = true/databases) through the SPN+SCCM inference (an MSSQLSvc SPN plus SCCM-relatedness), never confirmed by RemoteRegistry/AdminService/WMI; omitted (null) otherwise — including for a bare non-SCCM server, which is never a site-database claim to begin with. See Assumed vs. confirmed graph content. |
assumptionBasis |
string | Human-readable explanation of the inference; present only when assumed is true. |
The fixed sysadmin server role on an SCCM-linked SQL Server. One node per SCCM-linked server; non-SCCM bare servers do not get a role node. Members are populated from the logins on the same server (a fix for a CMBP scope bug where members was always emitted empty). Model: models/mssql_server_role.py.
- Node id:
sysadmin@<UPPER_HOST_SID>:<port>(e.g.sysadmin@S-1-5-21-11-22-33-1104:1433). environmentid: the AD domain SID of the SQL host.- Kinds:
["MSSQL_ServerRole"]. name/displayname:sysadmin.- Note: Emitted to the MSSQL payload (
mssql_nodes-*.json,source_kind = "MSSQL").
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Every collection phase that contributed to the server this role is templated from — e.g. ["AdminService-SiteDefinition", "RemoteRegistry-MultisiteComponentServers"]. Not a live SQL read: the schema is templated from SCCM topology, and assumed below records whether that topology was confirmed or inferred. |
isFixedRole |
bool | Always true — sysadmin is a SQL Server fixed server role. |
members |
list<string> | Login node IDs that are members of this role (e.g. MAYYHEM\PS1-SMS$@S-1-5-21-…:1433). |
SCCMSite |
string | Site code of the SCCM site. |
SQLServer |
string | DNS hostname of the SQL Server. |
assumed |
bool | true when this role's server rests on the SPN+SCCM inference; omitted (null) otherwise. |
assumptionBasis |
string | Human-readable explanation of the inference; present only when assumed is true. |
Currently emitted: 38 edge kinds — counted by the graph-pipeline stage that introduced them (the increments of the preprocess/convert port, plans
2026-06-16-sccm-preproc-convert-stage0.md…2026-07-01-sccm-preproc-convert-stage7.md; see the Reference key): 11 from Stages 1–2, 10 new from Stage 3, 2 new from Stage 4, 11 new from Stage 5, 3 new from Stage 6, and 1 new base-kind edge (GenericAll) from the low-privilege assumed-edges work,docs/superpowers/plans/2026-07-23-low-priv-assumed-edges.md. (SCCM_AssignAllPermissionsgains a new Database→Site configuration in Stage 5 but is not a new kind string.)
Edges are emitted from the graph_edges preproc table by the generic GraphEdge model. Every edge carries these standard properties:
traversable— set from the CMBP traversable allow-list (TRAVERSABLE_EDGE_KINDSin kinds/edges.py, transcribed from CMBPps1:2216-2249). Only traversable edges are followed by BloodHound's attack-path engine.collectionSource— a list of strings identifying which collectors contributed the data behind this edge (e.g.["AdminService-SMS_Admin"],["SCCM_Invoke-PostProcessing"]). Matches thecollectionSourceprovenance tags used by ConfigManBearPig.assumed/assumptionBasis— present only on assumed (templated/inferred) edges,null/omitted on confirmed ones. See Assumed vs. confirmed graph content for the full catalog of which families these appear on and why.
Edges that describe an SCCM-specific attack path carry documentation in their property bag so BloodHound's entity panel explains the edge when you click it. The keys mirror BloodHound's built-in edge help:
| Property | Type | Description |
|---|---|---|
general |
string | What the edge means and why it matters. |
windowsAbuse |
string | How to abuse the edge from a Windows host. |
linuxAbuse |
string | How to abuse the edge from a Linux host. |
opsec |
string | Detection / operational-security considerations. |
references |
list<string> | Source URLs. |
Only sections that apply are emitted; an edge kind with no authored content carries
none of these keys. Kinds BloodHound already documents natively (MemberOf,
AdminTo, HasSession) are intentionally left to BloodHound's own help. The content
lives in src/openhound_sccm/edge_help.py; kinds still awaiting content are listed
there in PENDING_HELP_KINDS.
Example (SCCM_AdminsReplicatedTo, abbreviated):
"properties": {
"traversable": true,
"collectionSource": ["SCCM_Invoke-PostProcessing"],
"general": "SCCM security roles assigned to users are replicated to every other site...",
"windowsAbuse": "There is no specific abuse required to follow this attack path... execute SharpSCCM.exe <command> <subcommand> -sms <sms_provider_ip> -sc <site_code>...",
"linuxAbuse": "python3 sccmhunter.py admin -u <username> -p <password> -ip <sms_provider_ip>...",
"opsec": "An EDR product may detect your attempt to run SharpSCCM...",
"references": ["https://posts.specterops.io/sccm-hierarchy-takeover-41929c61e087", "..."]
}Links each AD principal that holds Full Control over the System Management container to the Container node for it. Wires up ldap_system_management_dacl, which the collector always parsed for its DACL but which fed no edge at all before the low-privilege work — this is "who can control SCCM via AD", the attack surface the container's ACL actually represents.
- Start: the Full-Control principal (any AD SID —
Computer,User, orGroup) - End:
Container(the System Management container) - Traversable: yes — standard BloodHound base kind, in
TRAVERSABLE_EDGE_KINDSalongsideGenericAll's usual SharpHound meaning collectionSource:["LDAP-GenericAllSystemManagement"]- Confirmed, both flag modes — an ACL read directly off AD, not a template; no
assumedstamp.
A principal with Full Control over this container can create/modify the SCCM site objects it holds — see the
MemberOfentry above for how a group holding this permission expands into its full nested membership.
Links a computer to a user whose session it hosts. Confirmed evidence from three independent sources — never possible-gated, present in both --disable-possible-edges modes:
- Current-logged-on user — the current-user SID read from RemoteRegistry.
- Privileged MSSQL service account — the SQL Server service-logon account on an SCCM-linked site database server, resolved from
SMS_SCI_SysResUse(AdminService/WMI). - Low-privilege
MSSQLSvcSPN holder — the AD account anMSSQLSvcservice principal name is registered on (LDAP-readable at low privilege), skipped when that holder is the host computer itself (no distinct session to represent). Distinct from source 2 — when both independently resolve the same real account, the edges collapse into one.
- Start:
Computer - End:
User - Traversable: yes (BloodHound-native edge kind)
collectionSource:RemoteRegistry-CurrentUser(source 1),AdminService-SMS_SCI_SysResUse/WMI-SMS_SCI_SysResUse(source 2), orLDAP-MSSQLSvcSPN(source 3).
Links an AD principal directly to an AD group. Emitted from two independent sources:
- SCCM's
security_group_namefield — a device's or user's recorded direct membership in a group SCCM knows about. - The System Management container's DACL groups — for each AD group that holds Full Control (
GenericAll) on the container, the full nested membership chain (every member→containing-group hop, at every level of nesting), read directly from AD'smemberattribute by the same recursive walk the collector already performs to register scan targets (collectors/ldap.py::_expand_group_targets). Confirmed, both flag modes, noassumedstamp.
- Start:
ComputerorUser(source 1); any principal in the nested chain (source 2) - End:
Group - Traversable: yes (BloodHound-native edge kind)
collectionSource(source 2 only):["LDAP-GenericAllSystemManagement"].
Assumption/Limitation (source 1 only): SCCM's
security_group_namecarries only direct memberships — a device or user belongs to the named group. Group-to-group nesting is not captured there. To see full nested-group attack paths for groups SCCM doesn't otherwise reference, merge this dataset with a SharpHound collection. Because Group nodes are keyed by AD SID and use the AD domain SID asenvironmentid, SharpHound'sMemberOfedges attach on the same SID keys — and BloodHound de-dupes any edge SharpHound also supplies for a DACL group's nested chain (source 2).
Represents the SCCM site replication topology — which sites replicate administrative data to which other sites. Built from the site hierarchy computed by preprocess (the graph_edges table). Edge model: models/graph_edge.py (GraphEdge).
- Start:
SCCM_Site - End:
SCCM_Site - Traversable: yes
- Direction:
- CAS ↔ Primary Site: bidirectional (two edges, one in each direction)
- Primary Site → Secondary Site: one-way
Links an SCCM_AdminUser to every non-secondary SCCM_Site in the hierarchy when they hold the Full Administrator role (SMS0001R) and are assigned to both SMS00001 (All Systems) and SMS00004 (All Users and User Groups). Indicates unrestricted, hierarchy-wide access (CMBP ps1:1730-1837).
- Start:
SCCM_AdminUser - End:
SCCM_Site - Traversable: yes
- Abuse note: Confirms the admin has no scope restriction — they can manage every device and user in every site.
Links an SCCM_AdminUser to SCCM_ClientDevice nodes reachable through their assigned device collections, when they hold the built-in Application Administrator role (SMS0009R).
- Start:
SCCM_AdminUser - End:
SCCM_ClientDevice - Traversable: yes
- Abuse note: Can create, modify, and deploy applications to managed clients — direct path to code execution on scoped devices.
Links an SCCM_AdminUser to SCCM_ClientDevice nodes reachable through their assigned device collections, when they hold the built-in Application Author role (SMS0008R).
- Start:
SCCM_AdminUser - End:
SCCM_ClientDevice - Traversable: no
- Abuse note: Can create and modify applications; combined with a deploying role can achieve code execution.
Links an SMS Provider computer to every non-secondary SCCM_Site in the hierarchy. A host running the SMS Provider role can write SCCM administrative data and effectively control any object the hierarchy manages (CMBP ps1:1932-1940).
- Start:
Computer(SMS Provider host) - End:
SCCM_Site - Traversable: yes
collectionSource:["SCCM_Invoke-PostProcessing", "Assumed-AssignAllPermissions"]assumed: alwaystrueon this configuration — the SMS Provider role is templated as implying site control, not read from an actual RBAC grant. Not gated by--disable-possible-edges(see Assumed vs. confirmed graph content).- Abuse note: Compromise of an SMS Provider host (e.g. via relay to the AdminService REST API) gives an attacker administrative control equivalent to a Full Administrator over the whole hierarchy.
Second configuration (Database → Site), added in graph-pipeline Stage 5: see the note after
MSSQL_GetAdminTGSbelow. That configuration currently does not carry theassumed/assumptionBasisstamp even when built off anSPN+SCCM-inferred database — a known gap, see Assumed vs. confirmed graph content.
Links the Authenticated Users group of a site server's domain to the SCCM_Site that the site server belongs to, when NTLM coercion can relay the site server's credentials to an SMS Provider's AdminService endpoint. An attacker authenticating as any domain user can trigger NTLM authentication from the site server and relay it to the SMS Provider, gaining AdminService access and therefore SCCM administrative control over the site.
- Start:
Group(Authenticated Users for the site server's domain, e.g.MAYYHEM.COM-S-1-5-11) - End:
SCCM_Site - Traversable: yes
collectionSource:["Post-processing", "Assumed-CoerceRelay"]assumed: alwaystrue— this templates relay feasibility from role topology rather than reading it from a live probe of the relay itself.- NOT gated by
--disable-possible-edges. A null/uncollectedrestrictReceivingNtlmTrafficon the SMS Provider is treated as vulnerable in both modes: an unset value is the Windows default (0 = allow all inbound NTLM), so this is a measured fact about the default rather than a guess. (An earlier revision of this README claimed the flag tightened this to require an explicitOff— that was wrong; the code never gated it, and ConfigManBearPig itself emits this family under its own-DisablePossibleEdgesswitch.) - Version gate: suppressed for a site confirmed to be SCCM 2509+ (build ≥ 9141) — that AdminService version rejects NTLM. An unknown/unparseable site
versionkeeps the edge (fail-open). See Collection Overview for how the version is fingerprinted. - Note: Lands in the AD payload (
ad_edges-*.json) because the start node is an ADGroup.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Always ["Post-processing", "Assumed-CoerceRelay"]. |
assumed |
bool | Always true. |
assumptionBasis |
string | "relay feasibility assumed from role topology + NTLM/SMB-signing state". |
coercionVictimAndRelayTargetPairs |
list<string> | One entry per coercion victim / relay target pair in the form "Coerce <victim_fqdn>, relay to <provider_fqdn>". Shows which site server is coerced and which SMS Provider receives the relayed credential. |
Links the Authenticated Users group of a site server's domain to a site system computer whose SMB signing is not required, when NTLM coercion can relay the site server's credentials to that computer over SMB. An attacker can coerce the site server and relay its NTLM credential over SMB to a peer site system that does not enforce SMB signing, gaining authenticated SMB access (and therefore potential code execution) on that host.
- Start:
Group(Authenticated Users for the site server's domain) - End:
Computer(site system with SMB signing not required) - Traversable: yes
collectionSource: subset of["SMB-Negotiate", "RemoteRegistry-SMBSigningCheck"]plus"Assumed-CoerceRelay"— whichever SMB-signing probes observed the target's signing setting, plus the assumed-family tag.assumed: alwaystrue— same "templated from role topology" reasoning asSCCM_CoerceAndRelayToAdminServiceabove.- NOT gated by
--disable-possible-edges. The target's NTLM restriction uses the same "unset = Windows default = vulnerable" rule in both modes; SMB signing itself was never assumed either way — it must always be confirmed not required. (An earlier revision of this README claimed the flag tightened the NTLM check here too — that was wrong; the code never gated it.) - Note: Lands in the AD payload because both endpoints are AD nodes (
GroupandComputer). - Bug fix note: ConfigManBearPig's traversable allow-list (
ps1:2221) named this kindCoerceAndRelayNTLMtoSMB, but the function that emits it (ps1:6775) usedCoerceAndRelayToSMB— the mismatch left the edge non-traversable in CMBP. This port emitsSCCM_CoerceAndRelayToSMBand marks it traversable inTRAVERSABLE_EDGE_KINDS.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Sources that observed SMB signing on the target (e.g. ["SMB-Negotiate"], ["RemoteRegistry-SMBSigningCheck"], or both), plus "Assumed-CoerceRelay". |
assumed |
bool | Always true. |
assumptionBasis |
string | "relay feasibility assumed from role topology + NTLM/SMB-signing state". |
coercionVictimHostnames |
list<string> | The FQDN(s) of the site server(s) that would be coerced. |
Links an SCCM_AdminUser to SCCM_ClientDevice nodes reachable through their assigned device collections, when they hold the built-in Compliance Settings Manager role (SMS0006R).
- Start:
SCCM_AdminUser - End:
SCCM_ClientDevice - Traversable: no
- Abuse note: Can author and deploy compliance baselines and configuration items; may enable script execution on clients.
Links a non-secondary SCCM site to every collection, security role, and admin user it contains. Built during post-processing from the site hierarchy and the node tables (CMBP ps1:1659-1690).
- Start:
SCCM_Site(non-secondary — CAS or Primary) - End:
SCCM_Collection,SCCM_SecurityRole, orSCCM_AdminUser - Traversable: yes
- Note: Secondary sites are excluded because administrative data does not originate from them.
Links an SCCM_AdminUser to every SCCM_ClientDevice in any device collection they are assigned to, when they hold the built-in Full Administrator role (SMS0001R). Grants unrestricted access to all SCCM functionality and all managed clients.
- Start:
SCCM_AdminUser - End:
SCCM_ClientDevice - Traversable: yes
- Abuse note: A Full Administrator can deploy scripts, applications, and OS images to any client device they are scoped to — full code execution on target.
Links a site to each of its confirmed (and possible, if enabled) SCCM-managed clients. Inferred "possible" clients (see Limitations) are attached to the first Primary site — never the Central Administration Site, which cannot own clients — matching ConfigManBearPig.
- Start:
SCCM_Site - End:
SCCM_ClientDevice - Traversable: yes
Links a collection to each member that resolves to a client device, user, or group. A member SCCM only discovered — e.g. a computer that never installed the client, so it has no SCCM_ClientDevice node — is not linked (matching ConfigManBearPig, which logs "No node found for member").
- Start:
SCCM_Collection - End:
SCCM_ClientDevice(device members, by ResourceID → SMSID) /User/Group(user & group members, by ResourceID → SID) - Traversable: no
Link an SCCM_ClientDevice to a user based on SCCM's recorded affinity or logon data.
| Kind | Start | End | Traversable | Source |
|---|---|---|---|---|
SCCM_HasPrimaryUser |
SCCM_ClientDevice |
User |
yes | SCCM user-device affinity (primaryUser) |
SCCM_HasCurrentUser |
SCCM_ClientDevice |
User |
yes | Currently logged-on user (currentLogonUser) |
SCCM_HasADLastLogonUser |
SCCM_ClientDevice |
User |
yes | Last AD-authenticated user (ADLastLogonUser) |
Links an SCCM site to any AD user or group stored as a reserved/NAA-style credential in SMS_SCI_Reserved.
- Start:
SCCM_Site - End:
UserorGroup - Traversable: no
Deferred:
SCCM_HasNetworkAccessAccount(NAA secret decryption) requires the--enable-bad-opsecflag and the NAA-secret collector, neither of which is implemented yet.
Links an SCCM_AdminUser to each scope it is assigned — either a collection (defining what they manage) or a security role (defining what they can do).
- Start:
SCCM_AdminUser - End:
SCCM_CollectionorSCCM_SecurityRole - Traversable: no
Links an AD user or group to its corresponding SCCM_AdminUser object — the SCCM RBAC record that grants them administrative access.
- Start:
UserorGroup - End:
SCCM_AdminUser - Traversable: yes
SCCMInfra: alwaystrueon this edge — flags the start-node principal (the admin'sUser/Group) as SCCM infrastructure.SCCM_IsMappedTois the only edge kind that carries this property; every other edge kind omits it entirely (see Entity-panel help properties).
Built from two independent rules.
Within a site — links each site server (Computer hosting SMS Site Server@<site>) to every other site system in the same non-secondary site. A site server requires local-administrator rights on its peer site systems (CMBP ps1:1882-1909). Self-edges and secondary-site computers are excluded.
Parent primary → child secondary — links the site servers of a parent primary to the site server of its child secondary, across sites. Microsoft states this as a setup prerequisite: "Add the computer account of the parent primary site to the Administrators group on the secondary site server" (prerequisites for installing sites). It matters beyond local admin — on a secondary whose SQL Express instance SCCM installed, BUILTIN\Administrators holds sysadmin, so this membership is also the parent's route to the secondary's database.
The parent is matched by role, so a site server running in passive mode is included: both the active and passive nodes carry SMS Site Server@<parent>, and both genuinely appear in the secondary's local Administrators group. The rule fires only where the child's siteType is positively known to be Secondary — an unknown type is excluded rather than assumed, so a low-privilege run (which cannot establish that a site is a secondary; see Limitations) produces no cross-site edges.
- Start:
Computer(site server) - End:
Computer(peer site system in the same non-secondary site, or the site server of a child secondary) - Traversable: yes
collectionSource:["SCCM_Invoke-PostProcessing", "Assumed-LocalAdminRequired"]assumed: alwaystruefor both rules, and not gated by--disable-possible-edges. TheassumptionBasisdiffers so the entity panel shows which rule fired: the within-a-site rule templates co-location as mutual local-admin rights, while the parent→secondary rule cites the documented setup prerequisite. Neither reads an actual local-group membership list.
Links an SCCM_AdminUser to SCCM_ClientDevice nodes reachable through their assigned device collections, when they hold the built-in Operations Administrator role (SMS000ER).
- Start:
SCCM_AdminUser - End:
SCCM_ClientDevice - Traversable: no
- Abuse note: Broad operational access including software deployments and remote tools; can achieve code execution on managed clients.
Links an SCCM_AdminUser to SCCM_ClientDevice nodes reachable through their assigned device collections, when they hold the built-in OSD (Operating System Deployment) Manager role (SMS000AR).
- Start:
SCCM_AdminUser - End:
SCCM_ClientDevice - Traversable: no
- Abuse note: Can author task sequences and boot images; a malicious task sequence delivers full OS-level code execution during deployment.
Links a Computer AD node to its corresponding SCCM_ClientDevice record for the same physical host, matched by the client device's ADDomainSID equalling the computer's AD SID. Both directions are emitted (one edge Computer → SCCM_ClientDevice, one SCCM_ClientDevice → Computer). Only deduped real-client or inferred-client survivors (after _dedup_client_device) appear here — CmRcService-only twins that were merged into a real client do not produce orphan edges (CMBP ps1:2314-2320).
- Start:
ComputerorSCCM_ClientDevice - End:
SCCM_ClientDeviceorComputer(bidirectional — both rows present) - Traversable: yes
- Source:
SCCM_Invoke-PostProcessing
Links an SCCM_AdminUser to SCCM_ClientDevice nodes reachable through their assigned device collections, when they hold the built-in Security Administrator role (SMS000FR).
- Start:
SCCM_AdminUser - End:
SCCM_ClientDevice - Traversable: no
- Abuse note: Can modify other admins' role assignments and collection scopes — an indirect path to escalating privileges within SCCM.
Links the Authenticated Users group of a sysadmin computer's domain to an MSSQL_Login on the site database server, when NTLM coercion can relay the sysadmin computer's credentials to the SQL Server. An attacker can coerce the sysadmin computer (a Primary Site Server or SMS Provider) and relay its NTLM credential to the SQL Server, authenticating as the corresponding Windows login and gaining sysadmin access to the site database.
- Start:
Group(Authenticated Users for the coercion victim's domain) - End:
MSSQL_Login - Traversable: yes
collectionSource: subset of["MSSQL-ScanForEPA", "RemoteRegistry-MSSQL"]— whichever sources determined the SQL Server's Extended Protection setting.- Two gates that behave differently under
--disable-possible-edges:- NTLM restriction on the coerced sysadmin computer — not gated by the flag at all; a null value is treated as vulnerable in both modes (the same "unset = Windows default = vulnerable" reasoning as the other two relay kinds above).
- Extended Protection on the SQL Server — is gated: by default a null/uncollected EPA is treated as vulnerable; with
--disable-possible-edges, EPA must be explicitlyOff.
assumed/assumptionBasisare stamped per row, not for the whole family:trueonly when EPA was never measured (the host was unreachable on its SQL port) and default mode is assuming itOff; a row where EPA was actually measuredOffis evidence, not an assumption, and carries no stamp — even in default mode.- Note: Lands in the AD payload because the start node is an AD
Group.
| Property | Type | Description |
|---|---|---|
collectionSource |
list<string> | Sources that determined the SQL Server's EPA setting (e.g. ["MSSQL-ScanForEPA"], ["RemoteRegistry-MSSQL"]). |
assumed |
bool | true only when Extended Protection was never measured and default mode is assuming it Off; omitted (null) when EPA was actually measured Off. |
assumptionBasis |
string | "Extended Protection never measured (host unreachable on its SQL port); default mode assumes EPA is Off", present only when assumed is true. |
coercionVictimAndRelayTargetPairs |
list<string> | One entry per victim / target pair: "Coerce <victim_fqdn>, relay to <sql_host>:<port>". Shows which sysadmin computer is coerced and which SQL Server endpoint receives the relay. |
Links a container node to the object it contains. Emitted in five distinct start→end configurations (all sharing the one edge kind, following CMBP):
| Start | End | Meaning |
|---|---|---|
MSSQL_Server |
MSSQL_ServerRole |
Server contains its sysadmin role |
MSSQL_Server |
MSSQL_Database |
Server contains the site database |
MSSQL_Server |
MSSQL_Login |
Server contains the Windows login |
MSSQL_Database |
MSSQL_DatabaseRole |
Database contains its db_owner role |
MSSQL_Database |
MSSQL_DatabaseUser |
Database contains the database user |
- Traversable: yes
- Note: Lands in the MSSQL payload (
mssql_edges-*.json) — both endpoints are MSSQL nodes.
Links the db_owner database role to the database it controls. Holding db_owner grants full control over the database, including the ability to execute code via CLR assemblies when TRUSTWORTHY is on.
- Start:
MSSQL_DatabaseRole(db_owner) - End:
MSSQL_Database - Traversable: yes
- Note: Lands in the MSSQL payload (
mssql_edges-*.json) — both endpoints are MSSQL nodes.
Links the sysadmin server role to the SQL Server it controls. Holding sysadmin grants full control over the SQL instance.
- Start:
MSSQL_ServerRole(sysadmin) - End:
MSSQL_Server - Traversable: yes
- Note: Lands in the MSSQL payload (
mssql_edges-*.json) — both endpoints are MSSQL nodes.
Links an SQL Server instance to the AD computer it runs on. Represents the inverse of MSSQL_HostFor — code executing inside SQL (e.g. via xp_cmdshell) runs on the host OS.
- Start:
MSSQL_Server - End:
Computer(the SQL host) - Traversable: yes
- Note: Lands in the AD payload because the end node is an AD
Computer.
Links the SQL service account to the SQL Server it runs on. Represents the ability to forge a Kerberos service ticket for the SQL SPN (using the service account's key) and authenticate to the SQL instance with sysadmin-equivalent access. Built from two sources, the same idiom as MSSQL_GetTGS/MSSQL_ServiceAccountFor above:
-
The privileged
SMS_SCI_SysResUsefield, only when the service account is not the SQL host's own computer SID. -
The low-privilege
MSSQLSvcSPN holder — because everyMSSQL_Loginrow is, by construction, a site-server/SMS-Provider machine account SCCM's own default schema grantssysadmin, "a login exists for this server" already means "a domain principal is sysadmin", so this arm fires for every server with at least one login, with no separate "not the host itself" check.assumed/assumptionBasis/collectionSourceon this arm are copied straight from theMSSQL_Loginrow it targets (seeMSSQL_Login). -
Start: AD SID of the SQL service account (
UserorComputer) -
End:
MSSQL_Server -
Traversable: yes
-
Emitted only when the service account resolves to an existing AD node (privileged arm additionally requires it differ from the SQL host's own SID; the low-priv SPN-holder arm's identity is already a live AD lookup result, so it always resolves).
-
Note: Lands in the AD payload because the start node is an AD principal.
SCCM_AssignAllPermissions(Database → Site variant): An additional set ofSCCM_AssignAllPermissionsedges is emitted from eachMSSQL_Databaseto every non-secondarySCCM_Sitein the hierarchy — beyond the existing Computer (SMS Provider) → Site edges described above. A database that hosts an SCCM site (withTRUSTWORTHYon anddb_ownermembership) can execute CLR code that writes SCCM administrative data, giving the same effective control as an SMS Provider. These edges are tagged["SCCM_Add-MSSQLServerNodesAndEdges"]and are traversable. They land in the SCCM payload — the start node (MSSQL_Database) is an MSSQL node but the end node (SCCM_Site) is not, so the edge is not both-MSSQL and falls to the SCCM payload by the routing rule's complement (see Limitations). Unlike the Computer variant above, this configuration does not currently carry theassumed/assumptionBasisstamp even when the database it's built from rests on theSPN+SCCMinference — see the known gap noted under Assumed vs. confirmed graph content.
Links the SQL service account (an AD principal) to each MSSQL_Login on the server it runs on. Any principal that can request a Kerberos service ticket for the SQL service SPN (because it knows the service account's credentials) can authenticate as any login on that SQL instance. Built from two sources: the privileged SMS_SCI_SysResUse service-account field (AdminService/WMI), and — at low privilege — the account that actually holds the MSSQLSvc SPN in AD (LDAP-readable, no local admin needed). When both independently resolve the same account on the same server, the resulting duplicate edge collapses into one.
- Start: AD SID of the SQL service account (
UserorComputer) - End:
MSSQL_Login - Traversable: yes
assumed/assumptionBasis/collectionSource: copied straight from theMSSQL_Loginrow this edge targets — since every login exists precisely because a sysadmin computer maps into it, "a login exists" already means "a domain principal is sysadmin", so the edge is exactly as confirmed/assumed as that login (seeMSSQL_Login).- Emitted only when the service account SID resolves to an existing AD node (privileged arm) — the low-priv SPN-holder arm's identity is already a live AD lookup result, so it always resolves.
- Note: Lands in the AD payload because the start node is an AD principal.
Links the sysadmin computer (Primary Site Server or SMS Provider) to its inferred SQL login on the server. The computer's machine account holds the sysadmin login — the link represents that grant.
- Start:
Computer(sysadmin computer — Primary Site Server or SMS Provider) - End:
MSSQL_Login - Traversable: yes
- Note: Lands in the AD payload because the start node is an AD
Computer.
Links an AD computer to the SQL Server instance running on it. Compromise of the host gives control of the SQL instance.
- Start:
Computer(the SQL host) - End:
MSSQL_Server - Traversable: yes
- Note: This edge lands in the AD payload (
ad_edges-*.json) because the start node is an ADComputer.
Links an SQL login to its corresponding database user in the site database. A Windows login is mapped to a database user of the same name in each database it has access to.
- Start:
MSSQL_Login - End:
MSSQL_DatabaseUser - Traversable: yes
- Note: Lands in the MSSQL payload (
mssql_edges-*.json) — both endpoints are MSSQL nodes.
Links a login or database user to the role it belongs to. Emitted in two configurations:
| Start | End | Meaning |
|---|---|---|
MSSQL_Login |
MSSQL_ServerRole |
Login is a member of the sysadmin server role |
MSSQL_DatabaseUser |
MSSQL_DatabaseRole |
Database user is a member of the db_owner role |
- Traversable: yes
- Note: Lands in the MSSQL payload (
mssql_edges-*.json) — both endpoints are MSSQL nodes.
Links the SQL service account to the SQL Server it runs on, when the service account is not the SQL host itself (i.e. a dedicated service account, not a machine account running on the same host). Represents the trust relationship — the service account identity controls the SQL instance. Built from the same two sources as MSSQL_GetTGS above: the privileged SMS_SCI_SysResUse field, and the low-privilege MSSQLSvc SPN holder (collectionSource: ["LDAP-MSSQLSvcSPN"] for the low-priv arm, with no "not the host itself" guard — the SPN holder is a live AD lookup, not a name pending resolution).
- Start: AD SID of the SQL service account (
UserorComputer) - End:
MSSQL_Server - Traversable: no — excluded from the BloodHound attack-path engine per CMBP's allow-list (
ps1:2233, commented out). - Emitted only when the service account is not the SQL host's own computer SID (privileged arm), and it resolves to an existing AD node.
- Note: Lands in the AD payload because the start node is an AD principal.
The following traversal shows how control of the PS1-DB SQL Server in the mayyhem.com lab leads to the PS1 SCCM site via the SCCM site database. Query after ingesting the collector output:
MATCH p = (c:Computer)-[:MSSQL_HostFor]->(srv:MSSQL_Server)
-[:MSSQL_Contains]->(db:MSSQL_Database)
-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
WHERE site.name = "PS1"
RETURN p LIMIT 10Step-by-step:
Computer(SQL host, e.g.PS1-DB$) →MSSQL_ServerviaMSSQL_HostFor.MSSQL_Server→MSSQL_Database(CM_PS1) viaMSSQL_Contains.MSSQL_Database→SCCM_Site(PS1) viaSCCM_AssignAllPermissions— the database hasTRUSTWORTHYon anddb_owneraccess, enabling CLR-based admin writes.
To find all SQL service accounts that can reach a SQL Server hosting an SCCM database:
MATCH p = (:User)-[:MSSQL_GetAdminTGS]->(srv:MSSQL_Server {SCCMInfra: true})
RETURN p LIMIT 25The following traversal shows how a Full Administrator in the mayyhem.com lab reaches a managed client device. The path uses only traversable edges and can be queried directly in BloodHound after ingesting the collector output.
MATCH p = (u:User {name: "MAYYHEM\\SCCMADMIN"})-[:SCCM_IsMappedTo]->
(a:SCCM_AdminUser)-[:SCCM_FullAdministrator]->
(d:SCCM_ClientDevice)
RETURN p LIMIT 25
Step-by-step:
MAYYHEM\SCCMADMIN(aUsernode, keyed by AD SID) is linked to its SCCM admin record viaSCCM_IsMappedTo.- The
SCCM_AdminUsernode carriesroleIDs = ["SMS0001R"](Full Administrator) andcollectionIdslisting the device collections in scope (e.g.SMS00001— All Systems). SCCM_FullAdministratoredges are drawn to everySCCM_ClientDevicethat belongs to any of those device collections, as built by the_edge_rbac_role_grantstransform.- Each
SCCM_ClientDevicenode carriescollectionIds,collectionNames, and the resolvedprimaryUserSID/currentLogonUserSID— useful for identifying which user account to target on the compromised host.
To see the scope of an admin's reach without filtering by user:
MATCH p = (:SCCM_AdminUser)-[:SCCM_FullAdministrator]->(d:SCCM_ClientDevice)
RETURN count(d) AS devices_at_risk
ConfigManBearPig/
├── pyproject.toml # Deps, Python version, entry point, dev tools
├── README.md # This file
├── ARCHITECTURE.md # How and why this collector diverges from a stock OpenHound one
├── cypher_queries/ # Saved BloodHound Cypher queries for the SCCM graph
├── dev/ # Debug harnesses and pipeline tour drivers (see below)
├── powershell_deprecated/ # The archived ConfigManBearPig.ps1 predecessor
├── tests/ # The test suite (see Testing Changes)
└── src/openhound_sccm/
├── extension.yaml # Extension metadata; ships inside the package because it's read at runtime
├── main.py # CLI: collect/preprocess/convert registration, logging, two-stage orchestration
├── source.py # DLT source, discovery resources, per-host emit resources
├── context.py # SourceContext: targets, allow-list, caches, --collection-methods gating
├── per_host_phases.py # The ordered per-host phases and the tables each writes
├── graph.py # SCCMNode, SCCMEdgeProperties, and one *Properties dataclass per node kind
├── convert_pipeline.py # Reads the DuckDB lookup database and emits the OpenGraph nodes + edges
├── opengraph_untagged.py # Destination for the untagged AD payload (ad_nodes-* / ad_edges-*)
├── edge_help.py # Entity-panel help text (description, abuse, opsec, references) per edge kind
├── cve_table.py # SCCM version → CVE lookup
├── log_context.py # [target][phase] log tagging, VERBOSE level, node/edge trace helpers
├── transforms.py # DuckDB SQL transforms run during preprocess
├── lookup.py # Cached LookupManager queries used during convert
├── schema_SCCM.json # Hand-maintained BloodHound schema for the SCCM_* kinds
├── schema_MSSQL.json # Hand-maintained BloodHound schema for the MSSQL_* kinds
├── kinds/ # Node + edge kind string constants: nodes.py · edges.py
├── models/ # @app.asset graph models: the AD, SCCM_ and MSSQL_ node models plus GraphEdge, StubNode, RawTable, TargetEntry
├── collectors/ # ldap.py · dns.py · local.py · registry.py · mssql.py · privileged.py · http.py · smb.py · sms_rows.py · stubs.py
├── clients/ # ad.py (LDAP auth) · mssql_epa.py (EPA probe) · http.py/http_auth.py (Negotiate) · wmi.py · smb_sso.py (SMB SSPI) · smb.py (signing + shares)
├── integration/ # Bundled known-good fixtures for --run-integration-tests
└── phased_pipeline/ # Reusable engine: work_queue.py · streams.py · engine.py
- Two-stage orchestration. main.py's
collect_sccmruns discovery resources once (collection Stage 1), seeds a work queue, then drains it through a worker pool of per-host phases (collection Stage 2), streaming each table to disk as it's produced. Both come from the per-host collection framework plan,docs/superpowers/plans/2026-06-03-per-host-collection-framework.md— and are a different numbering from the graph-pipeline stages; see the Reference key. - The phased pipeline (phased_pipeline/) is service-agnostic: a bounded-stream model (
streams.py), a recursive work queue (work_queue.py), and an engine that runs phases per target with ashould_rungate (engine.py). - Authentication lives in clients/ad.py: it auto-detects LDAP transport/signing (LDAPS → StartTLS → LDAP with sign-and-seal), supports explicit creds, NTLM, and current-user Kerberos/SSPI, and is careful not to increment
badPwdCounton non-credential failures. - EPA detection (clients/mssql_epa.py) infers Extended Protection enforcement by sending deliberately malformed NTLM channel/service bindings and observing how SQL Server reacts.
- Convert enrichment is driven by lookup.py (DuckDB-backed, cached) reading tables built by transforms.py.
Much of the hardest, most security-sensitive machinery this collector relies on does not live in this
repository. openhound-collector-common is a
separate published package, shared with the sibling MSSQL collector, declared as a capped range in
pyproject.toml:
"openhound-collector-common>=0.1.3,<0.2.0",It resolves from PyPI like any other dependency — there is deliberately no path redirect, and one must
never reach a commit (it would break every environment without a sibling checkout, starting with CI). The
cap is deliberate too: a 0.x library makes no API-stability promise, so an uncapped floor would let a
breaking 0.2 land silently in someone's install. To develop against a live library checkout, see
If you are also editing the shared library.
| Shared module | What this collector gets from it | The local adapter |
|---|---|---|
clients.auth · clients.ad · clients.wmi · clients.mssql |
The Windows authentication ladders and token minters — Kerberos, NTLM, SSPI, pass-the-hash, pass-the-ticket — plus the lockout-safe LDAP/AD client and the TDS Extended Protection probe | clients/ad.py's ADClient(AdClient) adds the SCCM attribute maps; clients/wmi.py and clients/mssql_epa.py wrap the shared backends |
dlt.source_bridge |
StreamBridge and the bounded-queue primitives that let a pushing worker pool feed dlt's pulling extractor |
phased_pipeline/streams.py re-exports them; source.py plants the bridge per run |
logging.log_context |
[target][phase] log tagging, the VERBOSE level, and the ordered-log machinery behind the two on-disk logs |
log_context.py re-exports it and binds the collector-specific helpers |
discovery.dns |
make_resolver, behind domain-controller and management-point SRV lookups |
context.py and collectors/dns.py keep the SCCM-specific query shapes |
proxy |
The process-wide socket interception behind -x/--proxy |
main.py parses and validates the flag, then installs the shared patch |
orchestration |
run_end_to_end / derive_stage_paths, which chain preprocess + convert behind --run-all |
main.py maps --progress and delegates |
dlt.duckdb_safe |
safe_execute / ensure_columns / arr_sql — the guards that stop preprocess SQL from breaking when dlt omits a column it never saw data for |
Used directly throughout transforms.py |
integration_testing |
The assert-and-diff engine behind --run-integration-tests and --compare-to-zip |
integration/ supplies the mayyhem.com fixtures |
graph.graph_edge |
The GraphEdge base model every edge is emitted through |
models/graph_edge.py subclasses it |
Why it exists, and which way the code flowed. This collector and the MSSQL one both have to authenticate over the same Windows protocols, bridge the same push/pull mismatch, and tag logs the same way. Two copies of code that subtle drift — a lockout-safety fix or a channel-binding correction lands in one and rots in the other. The subsystems above were proven here first and then promoted up into the library as a superset, rather than rewritten down to a lowest common denominator. ARCHITECTURE.md → "Where this code lives" has the full per-subsystem breakdown.
Treat it like framework code. A change there affects both collectors and releases on its own tag, so editing it is never part of a change here — if a task appears to need one, stop and say so.
These standalone scripts live in dev/ and drive individual subsystems against real infrastructure. They are developer tools, not part of the CLI. Each reads its credentials from the environment rather than the source — see Testing Changes → Validate against a real hierarchy for how to supply those and invoke them.
debug_epa_matrix.py— flips the SQL Server EPA-related registry settings through all 12 combinations, restarts the service, and verifies the EPA detector reports the right enforcement for each. Modifies a live lab SQL Server — see the in-script warning.debug_per_host.py— exercises the per-host pipeline (ordering, concurrency, recursion, termination) with stub phases. SetCOLLECTION_METHODSto run only specific collectors (mirrors the-m/--collection-methodsflag).debug_smb_auth.py— runsconnect_smb/list_shares/check_smb_signingagainst a live host once per authentication method, then feeds the real share list through the SMB collector's_classify_shares.debug_wmi_auth.py— the WMI sibling of the above: builds aWmiClientper authentication method against the lab SMS Provider, runsidentify()plus a few WQL queries, and confirms the impacket/pywin32 row normalization (including the embeddedPropsarray) holds against real data.spike_smb_sso.py— validates the SMB SSPI Negotiate session-setup path in isolation.tour_driver_stage0.py…tour_driver_stage3.py— debugger walkthroughs ofpreprocess+convert. Set the breakpoints each file lists, then debug that file to step through the DuckDB transforms and the graph emit. The per-host collection side has its own written tour in docs/per-host-collection-framework-tour.md.
This extension follows the rules in AGENTS.md and the .agents/ directory — the .agents/standards/openhound.md standards and the openhound skill's task references (plan-collector, graph-schema, register-extension, source-collection, add-asset, preproc-lookup, validate-extension).
You need a system Python 3.13 or 3.14 on PATH, plus uv.
pyproject.toml sets python-preference = "only-system", so uv will not download an interpreter
for you — if none is present it fails with "No interpreter found for Python >=3.13" rather than
fetching one. That is deliberate: uv's bundled CPython ships a libcrypto without the
OPENSSL_Applink shim, and on Windows any TLS handshake — LDAPS, AdminService, MSSQL TDS — aborts
the process.
git clone git@github.com:SpecterOps/ConfigManBearPig.git
cd ConfigManBearPig
git config merge.ours.driver true # once per clone -- see below
uv sync --group devThat is the whole setup. The shared library resolves from PyPI, and no --prerelease=allow is
needed here even though the collector requires ldap3>=2.10.2rc4: a project is allowed its own
declared pre-release. That flag is only for uv tool install / uv run --with, which resolve
outside a project context.
Every command below is uv run …, which uses this environment.
Why
git config merge.ours.driver true?.gitattributesmarks the generated ticket index.tickets/_TICKETS-BY-STATUS.mdasmerge=oursso git never tries to merge it — two branches that each closed a different ticket both rewrite it, and the resolution is always "regenerate", never "merge".oursis not one of git's built-in merge drivers (those aretext,binaryandunion), so it must be defined per clone — and.git/configcannot be committed, which is why this is a manual step rather than something the repository can do for you.
Most collector work needs nothing here: uv sync --group dev already installed the published
openhound-collector-common, and that is
the simpler and safer default. Read on only if you need your edits to that package to take effect.
Clone it as a sibling of this repository — the path below is resolved relative to
pyproject.toml, so the layout is load-bearing:
~\Desktop\ConfigManBearPig\ this repo
~\Desktop\openhound-collector-common\ the shared library
cd ..
git clone git@github.com:Mayyhem/openhound-collector-common.gitThen add this to ConfigManBearPig\pyproject.toml, immediately above [tool.uv]:
[tool.uv.sources]
openhound-collector-common = { path = "../openhound-collector-common", editable = true }Do not commit it. A committed path redirect breaks every environment without that sibling checkout — starting with CI, whose runner checks out this repository alone and fails with "Distribution not found at file:///home/runner/work/ConfigManBearPig/openhound-collector-common". Tell git to ignore your local edit:
git update-index --skip-worktree pyproject.toml
uv sync --group devLibrary edits are now live in this environment, and they survive later uv sync runs. To change
pyproject.toml for real later: git update-index --no-skip-worktree pyproject.toml, edit, commit,
then re-apply the skip.
Two alternatives that look right and are not — both tested:
| Approach | What actually happens |
|---|---|
uv pip install -e ../openhound-collector-common |
Installs a copy, not a live editable. Your library edits are invisible, and the next uv sync silently replaces it with the PyPI version |
[sources] in a gitignored uv.toml |
uv rejects it outright — "sources is only applicable in the context of a project" — and a uv.toml would also shadow [tool.uv] python-preference |
These three are exactly what ci.yml runs, so a green local run means a
green pull request:
uv run ruff check src tests
uv run mypy src\openhound_sccm
uv run pytest tests\extension_metadata_test.py tests\integration_wiring_test.py `
tests\convert_pipeline_test.py tests\integration_fixtures_test.py `
tests\integration_privilege_test.py tests\integration_cli_flags_test.py `
tests\collect_exit_code_test.py tests\ab_matrix_test.py `
tests\regen_ticket_index_test.py -qThe pytest list is deliberately a named subset — those are the files that pass with no lab, no live
AdminService and no cached DuckDB. The full suite is uv run pytest tests; most of it is offline, and
the remainder skips itself without a hierarchy to talk to.
Name a new test file <subject>_test.py. pyproject.toml pins
python_files = "*_test.py", so pytest collects the suffix form only. A file named
test_something.py is not collected when you run pytest tests — it does not run, it does not
fail, and nothing warns you. (Naming it explicitly on the command line does collect it, which is a
good way to be misled into thinking it is wired up.)
For a change to preprocess or convert, the cheapest strong check is to re-run both stages over a
cached collection bucket and diff the emitted graph. That holds collection constant, so the only thing
that can move is your change. INPUT_PATH is the parent of the sccm/ directory, not sccm/
itself, and a short path matters — dlt's state filenames are ~130 characters and overflow Windows'
260-character limit under a deep output directory, failing with FileNotFoundError on a file dlt just
wrote:
Copy-Item -Recurse .\out\sccm C:\ohcheck\sccm
uv run openhound preprocess sccm C:\ohcheck C:\ohcheck\lookup.duckdb
uv run openhound convert sccm C:\ohcheck C:\ohcheck\graph --lookup-file C:\ohcheck\lookup.duckdbExpect a handful of list-valued properties to be identical, not merely equivalent: array properties are sorted at the emit boundary precisely so this diff is meaningful.
openhound-compare ships with the shared library and diffs any two payloads — a .zip or a convert
output directory on either side — with no collection involved:
openhound-compare .\baselines\lowpriv-pe-on.zip .\out\graphThe baseline is what came first. The command exits non-zero if the candidate lost anything the
baseline had: a dropped node or edge, a lost property, a property emptied to null, a list-valued
property that lost items, or a property name that no longer appears on a kind at all. Additions and
ordinary value changes are listed under ADDED and exit 0. There is no flag to disable the exit code.
The strongest form of this check holds collection constant and varies only your code — reprocess one frozen raw bucket with both versions, so the only thing that can move is your change:
Copy-Item -Recurse .\out\sccm C:\ohcheck\sccm
uv run openhound preprocess sccm C:\ohcheck C:\ohcheck\lookup.duckdb
# ...on the baseline code:
uv run openhound convert sccm C:\ohcheck C:\ohcheck\graph-before --lookup-file C:\ohcheck\lookup.duckdb
# ...make your change, then:
uv run openhound convert sccm C:\ohcheck C:\ohcheck\graph-after --lookup-file C:\ohcheck\lookup.duckdb
openhound-compare C:\ohcheck\graph-before C:\ohcheck\graph-afterTwo live collections will also differ from ordinary lab churn — a decommissioned host, an ended session — so per-instance differences there are not all regressions. Prefer the frozen-bucket form when you want the diff to mean only your change.
For the full matrix — several identities × both possible-edge states — dev/ab_matrix.py
runs each cell and compares it against that cell's baseline in one command. Credentials come from the
environment, never the command line, and every cell is forced to a fresh directory:
$env:SCCM_LAB_PASSWORD_LOWPRIV = "..."
uv run python dev\ab_matrix.py --identity lowpriv:low --identity domainadmin:high `
--domain mayyhem.com --dc dc.mayyhem.com `
--baselines .\baselines --out-root .\ab-20260801The :low / :high suffix pins --integration-privilege for that identity's cells. Pinning
matters in a matrix: the default auto reads each run's own row counts, so a detection that flips
between runs would show up as a diff even though your code did not change. Add --dry-run to print the
planned cells without collecting, and --with-cmbp to also run the deprecated PowerShell collector for
a side-by-side parity pass (Windows only, and the shell must already be running as the target identity).
Need a hierarchy to test against? The lab this collector is developed and validated against is published as a Ludus Ansible collection — Mayyhem/ludus_sccm:
ludus ansible collection add mayyhem.ludus_sccmIt stands up the whole three-tier hierarchy the examples in this README assume: a parent CAS, a
primary PS1 with its site database, SMS Provider, management point, distribution point, content
library and a passive site server, and a child secondary SEC with its own management point and
distribution point — plus a domain controller running ADCS and the SCCM client installed on every
domain-joined host. (It extends Zach Stein's and Erik Hunstad's original Ludus SCCM project, which
deploys the standalone PS1 primary site.) Choose a domain suffix other than .local, which SCCM does
not handle properly; the examples throughout this README use mayyhem.com.
The three tiers matter for more than realism. A Central Administration Site has no management point of
its own to report its type, so the collector can only infer it from being the parent of a site typed
Primary — and SCCM_AdminsReplicatedTo needs both a typed CAS and a
Primary↔Secondary pair. A standalone-primary lab cannot exercise either path, nor the
Always an empty list on Secondary Sites behaviour of SCCM_Site.siteSystemRoles.
Collect-side changes — anything under collectors/ or clients/ — are not exercised by the offline
suite or by reprocessing a bucket. Only a live run covers them:
uv run openhound collect sccm .\out --clean --run-all--clean is not optional when you intend to compare runs. Without it dlt appends beside the previous
load packages and preprocess merges every accumulated run into one graph, so raw row counts multiply by
the number of runs in the directory, and any table this run finds empty keeps the previous run's rows.
Two flags exist for exactly this comparison, both implying --run-all:
--run-integration-tests asserts the graph against the bundled fixtures, and --compare-to-zip <payload>
diffs this run against a saved payload property-by-property. Both exit non-zero on failure — for the
comparison, "failure" means this run lost something the saved payload had. When both are passed, both
run to completion so you get both reports, and the process reports the worse of the two outcomes.
The dev/ scripts drive individual subsystems against a single host without a full collection. They
read credentials from the environment, never from the source:
$env:SCCM_LAB_PASSWORD = "..."
$env:SCCM_LAB_NT_HASH = "..."
uv run python dev\debug_epa_matrix.py --help-
Read the standards first. .agents/standards/openhound.md for collector rules, .agents/standards/workflow.md for the order of work, and the
openhoundskill references under .agents/skills/openhound/. -
Set up your environment and run the checks — see Testing Changes for both, including what to do when your change also touches the shared library. The four commands there are the ones CI runs, so a green local run means a green pull request.
To validate without touching the repo-local
.venvat all:$env:UV_PROJECT_ENVIRONMENT = "$env:TEMP\openhound-venv"; uv run pytest tests -q
-
Know what the tests cover. All of them live in tests/ — CLI parsing, AD auth warnings, the phased-pipeline engine/streams/work-queue, per-host wiring and log blocks, LDAP MP parsing, lookup/transform queries, SMB SSO, graph node/edge models, convert integration, and more. A change to
collectors/orclients/needs a live run as well; the offline suite does not reach them. -
Pre-commit hooks (.pre-commit-config.yaml) run
blackformatting plus YAML/JSON/whitespace/large-file checks:uv run pre-commit run --all-files
-
Replace a stub with a real collector by following its follow-up ticket: implement the collector in collectors/, add a typed model under models/ (import it from
models/__init__.pyso its@app.assetactually registers), wire any new tables into thepreprocesstable map in main.py, and validate against.agents/skills/openhound/references/validate-extension.mdbefore finishing.
This collector documents what the code does, not what it will do — please keep the README honest as features land, marking anything in flight as such.
The graph pipeline is complete for every collection method this collector implements — everything collect gathers now reaches the graph. Two ConfigManBearPig capabilities remain unported: DHCP/PXE collection and NAA-secret decryption. As of today:
collectruns LDAP / Local / DNS discovery plus six real per-host phases — RemoteRegistry, MSSQL EPA detection, AdminService, WMI (the AdminService fallback), HTTP (unauthenticated site-system role probing), and SMB (signing check + SCCM share-role enumeration). All six reach the graph — their rawremoteregistry_*/adminservice_*/wmi_*/http_*/smb_*tables are all read by transforms.py duringpreprocess. DHCP is accepted on the command line but its per-host collector is not yet ported.convertemits fifteen node kinds —Computer,User,Group,Container,SCCM_Site,SCCM_ClientDevice,SCCM_Collection,SCCM_AdminUser,SCCM_SecurityRole,MSSQL_Server,MSSQL_Database,MSSQL_ServerRole,MSSQL_DatabaseRole,MSSQL_Login, andMSSQL_DatabaseUser— and thirty-eight edge kinds, grouped here by the graph-pipeline stage that introduced them (these are increments of the preprocess/convert port, plans2026-06-16-sccm-preproc-convert-stage0.md…2026-07-01-sccm-preproc-convert-stage7.md; they are not the collection stages of the same number — see the Reference key): the eleven from Stages 1–2 (SCCM_AdminsReplicatedTo,SCCM_HasClient,SCCM_HasMember,SCCM_IsMappedTo,SCCM_IsAssigned,SCCM_HasPrimaryUser,SCCM_HasCurrentUser,SCCM_HasADLastLogonUser,SCCM_HasStoredAccount,MemberOf,HasSession) plus ten new from Stage 3 (SCCM_Contains,SCCM_FullAdministrator,SCCM_ApplicationAuthor,SCCM_ApplicationAdministrator,SCCM_ComplianceSettingsManager,SCCM_OSDManager,SCCM_OperationsAdministrator,SCCM_SecurityAdministrator,SCCM_AllPermissions,SCCM_AssignAllPermissions) plus two new from Stage 4 (SCCM_SameHostAs,SCCM_LocalAdminRequired) plus eleven new from Stage 5 (MSSQL_Contains,MSSQL_ControlServer,MSSQL_ControlDB,MSSQL_HostFor,MSSQL_ExecuteOnHost,MSSQL_HasLogin,MSSQL_IsMappedTo,MSSQL_MemberOf,MSSQL_ServiceAccountFor,MSSQL_GetTGS,MSSQL_GetAdminTGS) plus three new from Stage 6 (SCCM_CoerceAndRelayToAdminService,MSSQL_CoerceAndRelayToMSSQL,SCCM_CoerceAndRelayToSMB) plus one new base-kind edge from the low-privilege work (GenericAll);SCCM_AssignAllPermissionsgains a new Database→Site configuration in Stage 5 but is not a new kind string.- Low-privilege graph (no AdminService needed). A non-privileged domain user — or even an anonymous/credential-free HTTP+DNS probe — now builds a real attack graph, not just a handful of nodes:
site_hierarchyis fed from every site-code source the collector has (LDAP, RemoteRegistry, HTTP, SMB, DNS, local WMI), not just AdminService/WMI, and the site-signing-certificate probe, the Fallback Status Point, and DNS-discovered management points are wired into the graph for the first time. See Collection privilege tiers and Assumed vs. confirmed graph content under Assumptions.
This README documents what the code actually does today, not the finished design. For the tool this was ported from, see ConfigManBearPig, whose script is archived in powershell_deprecated/; for the graph pipeline's intended model, see the preprocess/convert design spec docs/superpowers/specs/2026-06-16-sccm-preproc-convert-design.md.
Two unrelated things are numbered "Stage" in this document. Which one is meant depends on the section:
| Shorthand | Scheme | What it means, and where it was decided |
|---|---|---|
| Stage 1 / Stage 2 — in Collection Overview, Command Line Options, and Understanding the Codebase | Collection stages | The two halves of collect: Stage 1 is once-per-run discovery (LDAP / Local / DNS); Stage 2 is the per-host phased pipeline that runs against each discovered host. From the per-host collection framework plan, docs/superpowers/plans/2026-06-03-per-host-collection-framework.md — with a step-by-step walkthrough in docs/per-host-collection-framework-tour.md. |
| Stage 1 – Stage 6 — everywhere else: the intro, Limitations, Graph Model, Node Reference, Edge Reference | Graph pipeline stages | The increments by which ConfigManBearPig's post-processing was ported into the DuckDB preprocess + convert stages. Stage 3 is the RBAC fan-out, Stage 4 host correlation, Stage 5 MSSQL, Stage 6 coerce-and-relay. From the numbered plans 2026-06-16-sccm-preproc-convert-stage0.md … 2026-07-01-sccm-preproc-convert-stage7.md, under one design spec, 2026-06-16-sccm-preproc-convert-design.md. |
D2a, D2b — in the MSSQL_Server property table |
Locked design decisions | Decisions from the low-privilege assumed-edges plan, docs/superpowers/plans/2026-07-23-low-priv-assumed-edges.md (design spec 2026-07-23-low-priv-assumed-edges-design.md): D2a resolves the MSSQLSvc SPN holder when the SQL port itself is unreachable; D2b treats an SCCM-related host carrying that SPN as the site database, stamping the result assumed with an assumptionBasis. |