-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathtransforms.py
More file actions
5995 lines (5519 loc) · 332 KB
/
Copy pathtransforms.py
File metadata and controls
5995 lines (5519 loc) · 332 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# src/openhound_sccm/transforms.py
"""DuckDB transforms for the SCCM collector's preproc phase.
Stage 1 builds the cross-cutting lookup tables (`principal_by_name`, site hierarchy
with `root_site_code`) and — added in later tasks — the coalesced `node_*` tables and
`graph_edges`. Each builder is defensive: a missing source table is logged and
skipped (early stages won't have collected everything).
"""
import logging
import os
from typing import Any
import duckdb
from openhound_collector_common.dlt.duckdb_safe import arr_sql, ensure_columns, safe_execute
logger = logging.getLogger(__name__)
# Truthy spellings accepted for the SOURCES__SCCM__DISABLE_POSSIBLE_EDGES env override.
_TRUTHY_ENV = frozenset({"1", "true", "yes", "on"})
def _privileged_transport_ran(con: duckdb.DuckDBPyConnection) -> bool:
"""True if any AdminService/WMI source table holds ROWS — i.e. a privileged
transport actually collected something this run.
HTTP and SMB are *fallback* phases: ``should_run_phase`` skips them for any
host a privileged transport already collected (per_host_phases.py:116,
mirroring ConfigManBearPig.ps1:8617). So when a privileged transport ran, an
absent http_/smb_ role table just means every host they would cover was
collected the privileged way — an expected, benign miss, not a real problem.
The clearest example: the SMS Provider *is* the AdminService host, so it is
privileged-collected and its HTTP probe is skipped, leaving http_smsproviders
empty in every normal authenticated run.
Rows, not mere existence. dlt writes a resource's SCHEMA even when it yields
zero rows, so "the table is in the catalog" does not mean "data was collected".
A real unprivileged run against an unreachable AdminService still materialised
adminservice_client_devices and adminservice_site_definitions with 0 rows; an
existence check read that as "a privileged transport ran" and kept 98 expected
misses at WARNING (con-81c2). Across 18 lab collections the true split is
binary — privileged runs have 13 populated tables, unprivileged runs have none
populated — so requiring a row is what separates them.
Short-circuits on the first populated table, so the common privileged case
costs one extra query, and the unprivileged case scans a handful of empties.
"""
try:
candidates = con.execute(
"SELECT table_schema, table_name FROM information_schema.tables "
"WHERE table_name LIKE 'adminservice_%' OR table_name LIKE 'wmi_%'"
).fetchall()
for schema, name in candidates:
if con.execute(f'SELECT 1 FROM "{schema}"."{name}" LIMIT 1').fetchone() is not None:
return True
except duckdb.Error:
# Can't query the catalog — stay safe and treat as "not privileged" (WARNING).
return False
# Either no privileged table at all, or every one of them is empty.
return False
def _sccm_expected_miss(con: duckdb.DuckDBPyConnection, missing: str) -> bool:
"""`expected_miss` predicate for the shared `safe_execute`: return True to
downgrade a missing-source-table log from WARNING to DEBUG.
Three benign-miss cases are specific to this collector:
1. Fallback-phase skip (http_ / smb_ tables). Handled by
``_privileged_transport_ran``: when a privileged transport ran this
collection, an absent http_/smb_ role table is expected. In an
HTTP-only/SMB-only run (no privileged table) it stays a WARNING.
2. Transport-mirror (wmi_ <-> adminservice_). The collector produces EITHER
wmi_<X> OR adminservice_<X> per data type — whichever transport was
available. A missing one whose sibling exists is a normal, expected miss.
3. No privileged transport at all (wmi_ and adminservice_ both absent). If the
catalog holds no privileged table whatsoever, the AdminService/WMI phases
produced nothing this run, so their tables are expected to be missing — this
is the unprivileged run, and it was 106 of the 146 WARNINGs a healthy
low-privilege collection emitted. If some privileged tables ARE present, an
absent one is a real gap and stays a WARNING.
Any other missing table has no such excuse and stays a WARNING. The catalog
lookups are schema-agnostic (match on table_name across schemas); under the
collector's single `sccm` schema this is equivalent to a schema-scoped check.
"""
# Case 1: HTTP/SMB are fallback phases — absent role tables are expected once
# a privileged transport has collected the hosts they would have covered.
if missing.startswith(("http_", "smb_")):
return _privileged_transport_ran(con)
# Case 2: wmi_/adminservice_ transport mirror — expected when the sibling ran.
if missing.startswith("wmi_"):
sibling = "adminservice_" + missing[len("wmi_"):]
elif missing.startswith("adminservice_"):
sibling = "wmi_" + missing[len("adminservice_"):]
else:
# Neither a fallback nor a transport-mirror prefix: a real miss (WARNING).
return False
try:
found = con.execute(
"SELECT 1 FROM information_schema.tables WHERE table_name = ?",
[sibling],
).fetchone()
except duckdb.Error:
# Can't query the catalog — stay safe and treat as a real miss (WARNING).
return False
if found is not None:
# Case 2: the other transport produced this data under the sibling name.
return True
# Case 3: NEITHER transport produced this table. When no adminservice_*/wmi_*
# table exists at all the privileged phases never ran, so every table they would
# have built is expected to be absent — that is an unprivileged run behaving
# correctly, and it accounted for 106 of the 146 WARNINGs in a low-privilege lab
# run. But when some DID land, this one query came back empty while its transport
# was working, which is a real gap and stays a WARNING.
return not _privileged_transport_ran(con)
def _safe(con: duckdb.DuckDBPyConnection, label: str, sql: str) -> None:
"""Run one SQL statement; log and continue if a source table is missing.
Thin wrapper over the shared `safe_execute` engine, injecting SCCM's
expected-miss downgrade (`_sccm_expected_miss`: wmi/adminservice transport
mirror + http/smb fallback-phase skip) and this module's logger so log
records stay under `openhound_sccm.transforms`.
"""
safe_execute(con, label, sql, expected_miss=_sccm_expected_miss, logger=logger)
def _ensure_columns(
con: duckdb.DuckDBPyConnection,
schema: str,
table: str,
coldefs: dict[str, str],
) -> None:
"""Add any missing columns (as NULL, typed) so a coalesce SELECT always binds.
Thin wrapper over the shared `ensure_columns`, passing this module's logger so
its DEBUG records stay under `openhound_sccm.transforms`. Why it's needed: a
coalesce SELECT references source columns inside expressions
(e.g. ``_arr('sccm_site_system_roles')``, ``coalesce(sccm_infra, false)``);
``INSERT ... BY NAME`` only maps *output* aliases, so every referenced source
column must physically exist or the whole SELECT fails to compile and ``_safe``
drops the source. Columns go missing when the source never emits them (e.g.
ldap_cmrc_devices has no roles column) or dlt drops an all-NULL column.
"""
ensure_columns(con, schema, table, coldefs, logger=logger)
def _column_exists(con: duckdb.DuckDBPyConnection, schema: str, table: str, column: str) -> bool:
"""True if *table* in *schema* currently has a column named *column*.
Backed by information_schema, which returns zero rows for a table that
doesn't exist yet — no try/except needed here (unlike a direct SELECT
against the table itself, which would raise CatalogException).
"""
row = con.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_schema = ? AND table_name = ? AND column_name = ?",
[schema, table, column],
).fetchone()
return row is not None
# SCCM's former _arr was byte-identical to the shared arr_sql (normalize a
# list-shaped column to VARCHAR[] whatever physical shape dlt produced). Pure SQL
# string builder, no logging — alias directly.
_arr = arr_sql
def _scalar(con: duckdb.DuckDBPyConnection, *execute_args: Any) -> Any:
"""First column of the first row of a scalar query. Same arguments as ``con.execute``.
DuckDB types ``fetchone()`` as ``tuple[Any, ...] | None``, so the bare
``con.execute(...).fetchone()[0]`` idiom this module used 28 times was 28 type errors —
mypy cannot know that a ``count(*)`` always returns a row.
Raising on a missing row is deliberate rather than returning None: every caller here
runs an aggregate (``count``/``min``/``max``) or a ``LIMIT 1`` lookup that always
yields exactly one row, so no row means the query is not the one the caller believes
it is. Failing here names the query; a ``TypeError`` three frames away does not.
"""
row = con.execute(*execute_args).fetchone()
if row is None:
raise RuntimeError(f"scalar query returned no row: {execute_args[0]!r}")
return row[0]
def _has_column(con: duckdb.DuckDBPyConnection, schema: str, table: str, column: str) -> bool:
"""True when `table` currently has `column`.
Needed by builders that assemble SQL differently per table (e.g. _stamp_sharphound_name,
which is shared across node tables with different column sets). _ensure_columns is the
wrong tool there: it would *add* the column as all-NULL, hiding the difference the caller
needs to branch on. Returns False for a missing table rather than raising, so a skipped
optional source behaves like an absent column everywhere else in this module.
"""
return bool(
_scalar(
con,
"SELECT count(*) FROM information_schema.columns "
"WHERE table_schema = ? AND table_name = ? AND column_name = ?",
[schema, table, column],
)
)
def _principal_by_name(con: duckdb.DuckDBPyConnection, schema: str) -> None:
"""Union every collected (name, SID) pair for offline name->SID resolution.
Each source table is optional — a missing table is logged and skipped. The
result is deduplicated with SIDs uppercased; names are stored in their
original casing (callers must upper() both sides when joining on name).
"""
con.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
con.execute(f"CREATE OR REPLACE TABLE {schema}.principal_by_name (name VARCHAR, sid VARCHAR)")
# Each entry is (label, SELECT statement yielding (name, sid) columns).
sources = [
("principal_by_name<-adminservice_r_system",
f"SELECT name, sid FROM {schema}.adminservice_r_system WHERE sid IS NOT NULL"),
("principal_by_name<-wmi_r_system",
f"SELECT name, sid FROM {schema}.wmi_r_system WHERE sid IS NOT NULL"),
("principal_by_name<-adminservice_r_user",
f"SELECT name, sid FROM {schema}.adminservice_r_user WHERE sid IS NOT NULL"),
("principal_by_name<-wmi_r_user",
f"SELECT name, sid FROM {schema}.wmi_r_user WHERE sid IS NOT NULL"),
# SMS_R_UserGroup carries each security group's own SID + its DOMAIN\name
# (unique_usergroup_name), so the security_group_name memberships on r_system /
# r_user rows resolve to a group SID here (offline) instead of a live AD lookup.
("principal_by_name<-adminservice_user_group",
f"SELECT unique_usergroup_name AS name, sid FROM {schema}.adminservice_user_group WHERE sid IS NOT NULL"),
("principal_by_name<-wmi_user_group",
f"SELECT unique_usergroup_name AS name, sid FROM {schema}.wmi_user_group WHERE sid IS NOT NULL"),
("principal_by_name<-adminservice_admins",
f"SELECT logon_name AS name, admin_sid AS sid FROM {schema}.adminservice_admins WHERE admin_sid IS NOT NULL"),
("principal_by_name<-wmi_admins",
f"SELECT logon_name AS name, admin_sid AS sid FROM {schema}.wmi_admins WHERE admin_sid IS NOT NULL"),
# DOMAIN\user / UPN name forms from r_user so device primary/current user and SQL service
# account fields (which carry these forms) resolve to a SID via principal_by_name.
("principal_by_name<-adminservice_r_user_unique",
f"SELECT unique_user_name AS name, sid FROM {schema}.adminservice_r_user WHERE sid IS NOT NULL AND unique_user_name IS NOT NULL"),
("principal_by_name<-wmi_r_user_unique",
f"SELECT unique_user_name AS name, sid FROM {schema}.wmi_r_user WHERE sid IS NOT NULL AND unique_user_name IS NOT NULL"),
("principal_by_name<-adminservice_r_user_full",
f"SELECT full_user_name AS name, sid FROM {schema}.adminservice_r_user WHERE sid IS NOT NULL AND full_user_name IS NOT NULL"),
("principal_by_name<-wmi_r_user_full",
f"SELECT full_user_name AS name, sid FROM {schema}.wmi_r_user WHERE sid IS NOT NULL AND full_user_name IS NOT NULL"),
("principal_by_name<-adminservice_r_user_upn",
f"SELECT user_principal_name AS name, sid FROM {schema}.adminservice_r_user WHERE sid IS NOT NULL AND user_principal_name IS NOT NULL"),
("principal_by_name<-wmi_r_user_upn",
f"SELECT user_principal_name AS name, sid FROM {schema}.wmi_r_user WHERE sid IS NOT NULL AND user_principal_name IS NOT NULL"),
]
for label, select_sql in sources:
# Preserve the original name casing; only uppercase the SID for consistent lookups.
_safe(
con,
label,
f"INSERT INTO {schema}.principal_by_name "
f"SELECT trim(name), upper(sid) FROM ({select_sql})",
)
# Replace the raw inserts with a deduplicated copy.
con.execute(
f"CREATE OR REPLACE TABLE {schema}.principal_by_name AS "
f"SELECT DISTINCT name, sid FROM {schema}.principal_by_name "
f"WHERE name IS NOT NULL AND sid IS NOT NULL"
)
logger.info("principal_by_name built in schema %r", schema)
# 'None' / 'Undetermined' / '' are placeholders several collectors emit for an
# unknown parent OR root (e.g. ldap_sites:197 for parent; MP capabilities can
# in principle emit the same placeholders for root_site_code). Both columns
# hold "a site code this collector may not have resolved yet", so both must
# normalize these to NULL or the parentless-Primary root test in
# _site_hierarchy silently never matches. One constant, one SQL fragment, so
# every consumer (the arms below and the root query) agrees on what a
# placeholder looks like -- previously the root query re-typed this list
# inline and could drift from the arms' own normalization.
_SITE_CODE_SENTINELS = ("None", "Undetermined", "")
_SITE_CODE_SENTINEL_SQL = ", ".join(f"'{s}'" for s in _SITE_CODE_SENTINELS)
# "This site is NOT a secondary" -- same one-constant reasoning as the sentinels
# above. Five builders need it (_edge_contains, _edge_all_permissions,
# _edge_assign_all_permissions, _edge_local_admin_required, _edge_coerce_relay_smb)
# and each used to inline its own copy, so the unknown-type policy could drift
# between them.
#
# con-edee: that policy was `coalesce(site_type, 0) != 1`, i.e. anything not
# explicitly Secondary counts as non-secondary -- which silently includes a site
# whose type is UNKNOWN. At low privilege that is backwards. A site discovered only
# as a bare site code (mayyhem's SEC secondary, learned from SMB share comments)
# carries site_type NULL, so every SMS Provider gained a spurious
# hierarchy-takeover edge to it: 12 SCCM_AssignAllPermissions edges where the lab
# has 8. Requiring a KNOWN Primary (2) or CAS (4) inverts the default to "exclude
# what we could not characterize", because a false "can take over this site" edge
# costs an operator more than a missing one.
#
# This does not cost coverage on a site the collector actually characterized: every
# privileged source states the type outright, and at low privilege _site_hierarchy's
# CAS/Secondary inference derives it (mayyhem's CAS and PS1 are both typed in a
# lowpriv run; only the bare-code SEC is not).
_NON_SECONDARY_SITE_TYPE_SQL = "site_type IN (2, 4)"
def _norm_site_code(col: str) -> str:
"""SQL fragment: a parent/root site-code column with sentinels normalized to NULL.
Casts to VARCHAR before comparing/upper-casing. When every row of *col* collected
this run is NULL -- the routine low-privilege shape this whole plan targets (e.g.
a single standalone Primary site genuinely has no parent to report) -- there is no
non-NULL value in the column to infer a type from, and DuckDB can infer it as
INTEGER instead of VARCHAR. A bare upper() on that then raises a BinderException;
_safe() swallows it as a logged skip, which silently drops the WHOLE arm -- not
just this column, but every other value (site_code, site_type, ...) that arm would
have contributed. The CAST makes this type-safe regardless of what DuckDB inferred
(same fix pattern already used ad hoc elsewhere in this file, e.g. for
restrict_receiving_ntlm_traffic / extended_protection).
"""
c = f"CAST({col} AS VARCHAR)"
return f"CASE WHEN {c} IN ({_SITE_CODE_SENTINEL_SQL}) THEN NULL ELSE upper({c}) END"
# Tables already consumed with their full hierarchy shape (type + parent + root);
# re-reading them as bare codes in the discovery loop below would be harmless but
# confusing in the logs.
_HIERARCHY_SHAPED = frozenset({
"adminservice_site_definitions", "wmi_site_definitions", "ldap_management_points_raw",
})
def _bare_site_code_tables(con: duckdb.DuckDBPyConnection, schema: str) -> list[str]:
"""Every raw table in *schema* carrying a site_code column, minus the ones
already loaded above with their parent/type (D5).
Discovered rather than hardcoded so a new collector that learns a site code
feeds the hierarchy automatically, and so absent tables need no _safe guard —
they simply aren't listed. A site_code appearing anywhere is evidence that
site exists, so widening the net cannot invent a site.
`_site_hierarchy` runs first in `transforms()`, before any node_*/edge_* table
exists, so those two guards below are pure defence for a future reordering —
they can't currently exclude anything. The underscore-prefixed and
`assumed_site_dbs` exclusions serve the same defensive purpose: they keep this
loop from ever feeding on transforms' own derived output if it is ever called
again after those tables exist. Every `LIKE` that targets a literal
underscore is `ESCAPE`d -- in DuckDB (as in standard SQL) an unescaped `_`
in a LIKE pattern is a single-character wildcard, so an unescaped
`'node_%'`/`'edge_%'`/`'_%'` would also match names like `nodeX...` or,
worse, match (and so silently exclude) every table name via the bare `_%`
guard.
"""
rows = con.execute(
"SELECT table_name FROM information_schema.columns "
"WHERE table_schema = ? AND column_name = 'site_code' "
" AND table_name NOT LIKE 'node\\_%' ESCAPE '\\' "
" AND table_name NOT LIKE 'edge\\_%' ESCAPE '\\' "
" AND table_name NOT LIKE '\\_%' ESCAPE '\\' "
" AND table_name <> 'site_hierarchy' AND table_name <> 'assumed_site_dbs' "
"ORDER BY table_name",
[schema],
).fetchall()
return [t for (t,) in rows if t not in _HIERARCHY_SHAPED]
def _site_hierarchy(con: duckdb.DuckDBPyConnection, schema: str, disable_possible_edges: bool) -> None:
"""Build site_code/parent_site_code/site_type, then stamp root_site_code.
Root resolution is tried in order of strongest evidence first (D4/D5):
A. `ldap_management_points_raw.root_site_code` — the site the MP
capabilities XML itself names as root. Observed evidence, not derived,
so it wins over B/C and applies in BOTH flag modes.
B. CAS (site_type=4) if present, else a parentless Primary (site_type=2) —
matching CMBP Get-HierarchyRoot (ps1:2620). Also observed, both modes.
C. Only if A and B found nothing: a best-effort guess among the remaining
untyped-or-Primary sites (never a Secondary — see Step C below), gated
by `disable_possible_edges` except in the single-parentless-candidate
case, which is deduction rather than a guess.
Single-hierarchy assumption (README Assumptions): one root per graph.
"""
con.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
con.execute(
f"CREATE OR REPLACE TABLE {schema}.site_hierarchy "
f"(site_code VARCHAR, parent_site_code VARCHAR, site_type INTEGER)"
)
# Load from whichever site-definition sources were collected. site_code is
# upper()'d here for the same reason every other arm upper()s it: the
# collapse below groups by site_code, so if this privileged source and a
# low-priv source (LDAP/HTTP/SMB, all upper()'d) ever disagree only on
# casing for the same site, the un-normalized version would split into two
# rows -- fanning out root_site_code and mis-anchoring every SCCM-native id
# built from it. This was a latent risk introduced by D5 (before it, both
# arms here were the only feeders, so a self-consistent casing was
# guaranteed); site codes are already uppercase on live data, so this is a
# no-op there. Sentinel parents are normalized here too (IMPORTANT-4) --
# without it a privileged collection can still store a literal '' / 'None'
# parent instead of NULL, which would silently fail the parentless-Primary
# check in Step B below.
#
# site_code itself is cast to VARCHAR before upper() throughout this function
# (here and in every arm below), for the same type-safety reason _norm_site_code
# now casts: if site_code is all-NULL for a source's whole load, DuckDB can infer
# it as a non-VARCHAR type and a bare upper() would BinderException the entire
# arm away.
#
# But an all-NULL column is the FIXTURE shape, not the production one: dlt does
# not materialize an all-NULL column as some other type -- it drops the column
# entirely (models/raw_table.py, _ensure_columns' own docstring). A dropped
# column raises the exact same BinderException the CAST above guards against,
# so every raw column any arm below references must ALSO go through
# _ensure_columns first -- the CAST alone only protects the test-fixture shape.
# This mirrors the protection the bare-code loop already has via
# _column_exists/has_parent a few lines down; these three hierarchy-shaped arms
# never had it.
for _hs in ("adminservice_site_definitions", "wmi_site_definitions"):
_ensure_columns(con, schema, _hs, {
"site_code": "VARCHAR", "parent_site_code": "VARCHAR", "site_type": "VARCHAR",
})
_safe(
con,
"site_hierarchy<-adminservice",
f"INSERT INTO {schema}.site_hierarchy "
f"SELECT upper(CAST(site_code AS VARCHAR)), {_norm_site_code('parent_site_code')}, "
f" TRY_CAST(site_type AS INTEGER) "
f"FROM {schema}.adminservice_site_definitions",
)
_safe(
con,
"site_hierarchy<-wmi",
f"INSERT INTO {schema}.site_hierarchy "
f"SELECT upper(CAST(site_code AS VARCHAR)), {_norm_site_code('parent_site_code')}, "
f" TRY_CAST(site_type AS INTEGER) "
f"FROM {schema}.wmi_site_definitions",
)
# LDAP management-point capabilities already carry site type/parent/root
# (collectors/ldap.py _parse_mp_capabilities). Low-priv reachable; feed them
# so a domain-only bind still yields a hierarchy. String site_type -> INTEGER
# contract (1=Secondary, 2=Primary, 4=CAS). root_site_code is read separately
# in Step A below (it is not a site_hierarchy row column; it is used once to
# resolve root_code directly) -- ensured here too since Step A reads the same
# table and would otherwise hit the identical dropped-column BinderException
# (reviewer C1: collectors/ldap.py:35 defaults root_site_code to None, so a run
# where every MP's mSSMSCapabilities is empty/unparseable never populates it,
# and dlt drops the resulting all-NULL column entirely).
_ensure_columns(con, schema, "ldap_management_points_raw", {
"site_code": "VARCHAR", "parent_site_code": "VARCHAR", "site_type": "VARCHAR",
"root_site_code": "VARCHAR",
})
_safe(
con,
"site_hierarchy<-ldap_mp",
f"INSERT INTO {schema}.site_hierarchy "
f"SELECT DISTINCT upper(CAST(site_code AS VARCHAR)), {_norm_site_code('parent_site_code')}, "
f" CASE site_type WHEN 'Central Administration Site' THEN 4 "
f" WHEN 'Primary Site' THEN 2 "
f" WHEN 'Secondary Site' THEN 1 ELSE NULL END "
f"FROM {schema}.ldap_management_points_raw WHERE site_code IS NOT NULL",
)
# D5: every other site-code source registers its bare code. No type/parent, so
# the collapse below lets any richer row for the same code win (max(site_type)).
# Track which tables actually inserted a row (IMPORTANT-8): several bare
# tables exist but carry only NULL site codes for every record they collect
# (e.g. adminservice_admins), so "N tables discovered" overstates what was
# actually learned -- log what contributed instead.
bare_tables = _bare_site_code_tables(con, schema)
contributed = []
for table in bare_tables:
# _bare_site_code_tables just confirmed this table exists (via
# information_schema, moments ago, on this same single-threaded
# connection), so this COUNT cannot hit a missing-table error. It
# references a dynamic column type, though (like every other query in
# this family), so a try/except still guards it for consistency: a
# LIST/STRUCT/BLOB-typed site_code would fail the CAST/upper() with a
# BinderException. This value only feeds the "which tables
# contributed" log line below, not site_hierarchy itself -- the
# _safe-wrapped INSERT further down is a separate statement and is
# unaffected either way -- so a failure here just falls back to
# "nothing counted" rather than aborting the loop.
try:
site_code_count = _scalar(con,
f"SELECT count(DISTINCT upper(CAST(site_code AS VARCHAR))) "
f"FROM {schema}.{table} WHERE site_code IS NOT NULL"
)
except duckdb.BinderException as ex:
logger.debug(
"site_hierarchy: could not count distinct site codes in %r for the "
"contributor log line (%s); treating as zero", table, ex,
)
site_code_count = 0
# Some bare sources (e.g. http_management_points) never collected a
# parent_site_code column at all; referencing a column that doesn't
# exist would fail the whole SELECT, so fall back to a literal NULL.
has_parent = _column_exists(con, schema, table, "parent_site_code")
parent_expr = _norm_site_code("parent_site_code") if has_parent else "NULL"
_safe(
con,
f"site_hierarchy<-{table}",
f"INSERT INTO {schema}.site_hierarchy "
f"SELECT DISTINCT upper(CAST(site_code AS VARCHAR)), {parent_expr}, NULL "
f"FROM {schema}.{table} WHERE site_code IS NOT NULL",
)
if site_code_count:
contributed.append(table)
logger.info(
"site_hierarchy: %d of %d discovered bare-code source table(s) actually contributed a "
"site code in schema %r: %s",
len(contributed), len(bare_tables), schema, contributed,
)
# Collapse duplicate rows (same site_code from multiple sources). any_value
# skips NULLs (picks the first non-null value in the group), so a bare
# source's NULL parent never overwrites a richer source's real parent for
# the same site_code -- pinned by
# test_bare_source_null_parent_does_not_clobber_privileged_parent.
con.execute(
f"CREATE OR REPLACE TABLE {schema}.site_hierarchy AS "
f"SELECT site_code, "
f" any_value(parent_site_code) AS parent_site_code, "
f" max(site_type) AS site_type "
f"FROM {schema}.site_hierarchy "
f"WHERE site_code IS NOT NULL "
f"GROUP BY site_code"
)
# --- Infer the site types the collected sources could not state ---
# Only management-point capabilities carry an explicit site_type, and a CAS has no
# management point, so a CAS is NEVER typed directly: live 2026-07-28 low-priv run
# produced ldap_management_points_raw = [('PS1','Primary Site','CAS','CAS')] and
# nothing at all for CAS, leaving site_hierarchy = [('CAS',NULL,NULL),('PS1','CAS',2)].
# That silently cost every SCCM_AdminsReplicatedTo edge, because _edge_replication
# joins on `child.site_type = 2 AND parent.site_type = 4`.
#
# Both rules below are deductions from SCCM's hierarchy model, not guesses: a Primary
# site's parent can only be a CAS, and a site whose parent is a Primary can only be a
# Secondary. Each fires only where the type is currently unknown, so an explicitly
# collected type always wins.
for label, sql in (
("cas-from-primary-parent",
f"UPDATE {schema}.site_hierarchy SET site_type = 4 WHERE site_type IS NULL "
f"AND site_code IN (SELECT parent_site_code FROM {schema}.site_hierarchy "
f" WHERE site_type = 2 AND parent_site_code IS NOT NULL)"),
("secondary-from-primary-parent",
f"UPDATE {schema}.site_hierarchy SET site_type = 1 WHERE site_type IS NULL "
f"AND parent_site_code IN (SELECT site_code FROM {schema}.site_hierarchy "
f" WHERE site_type = 2)"),
):
try:
n = con.execute(sql).fetchone()
changed = n[0] if n else 0
if changed:
logger.info("site_hierarchy: inferred site_type for %s site(s) via %s",
changed, label)
else:
logger.debug("site_hierarchy: %s inferred nothing", label)
except duckdb.Error as ex:
# Never fatal: an un-inferred type only costs edges, whereas aborting
# preprocess costs the whole graph.
logger.warning("site_hierarchy: site_type inference %s failed: %s", label, ex)
# --- Root resolution (strongest evidence first) ---
# Step A (D4/CRITICAL-2): LDAP MP capabilities directly report the site the
# site-hierarchy XML calls the root (RootSiteCode). This is observed
# evidence read straight off the wire, not something derived from collected
# types/parents, so it takes priority over Steps B/C and applies in BOTH
# flag modes -- consulting it isn't a guess.
try:
mp_root_rows = con.execute(
f"SELECT DISTINCT {_norm_site_code('root_site_code')} AS root_site_code "
f"FROM {schema}.ldap_management_points_raw "
f"WHERE root_site_code IS NOT NULL"
).fetchall()
except duckdb.CatalogException:
# No LDAP management-point capabilities this run (an HTTP-only,
# SMB-only, or RemoteRegistry-only collection) -- fall through to Step B.
logger.debug("site_hierarchy: ldap_management_points_raw absent in schema %r; "
"no MP-reported root to consult", schema)
mp_root_rows = []
except duckdb.BinderException as ex:
# Belt-and-suspenders: root_site_code is _ensure_columns'd onto this table
# above, so this branch should be unreachable via the column-dropped path
# (reviewer C1) it was originally written for -- BinderException is a
# SIBLING of CatalogException (not a subclass), so an unguarded reference
# to a dropped column would otherwise propagate uncaught and abort the
# entire preprocess run. But _ensure_columns now guarantees that specific
# cause is gone, so any BinderException actually reaching this handler is,
# BY DEFINITION, something else -- a typo, a bad cast, some other unguarded
# reference. WARNING (not DEBUG) + the real exception text (reviewer
# OVER-CATCH), so a genuine SQL bug here can't vanish silently under a
# hardcoded "missing root_site_code" diagnosis that no longer applies.
logger.warning(
"site_hierarchy: unexpected BinderException reading root_site_code from "
"ldap_management_points_raw in schema %r (%s); treating as no MP-reported "
"root and falling through to Step B", schema, ex,
)
mp_root_rows = []
mp_root_codes = sorted({code for (code,) in mp_root_rows if code is not None})
root_code = None
if len(mp_root_codes) == 1:
root_code = mp_root_codes[0]
logger.info(
"site_hierarchy: root %r observed directly via LDAP MP capabilities' RootSiteCode "
"in schema %r", root_code, schema,
)
elif len(mp_root_codes) > 1:
# More than one distinct RootSiteCode means more than one hierarchy was
# traversed (a multi-hierarchy environment). Still observed evidence --
# not a guess -- so this resolves in BOTH modes; pick deterministically.
root_code = min(mp_root_codes)
logger.warning(
"site_hierarchy: %d distinct RootSiteCode values reported by LDAP MP capabilities "
"%s in schema %r (multi-hierarchy environment); using %r deterministically.",
len(mp_root_codes), mp_root_codes, schema, root_code,
)
# else: no LDAP-reported root at all -- fall through to Step B.
# Step B: CAS (type 4) takes priority; fall back to a parentless Primary
# (type 2) for single-Primary hierarchies with no CAS. Only consulted when
# Step A found nothing. The sentinel IN-list reuses _SITE_CODE_SENTINEL_SQL
# (IMPORTANT-4) as a defensive backstop -- every arm above already
# normalizes sentinels to NULL at insert time, so this should never match
# a live row, but it keeps this query correct even if a future arm forgets.
if root_code is None:
root = con.execute(
f"SELECT site_code FROM {schema}.site_hierarchy WHERE site_type = 4 "
f"UNION ALL "
f"SELECT site_code FROM {schema}.site_hierarchy "
f"WHERE site_type = 2 "
f" AND (parent_site_code IS NULL OR parent_site_code IN ({_SITE_CODE_SENTINEL_SQL})) "
f"LIMIT 1"
).fetchone()
root_code = root[0] if root else None
if root_code is not None:
logger.info("site_hierarchy root resolved to %r in schema %r", root_code, schema)
# Step C (CRITICAL-1): neither A nor B found a root. Only untyped-or-Primary
# sites are ever viable roots: a Secondary (type 1) reports to something
# above it by definition and is excluded outright here, not merely
# deprioritized -- picking it would mint the whole graph under the wrong
# '@<root>' scope.
if root_code is None:
# Preferred pool: no known type, or explicitly Primary (2), AND no known
# parent. That is the same CAS-else-parentless-Primary shape the
# contract requires, just without a confirmed type for the untyped
# case -- so a SINGLE such site is deduction (it must be the root, the
# same way a lone parentless Primary is in Step B), not a guess, and
# resolves in BOTH modes.
preferred = [
code for (code,) in con.execute(
f"SELECT site_code FROM {schema}.site_hierarchy "
f"WHERE (site_type IS NULL OR site_type = 2) AND parent_site_code IS NULL "
f"ORDER BY site_code"
).fetchall()
]
if len(preferred) == 1:
root_code = preferred[0]
logger.info(
"site_hierarchy: single untyped-or-Primary, parentless site %r in schema %r; "
"using it as the root by deduction", root_code, schema,
)
elif len(preferred) > 1:
# More than one parentless untyped-or-Primary site: picking one IS
# an assumption, so evidence-only mode declines to make it. Leaving
# root_code None means SCCM-native ids lose their '@<root>' scope
# (transforms.py:1583), so say that plainly.
if disable_possible_edges:
logger.warning(
"site_hierarchy: %d parentless untyped-or-Primary sites %s in schema %r and "
"--disable-possible-edges is set, so no root is assumed. SCCM-native node ids "
"will be minted without their '@<root>' scope and will not match a "
"default-mode graph of the same environment. Collect with LDAP reachable "
"(or pass --site-codes) to resolve a real root.",
len(preferred), preferred, schema,
)
else:
root_code = min(preferred)
# WARNING here, because with more than one candidate the pick is
# alphabetical and the root -- plus every id and edge anchored to
# it -- may be wrong.
logger.warning(
"site_hierarchy: %d parentless untyped-or-Primary sites %s in schema %r; none "
"is confirmed as the root (no CAS/RootSiteCode observed), so %r was chosen "
"alphabetically. Collect with LDAP reachable (or pass --site-codes) to anchor "
"the root correctly.",
len(preferred), preferred, schema, root_code,
)
else:
# No parentless candidate at all: every known untyped-or-Primary
# site reports to a parent that was never itself observed as a row
# (e.g. a Primary whose CAS wasn't collected). That parent is
# unresolved, so picking one of these is a guess too -- a weaker one
# than the parentless case above, since even the sole candidate's
# own parent is unknown.
fallback = [
code for (code,) in con.execute(
f"SELECT site_code FROM {schema}.site_hierarchy "
f"WHERE (site_type IS NULL OR site_type = 2) "
f"ORDER BY site_code"
).fetchall()
]
if fallback and disable_possible_edges:
logger.warning(
"site_hierarchy: %d untyped-or-Primary site(s) %s in schema %r have only an "
"unresolved parent (no CAS/RootSiteCode observed) and --disable-possible-edges "
"is set, so no root is assumed. SCCM-native node ids will be minted without "
"their '@<root>' scope. Collect with LDAP reachable (or pass --site-codes) to "
"resolve a real root.",
len(fallback), fallback, schema,
)
elif fallback:
root_code = min(fallback)
logger.warning(
"site_hierarchy: %d untyped-or-Primary site(s) %s in schema %r have only an "
"unresolved parent (no CAS/RootSiteCode observed), so %r was chosen "
"alphabetically as the root. Collect with LDAP reachable (or pass "
"--site-codes) to anchor the root correctly.",
len(fallback), fallback, schema, root_code,
)
else:
# Every known site is a Secondary (excluded outright), or there
# are no sites at all -- nothing viable to guess from in either mode.
logger.warning(
"site_hierarchy: no untyped-or-Primary site found in schema %r (only Secondary "
"sites and/or no sites at all); no root can be determined", schema,
)
# Stamp every row with the resolved root.
con.execute(
f"CREATE OR REPLACE TABLE {schema}.site_hierarchy AS "
f"SELECT site_code, parent_site_code, site_type, ? AS root_site_code "
f"FROM {schema}.site_hierarchy",
[root_code],
)
def _authed_users_id(dnshostname_col: str) -> str:
"""SQL fragment: the SharpHound-form Authenticated Users node id for the domain of a
computer, derived from its dnshostname column. FQDN = dnshostname with the first
(host) label stripped, uppercased (e.g. 'PROV01.mayyhem.com' -> 'MAYYHEM.COM-S-1-5-11').
Always pair with a `<col> LIKE '%.%'` guard so a bare hostname can't yield a bad id."""
return f"upper(regexp_replace({dnshostname_col}, '^[^.]+\\.', '')) || '-S-1-5-11'"
def _derive_ad_props(con: duckdb.DuckDBPyConnection, schema: str) -> None:
"""Build sccm.ad_props (sid -> CMBP-parity AD attributes) from ldap_resolved_principals.
Enabled = userAccountControl bit 2 (ACCOUNTDISABLE) clear; Type = last objectClass
element title-cased (e.g. 'user' -> 'User'); IsDomainPrincipal = True for every row,
since only LDAP-resolved principals land in ldap_resolved_principals in the first
place. Must run before _node_computer/_node_user/_node_group so _join_ad_props has
something to join against.
sam_account_name / distinguished_name are carried here too (ldap_resolved_principals
already persists both -- context.py:_record_resolved_principal) so that a node type
with no OTHER source for them (e.g. Group: unlike Computer/User it has no raw arm of
its own that spreads an AD object) can still pick them up whenever its SID happens to
have been independently LDAP-resolved.
ldap_resolved_principals is itself a best-effort finalization table (Task A2) whose
own pipeline.run is allowed to fail without aborting the collect, so it may be absent
this run. Treated like any other optional source: _ensure_columns backfills columns a
load never emitted or dropped as all-NULL, and _safe() logs+skips a missing table —
leaving ad_props created-but-empty (schema-complete) rather than raising, so the
LEFT JOINs in _join_ad_props always bind.
"""
con.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
con.execute(
f"CREATE OR REPLACE TABLE {schema}.ad_props ("
"sid VARCHAR, "
"enabled BOOLEAN, "
"type VARCHAR, "
"is_domain_principal BOOLEAN, "
"object_class VARCHAR[], "
"service_principal_name VARCHAR[], "
"cn VARCHAR, "
"domain VARCHAR, "
"sam_account_name VARCHAR, "
"distinguished_name VARCHAR"
")"
)
_ensure_columns(con, schema, "ldap_resolved_principals", {
"object_class": "VARCHAR",
"user_account_control": "BIGINT",
"service_principal_name": "VARCHAR",
"cn": "VARCHAR",
"domain": "VARCHAR",
"sam_account_name": "VARCHAR",
"distinguished_name": "VARCHAR",
})
_oc = _arr("object_class")
_spn = _arr("service_principal_name")
_safe(
con,
"ad_props<-ldap_resolved_principals",
f"INSERT INTO {schema}.ad_props BY NAME "
# Collapse to one row per sid: ldap_resolved_principals can carry more than one
# row for the same principal (case-variant SID strings — the in-memory
# accumulator dedupes on the raw, case-sensitive SID — or a resumed/retried
# collect re-appending to the no-primary-key finalization resource). Without
# this GROUP BY, a duplicated sid here would duplicate a real node row through
# the LEFT JOIN in _join_ad_props.
f"SELECT sid, "
f" any_value(enabled) AS enabled, "
f" any_value(type) AS type, "
f" any_value(is_domain_principal) AS is_domain_principal, "
f" any_value(object_class) AS object_class, "
f" any_value(service_principal_name) AS service_principal_name, "
f" any_value(cn) AS cn, "
f" any_value(domain) AS domain, "
f" any_value(sam_account_name) AS sam_account_name, "
f" any_value(distinguished_name) AS distinguished_name "
f"FROM ("
f" SELECT upper(sid) AS sid, "
# userAccountControl arrives as VARCHAR in production (ldap3 raw values decode to
# strings and dlt infers a text column -- the BIGINT in _ensure_columns only applies
# when the column is absent), so TRY_CAST it before the bitwise AND. Without the cast
# DuckDB raises a VARCHAR & INTEGER binder error that _safe() would swallow, silently
# emptying ad_props. TRY_CAST -> NULL on a non-numeric value, yielding enabled=NULL.
f" CASE WHEN user_account_control IS NULL THEN NULL "
f" ELSE (TRY_CAST(user_account_control AS BIGINT) & 2) = 0 END AS enabled, "
f" CASE WHEN len({_oc}) = 0 THEN NULL "
f" ELSE upper(substr({_oc}[-1], 1, 1)) || lower(substr({_oc}[-1], 2)) END AS type, "
f" TRUE AS is_domain_principal, "
f" {_oc} AS object_class, "
f" {_spn} AS service_principal_name, "
f" cn, domain, sam_account_name, distinguished_name "
f" FROM {schema}.ldap_resolved_principals "
f" WHERE sid IS NOT NULL"
f") "
f"GROUP BY sid",
)
# ad_props is the ONLY source of AD attributes for every AD node kind (names,
# Enabled, Type, objectClass, servicePrincipalName, CN, Domain). _safe() above
# logs-and-skips a missing ldap_resolved_principals, which leaves this table
# created-but-empty and silently strips those attributes from the whole graph.
# _emit_resolved_principals warns at the emission site; without this warning the
# consumption site is silent, so an operator sees an attribute-less graph with no
# indication why. Ope-15m7.
if not _scalar(con, f"SELECT count(*) FROM {schema}.ad_props"):
logger.warning(
"ad_props is empty: no LDAP-resolved principals were available. Every AD "
"node will be emitted without a name or AD attributes. Check for an earlier "
"'Failed to persist ldap_resolved_principals' warning, or for a collect that "
"reached no domain controller."
)
else:
logger.info("ad_props built in schema %r", schema)
# Matches a domain-relative SID and captures its domain portion (S-1-5-21-x-y-z).
# Builtin/well-known SIDs (S-1-5-32-*, S-1-5-11) do not match -- they have no domain
# part of their own and are qualified from a co-occurring principal instead.
_DOMAIN_SID_SQL = "'^(S-1-5-21(?:-\\d+){3})-\\d+$'"
def _domain_sid_of(col: str) -> str:
"""SQL expression extracting the domain SID from a SID column, NULL if not domain-relative."""
return f"nullif(regexp_extract(upper({col}), {_DOMAIN_SID_SQL}, 1), '')"
def _is_object_class(oc: str, *, unless: tuple[str, ...] = ()) -> str:
"""SQL predicate: does this ad_props row describe an object of LDAP class `oc`?
Checks the objectClass list rather than only `type` (which _derive_ad_props derives from
the list's last element) so an object whose class chain ends unexpectedly is still
classified correctly.
`unless` names classes that disqualify the row even when `oc` is present, because AD's
objectClass chains nest: a computer account's chain is
(top, person, organizationalPerson, user, computer), so it satisfies 'user' too. Without
the exclusion the user arm below claims every computer in the domain -- the same
is-a-computer guard the mssql_server_instances arm already applies for this reason.
"""
def contains(cls: str) -> str:
return (
f"list_contains(list_transform(coalesce(object_class, CAST([] AS VARCHAR[])), "
f"x -> lower(x)), '{cls}')"
)
predicate = f"(lower(coalesce(type, '')) = '{oc}' OR {contains(oc)})"
for other in unless:
predicate += f" AND NOT (lower(coalesce(type, '')) = '{other}' OR {contains(other)})"
return f"({predicate})"
def _ensure_domain_fqdn_table(con: duckdb.DuckDBPyConnection, schema: str) -> None:
"""Create domain_fqdn_by_sid empty-but-schema-complete if it does not exist yet.
Same contract _derive_ad_props gives ad_props: the LEFT JOIN in _stamp_sharphound_name
must always bind. Without this, a caller that runs one node builder without the full
transforms() pipeline (every node-builder unit test does exactly that) hits a missing
table, _safe skips the stamp, and the node table ends up with no sharphound_name column
at all -- so the models read None and emit unnamed nodes, which is the very failure this
is meant to remove. An empty map instead yields a NULL FQDN, which the stamp already
handles as "unknown domain".
"""
con.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
con.execute(
f"CREATE TABLE IF NOT EXISTS {schema}.domain_fqdn_by_sid "
"(domain_sid VARCHAR, fqdn VARCHAR)"
)
def _domain_fqdn_by_sid(con: duckdb.DuckDBPyConnection, schema: str) -> None:
"""Build sccm.domain_fqdn_by_sid: domain SID -> uppercase domain FQDN.
Inverts the idiom the node builders already use for environmentid (a co-occurring
principal supplies the domain context a SID lacks) to recover the *FQDN*, which is
what SharpHound-format names need.
Why it's needed: a principal can enter the graph from a non-LDAP direction and so
carry a domain SID but no domain name. The SCCM site database's SQL service account
is the standard case -- it arrives via mssql_server_instances with a bare account
name and a SID, and is never passed through resolve_principal, so ad_props has no
row for it. Its SID nevertheless shares the domain prefix of every LDAP-resolved
principal, so the FQDN is recoverable.
Only rows whose domain looks like an FQDN (contains a dot) are used, so a NetBIOS
name can never be mistaken for one. mode() picks the most common FQDN per domain SID
so a single odd row cannot flip the mapping, and the result is deterministic.
"""
con.execute(
f"CREATE OR REPLACE TABLE {schema}.domain_fqdn_by_sid AS "
f"SELECT {_domain_sid_of('sid')} AS domain_sid, "
f" mode(upper(domain)) AS fqdn "
f"FROM {schema}.ad_props "
f"WHERE domain IS NOT NULL AND contains(domain, '.') "
f" AND {_domain_sid_of('sid')} IS NOT NULL "
f"GROUP BY 1"
)
cnt = _scalar(con, f"SELECT count(*) FROM {schema}.domain_fqdn_by_sid")
if cnt:
logger.info("domain_fqdn_by_sid: mapped %d domain SID(s) to an FQDN", cnt)
else:
logger.warning(
"domain_fqdn_by_sid is empty: no LDAP-resolved principal carried a domain "
"FQDN, so AD nodes cannot be given SharpHound-format names and will be "
"emitted without a name (BloodHound will display their object id)."
)
# Local part of a principal name: drop any DOMAIN\ prefix, uppercase. SCCM's
# SecurityGroupName arrives as 'mayyhem\Domain Admins', LDAP's samAccountName as
# 'Domain Admins'; both must reduce to the same 'DOMAIN ADMINS'.
def _local_part(col: str) -> str:
return f"upper(regexp_replace({col}, '^.*\\\\', ''))"
def _fqdn_from_dn(col: str) -> str:
"""SQL expression rebuilding a domain FQDN from a DN's DC= components.
'CN=System Management,CN=System,DC=mayyhem,DC=com' -> 'MAYYHEM.COM'. Preferred over
the domain_fqdn_by_sid map when a DN is present: it needs no co-occurring principal
and stays correct in a multi-domain forest where the map may not cover every domain.
"""
return (
f"nullif(upper(array_to_string("
f"regexp_extract_all({col}, 'DC=([^,]+)', 1), '.')), '')"
)
def _stamp_sharphound_name(
con: duckdb.DuckDBPyConnection,
schema: str,
table: str,
kind: str,
*,
sid_col: str = "sid",
fallback_domain_sid_col: str | None = None,
) -> None:
"""Add a `sharphound_name` column holding this row's name in SharpHound's convention.
These nodes ship in the untagged AD payload (ARCHITECTURE 11f) so they merge into
BloodHound's native AD graph by id -- which means whatever `name` we emit *overwrites*
SharpHound's label on the merged node. Matching SharpHound's own format is therefore
what keeps a merged graph stable, and it is why the column is NULL rather than a bare
or DOMAIN\\-prefixed name when the form can't be built: the models omit a null name,
convert prunes it, and BloodHound falls back to the object id, leaving any
SharpHound-collected label untouched. Ope-15m7.
Formats, all uppercase:
* user, group -- SAMACCOUNTNAME@DOMAIN.FQDN
* computer -- HOSTNAME.DOMAIN.FQDN (the dNSHostName)
* container -- NAME@DOMAIN.FQDN
The domain FQDN is resolved in decreasing order of directness: the row's own `domain`
column, then its DN's DC= components, then the domain_fqdn_by_sid map keyed on the
domain portion of its SID. A name already in SharpHound form is passed through
unchanged, so the synthetic Authenticated Users node (which is built in that form
directly, and whose well-known SID has no domain part) survives this stamp.