Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
2229 lines (1950 loc) · 105 KB
/
Copy pathmain.py
File metadata and controls
2229 lines (1950 loc) · 105 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
import copy
import datetime
import importlib.metadata
import duckdb
import logging
import os
import pathlib
import platform
import shutil
import socket
import sys
import threading
import time
import traceback as _traceback
import types
from enum import Enum
import typer
from openhound.cli.collect import collect as _collect_typer # noqa: E402
from openhound.cli.convert import convert as _convert_typer # noqa: E402
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Protocol, Sequence, cast
from openhound.core.app import (
Contract,
DEFAULT_LOOKUP_FILE,
InputPath,
OpenHound,
OutputPath,
)
from openhound.core.collect import CollectContext, Collector
from openhound.core.convert import ConvertContext, Converter, Method
from openhound.core.preproc import PreProcContext
from openhound.core.progress import Progress
from dlt.common.pipeline import LoadInfo
import dlt
from .convert_pipeline import emit_graph_from_duckdb
from .lookup import SCCMLookup
from .log_context import get_logger
from .models.computer import ComputerNode
from .models.container import ContainerNode
from .models.group import GroupNode
from .models.graph_edge import GraphEdge
from .models.mssql_database import MSSQLDatabase
from .models.mssql_database_role import MSSQLDatabaseRole
from .models.mssql_database_user import MSSQLDatabaseUser
from .models.mssql_login import MSSQLLogin
from .models.mssql_server import MSSQLServer
from .models.mssql_server_role import MSSQLServerRole
from .models.sccm_admin_user import SCCMAdminUser
from .models.sccm_client_device import SCCMClientDevice
from .models.sccm_collection import SCCMCollection
from .models.sccm_security_role import SCCMSecurityRole
from .models.sccm_site import SCCMSite
from .models.stub_node import StubNode
from .models.user import UserNode
from .transforms import transforms
if TYPE_CHECKING:
# Type-only import: StagePaths annotates the --run-all output-summary helpers.
# The runtime import stays deferred inside the functions so importing this
# module never pulls the shared library in at import time.
from openhound_collector_common.orchestration import StagePaths
# Type-only import: ProxyConfig annotates the --proxy helpers below.
# The runtime import stays deferred inside _parse_proxy_or_exit for the same reason.
from openhound_collector_common.proxy import ProxyConfig
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Progress backend selection (`--progress`)
# ---------------------------------------------------------------------------
# dlt draws a live counter for every resource it extracts (the
# `adminservice_r_system: 69it [...]` lines). Those redraws smear into the
# collector's own [target][phase] INFO/VERBOSE records, so the default here is
# "off": no dlt progress at all, leaving only our structured logs. tqdm / log /
# alive_progress stay available as opt-ins for anyone who wants a live bar.
#
# The framework's core Progress enum (off-limits to edit) has no "off" member,
# and core's Collector always forwards `progress.value` to
# `dlt.pipeline(progress=...)`. dlt maps a `None` progress arg to its no-op
# NULL_COLLECTOR, so we express "off" as a tiny stand-in whose `.value` is None
# rather than touching OpenHound core.
# ---------------------------------------------------------------------------
class IntegrationPrivilege(str, Enum):
"""Which fixture set --run-integration-tests asserts.
Describes the COLLECTION, not the fixtures. `auto` derives it from how much the
run actually collected; the two explicit values exist for the case auto cannot
see -- a partially-privileged run, where AdminService reached one site and not
another, looks privileged by row count but should often be asserted as low.
"""
auto = "auto"
high = "high"
low = "low"
# AdminService and WMI are the two privileged per-host transports. The prefixes are
# matched with startswith, never a substring test: local_wmi_sms_authority,
# local_wmi_sms_lookupmp and local_wmi_ccm_client are DISCOVERY resources reading
# WMI on the collector host itself, which any domain user can do.
_PRIVILEGED_TABLE_PREFIXES = ("adminservice_", "wmi_")
class ProgressOption(str, Enum):
off = "off"
tqdm = "tqdm"
log = "log"
alive_progress = "alive_progress"
class _SilentProgress:
"""Progress stand-in that disables dlt's progress output entirely.
Collector reads only `.value` and hands it to `dlt.pipeline(progress=...)`;
`None` resolves to dlt's NULL_COLLECTOR (no bars, no periodic log dumps).
"""
value = None
def _resolve_progress(choice: ProgressOption):
"""Translate a `--progress` choice into what core's Collector expects.
'off' -> silent stand-in (dlt NULL_COLLECTOR); anything else -> the matching
core Progress enum member (tqdm / log / alive_progress).
"""
if choice is ProgressOption.off:
logger.debug("Progress output disabled (--progress off); using dlt NULL_COLLECTOR")
return _SilentProgress()
# A real backend was explicitly requested; hand core the matching enum member.
logger.debug("Progress backend selected: %s", choice.value)
return Progress(choice.value)
# ---------------------------------------------------------------------------
# OpenHound app instance. Still owns `source_kind` (flows into the OpenGraph
# metadata block) plus the asset registry that ``Converter.run`` reads. The
# `@app.collect/@app.preproc/@app.convert` convenience decorators are
# intentionally *not* used here — we register richer Typer commands on the
# framework's public Typer groups directly so we can add CLI options/flags.
# ---------------------------------------------------------------------------
app = OpenHound(
"sccm",
# source_kind tags every emitted node/edge as belonging to the SCCM data source in
# BloodHound (used for source-scoped re-ingest/deletion). Was the Stage-0 spike
# placeholder "Kind"; set to the real collector source name.
source_kind="SCCM",
help="OpenGraph collector for sccm"
)
def _show_version(ctx: typer.Context, value: bool) -> None:
if value and not ctx.resilient_parsing:
# No collection or logging session exists for this metadata-only command.
typer.echo(f"ConfigManBearPig {importlib.metadata.version('configmanbearpig')}")
raise typer.Exit()
# ---------------------------------------------------------------------------
# Flag → env-var translation
# ---------------------------------------------------------------------------
# Every CMBP-style flag on `collect` / `preprocess` / `convert` maps to a
# ``SOURCES__SCCM__*`` env var. The Typer command sets the env var BEFORE the
# DLT source factory resolves its `dlt.config.value` parameters, so the user
# can supply config equivalently via CLI flag, env var, or `.env` file. Flag
# values win because they're applied last (just before the framework's
# Collector/Converter/PreProcessor is constructed).
# ---------------------------------------------------------------------------
_FLAG_TO_ENV: dict[str, str] = {
# Connection
"domain": "SOURCES__SCCM__DOMAIN",
"domain_controller": "SOURCES__SCCM__DOMAIN_CONTROLLER",
"username": "SOURCES__SCCM__USERNAME",
"password": "SOURCES__SCCM__PASSWORD",
"nt_hash": "SOURCES__SCCM__NT_HASH",
"kerberos_ticket": "SOURCES__SCCM__KERBEROS_TICKET",
"ldap_port": "SOURCES__SCCM__LDAP_PORT",
# Collection
"collection_methods": "SOURCES__SCCM__COLLECTION_METHODS",
"computers": "SOURCES__SCCM__COMPUTERS",
"computer_file": "SOURCES__SCCM__COMPUTER_FILE",
"site_codes": "SOURCES__SCCM__SITE_CODES",
# Behavior
"disable_possible_edges": "SOURCES__SCCM__DISABLE_POSSIBLE_EDGES",
"enable_bad_opsec": "SOURCES__SCCM__ENABLE_BAD_OPSEC",
"threads": "SOURCES__SCCM__THREADS",
"show_cleartext_passwords": "SOURCES__SCCM__SHOW_CLEARTEXT_PASSWORDS",
# Network
"socks_proxy": "SOURCES__SCCM__SOCKS_PROXY",
"http_proxy": "SOURCES__SCCM__HTTP_PROXY_CONFIG",
# DNS
"dns_resolver": "SOURCES__SCCM__DNS_RESOLVER",
}
_TYPED_DLT_ENV = {
"SOURCES__SCCM__LDAP_PORT",
"SOURCES__SCCM__THREADS",
"SOURCES__SCCM__DISABLE_POSSIBLE_EDGES",
"SOURCES__SCCM__ENABLE_BAD_OPSEC",
"SOURCES__SCCM__SHOW_CLEARTEXT_PASSWORDS",
}
def _drop_empty_dlt_env_values() -> None:
for env_name in _FLAG_TO_ENV.values():
if os.environ.get(env_name) == "":
os.environ.pop(env_name, None)
for env_name in _TYPED_DLT_ENV:
value = os.environ.get(env_name)
if value is not None and value.strip() == "":
os.environ.pop(env_name, None)
_SHORT_OPTIONS_WITH_VALUES: dict[str, str] = {
"-d": "--domain",
"-u": "--username",
"-p": "--password",
"-m": "--collection-methods",
"-c": "--computers",
"-t": "--threads",
"-x": "--proxy",
}
_LONG_OPTIONS_WITH_VALUES: set[str] = {
"--progress",
"--tables",
"--columns",
"--data-type",
"--domain",
"--dc",
"--domain-controller",
"--username",
"--password",
"--nt-hash",
"--ticket",
"--ldap-port",
"--collection-methods",
"--computers",
"--cf",
"--computer-file",
"--sc",
"--site-codes",
"--threads",
"--proxy",
"--http-proxy",
"--dns",
"--dns-resolver",
}
_SENSITIVE_OPTIONS: set[str] = {
"-p",
"--password",
"--machine-pass",
"--nt-hash",
"--ticket",
# A proxy address can embed credentials (socks5://user:pass@host:port),
# so mask it in the split-value warnings just like a password.
"-x",
"--proxy",
"--http-proxy",
}
def _display_cli_value(option: str, value: str) -> str:
if option in _SENSITIVE_OPTIONS:
return "<value>"
return value
def _suspicious_cli_argument_warnings(argv: Sequence[str]) -> list[str]:
"""Build warnings for short-option attachments that leave a value behind.
Click accepts ``-dc`` as ``-d c`` because ``-d`` takes a value. That means
typos like ``-dc 10.2.10.100`` make it through parsing and silently turn
the intended domain-controller value into a positional argument.
"""
warnings: list[str] = []
for index, token in enumerate(argv):
if not token.startswith("-") or token.startswith("--") or token == "-":
continue
next_token = argv[index + 1] if index + 1 < len(argv) else None
if next_token is None or next_token.startswith("-"):
continue
one_dash_long = f"-{token}"
if one_dash_long in _LONG_OPTIONS_WITH_VALUES:
value = _display_cli_value(one_dash_long, next_token)
warnings.append(
f'Suspicious CLI option "{token} {value}" was parsed as a '
"short option with an attached value, leaving the next value "
f'as a separate argument. Did you mean "{one_dash_long} {value}"?'
)
continue
for short_option in _SHORT_OPTIONS_WITH_VALUES:
if not token.startswith(short_option) or token == short_option:
continue
attached_value = token[len(short_option):]
if len(attached_value) > 2:
break
canonical = _SHORT_OPTIONS_WITH_VALUES[short_option]
value = _display_cli_value(short_option, f"{attached_value} {next_token}")
if short_option in _SENSITIVE_OPTIONS:
warnings.append(
f'Suspicious CLI option "{short_option}" has an attached '
"value followed by another value token. Did you mean to "
f'quote the {canonical} value, for example "{short_option} {value}"?'
)
else:
warnings.append(
f'Suspicious CLI option "{token} {next_token}" looks like '
f'a split value for {canonical}. Did you mean '
f'"{short_option} {value}"?'
)
break
return warnings
def _warn_for_suspicious_cli_arguments(argv: Sequence[str] | None = None) -> None:
args = sys.argv[1:] if argv is None else argv
for warning in _suspicious_cli_argument_warnings(args):
logger.warning(warning)
def _apply_env_overrides(flag_kwargs: dict) -> None:
"""Map CLI option/flag values to ``SOURCES__SCCM__*`` env vars.
Skips values that are ``None`` or default-``False`` so a flag that wasn't
passed doesn't overwrite a higher-priority env-var or ``.env`` entry.
Booleans serialise as ``"true"`` / ``"false"`` (lowercased); paths
serialise via ``str()``.
"""
for flag_name, env_name in _FLAG_TO_ENV.items():
if flag_name not in flag_kwargs:
continue
value = flag_kwargs[flag_name]
if value is None:
continue
if isinstance(value, bool):
if not value:
continue
os.environ[env_name] = "true"
elif isinstance(value, pathlib.Path):
os.environ[env_name] = str(value)
else:
os.environ[env_name] = str(value)
def _resolve_dc_only_methods(dc_only: bool, collection_methods: Optional[str]) -> Optional[str]:
"""Resolve the effective --collection-methods for a run, honoring --dc-only.
--dc-only is a recon mode: collect only LDAP + DNS from the domain controller
and skip every per-host phase. It forces the method set to "LDAP,DNS". Because
both flags decide *what* gets collected, passing them together is a
contradiction we reject up front rather than silently pick a winner.
"""
if not dc_only:
# Normal run: leave the operator's -m (or None -> "All" later) untouched.
return collection_methods
if collection_methods is not None:
# Explicit -m alongside --dc-only: the operator asked for two different scopes.
raise typer.BadParameter(
"--dc-only and -m/--collection-methods are mutually exclusive; "
"--dc-only already restricts collection to LDAP + DNS."
)
return "LDAP,DNS"
def _should_run_per_host(ctx, phases, dc_only: bool) -> bool:
"""Whether Stage 2 (per-host probing) runs this collect.
It runs only when discovery produced a usable context and there are phases to
run — and never in --dc-only recon mode, which stops after LDAP + DNS discovery
against the domain controller.
"""
return bool(ctx is not None and phases) and not dc_only
# A level above CRITICAL (50) that no record can ever reach — used to mute a
# console handler without detaching it. Reversible and in keeping with this
# extension's "mutate live handler instances" approach (see ARCHITECTURE §7).
_CONSOLE_MUTE_LEVEL = logging.CRITICAL + 1
def _is_console_handler(handler: logging.Handler) -> bool:
"""True for handlers that write to the terminal — the framework's
``RichHandler`` (CLI mode) or its stdout ``StreamHandler`` (container mode) —
and False for on-disk sinks (the rotating JSON file, plus this extension's
``_DiagnosticFileHandler`` / ``_OrderedLogFileHandler``).
``FileHandler`` is a ``StreamHandler`` subclass, so it must be excluded first.
``RichHandler`` is *not* a ``StreamHandler``, so it's matched by duck-typing
on its ``.console`` attribute (avoids importing rich just to isinstance it).
"""
if isinstance(handler, logging.FileHandler):
return False
return isinstance(handler, logging.StreamHandler) or hasattr(handler, "console")
def _silence_console_handlers() -> None:
"""Mute every console handler on the root / ``dlt`` / ``openhound`` loggers by
raising it above CRITICAL. File handlers are left untouched, so ``--silent``
gags the terminal while the on-disk logs keep recording."""
for logger_name in ("", "dlt", "openhound"):
for handler in logging.getLogger(logger_name).handlers:
if _is_console_handler(handler):
logger.debug("Silencing console handler %r (--silent)", handler)
handler.setLevel(_CONSOLE_MUTE_LEVEL)
def _apply_log_level(verbose: bool, debug: bool, silent: bool) -> None:
"""Set the console log level from the verbosity flags — or mute the console
entirely with ``--silent`` — and install the ``[target][phase]`` prefix filter.
(no flags) → INFO (collection-step summaries)
``-v`` → VERBOSE (PS1 ``[Verbose]`` parity tier: per-AD-resolution,
per-node-add, per-edge dedupe traces)
``--debug`` → DEBUG (everything, including dlt and ldap3 internals)
``--debug`` outranks ``-v``. ``--silent`` silences the *console only*: the
console handlers are raised above CRITICAL so nothing prints, while the root
logger stays at the requested detail level so the on-disk logs
(``collect_full_*`` / ``collect_issues_*``) keep recording. ``--silent``
therefore composes with ``-v`` / ``--debug`` — e.g. ``--silent --debug`` is a
quiet terminal with DEBUG-level file logs. (The full log always captures the
collector's own DEBUG regardless; ``--debug`` additionally adds dlt/ldap3.)
The framework's default config uses a ``RichHandler`` (or stdout
``StreamHandler`` in container mode) wired up by
``openhound/core/logging.py``. We keep those handlers in place — the
user prefers the framework's ``time=…, msg=…`` format — and only swap
the formatter to drop the trailing ``(openhound_version=…)`` suffix
that ``OpenHoundRichFormatter`` appends to every line.
"""
from .log_context import VERBOSE, install_filter
if debug:
level_name, level = "DEBUG", logging.DEBUG
elif verbose:
level_name, level = "VERBOSE", VERBOSE
else:
level_name, level = "INFO", logging.INFO
os.environ["RUNTIME__LOG_LEVEL"] = level_name
os.environ["RUNTIME__LOG_CLI_LEVEL"] = level_name
root = logging.getLogger()
# Lower the root logger so records of the requested level can reach any
# handler. This is also what keeps --silent's on-disk logs at full detail:
# only the console handlers get muted below, never the root logger. Existing
# file handlers keep their own level.
if root.level == 0 or root.level > level:
root.setLevel(level)
if silent:
# Console-only mute — see _silence_console_handlers. Skip the level-lowering
# loop below entirely so the terminal handlers stay muted.
_silence_console_handlers()
else:
for log in (root, logging.getLogger("dlt")):
for handler in log.handlers:
if handler.level == 0 or handler.level > level:
handler.setLevel(level)
# The OpenHound framework's CLI handler uses ``OpenHoundRichFormatter``
# (see ``openhound/core/logging.py:170``) which appends
# `` (openhound_version=<v>)`` to every line. Swap the formatter
# for one that produces the same ``time=…, msg=…`` shape minus
# that suffix — keeps the framework's preferred format without
# touching OpenHound code. The JSON file handler keeps its own
# formatter so the structured log is untouched.
_strip_version_suffix_from_handlers()
install_filter()
# Hard-frozen to the framework's current timestamp format. We identify the
# formatter to swap by class name, so format divergence is a visible signal.
_LOG_TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S"
class _NoVersionRichFormatter(logging.Formatter):
"""Mirror of ``openhound.core.logging.OpenHoundRichFormatter`` minus the
trailing ``(openhound_version=…)`` suffix. Used by ``_apply_log_level``
to swap the framework's CLI formatter in place."""
def format(self, record: logging.LogRecord) -> str:
return f"time={self.formatTime(record, _LOG_TIMESTAMP_FMT)}, msg={record.getMessage()}"
def _strip_version_suffix_from_handlers() -> None:
"""Replace any ``OpenHoundRichFormatter`` instance on console handlers
with ``_NoVersionRichFormatter``. Identifies by class name to avoid
importing the framework symbol (which would tie us to its module layout).
Idempotent: re-running it on already-swapped handlers is a no-op.
"""
seen_loggers = (
logging.getLogger(),
logging.getLogger("dlt"),
logging.getLogger("openhound"),
)
replacement = _NoVersionRichFormatter()
for log in seen_loggers:
for handler in log.handlers:
fmt = handler.formatter
if fmt is None:
continue
if type(fmt).__name__ == "OpenHoundRichFormatter":
handler.setFormatter(replacement)
# ---------------------------------------------------------------------------
# Windows-safe core log rotation
# ---------------------------------------------------------------------------
# OpenHound core attaches a TimedRotatingFileHandler (``when="midnight"``) to
# BOTH the root and ``dlt`` loggers, each pointed at the same ``openhound.log``
# (see ``openhound/core/logging.py``). The first record after midnight fires a
# rollover whose ``os.rename(openhound.log -> openhound.log.<date>)`` fails on
# Windows with ``WinError 32``, because the sibling handler still holds the
# file open — so core's daily rotation has never worked on Windows. We cannot
# edit core, so we mutate the live handler instances from our side:
#
# 1. Repoint each to a per-run timestamped file so every run owns a distinct
# log and a stale rotated file never collides with a fresh rename target.
# 2. Replace ``doRollover`` with a copy+truncate that never renames, so a run
# crossing midnight (or tripping the size cap) rotates without needing
# exclusive access to the open file.
class _RotatingHandler(Protocol):
"""The subset of core's ``RotatingFileHandler`` that the Windows fix below reaches into.
Written as a Protocol rather than an import of the class, because the handler belongs
to openhound core, is attached to the root logger before this module runs, and is
identified by class *name* — a duck-type check, since core's class is not a stdlib
``RotatingFileHandler`` and `isinstance` against the stdlib one would not match. So
there is no concrete type available to annotate against.
Spelling the contract out has a second benefit beyond satisfying the type checker: it
is the only place a reader can see exactly which attributes — two of them private —
this fix depends on, and therefore what a core upgrade could silently break.
"""
stream: Any
baseFilename: str
rolloverAt: int
# A settable attribute, not a method: the fix replaces it with a bound MethodType.
doRollover: Callable[[], None]
def computeRollover(self, current_time: int) -> int: ...
def _open(self) -> Any: ...
def acquire(self) -> None: ...
def release(self) -> None: ...
def _copytruncate_rollover(self: _RotatingHandler) -> None:
"""Windows-safe ``doRollover`` for core's ``RotatingFileHandler``.
Copies the live log to a dated sibling, then truncates it in place rather
than renaming it — so the sibling handler's open handle stays valid and
Windows never raises ``WinError 32``. Mirrors core's suffix scheme: a date
for time-based rollovers, date + time for size-triggered ones.
"""
self.acquire()
try:
if self.stream:
self.stream.close()
self.stream = None
if getattr(self, "_size_triggered", False):
stamp = time.strftime("%Y-%m-%d_%H-%M-%S")
else:
stamp = time.strftime("%Y-%m-%d")
src = pathlib.Path(self.baseFilename)
if src.exists():
shutil.copy2(src, src.with_name(f"{src.name}.{stamp}"))
open(src, "w").close() # truncate in place; keeps path + handle valid
self.rolloverAt = self.computeRollover(int(time.time()))
finally:
self.release()
def _make_core_rotation_windows_safe() -> None:
"""Neutralize the Windows-broken daily rotation in core's log handlers.
See the section comment above. No-op off Windows, where core's
rename-based rollover works correctly. Runs once at module import, after
core has already attached its handlers, so it covers every CLI subcommand.
"""
if platform.system() != "Windows":
return
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
for log in (logging.getLogger(), logging.getLogger("dlt")):
for raw_handler in log.handlers:
if type(raw_handler).__name__ != "RotatingFileHandler":
continue
# The name check above IS the type narrowing; a cast is how it reaches mypy,
# since core's class is not the stdlib RotatingFileHandler.
handler = cast(_RotatingHandler, raw_handler)
base = pathlib.Path(handler.baseFilename)
handler.acquire()
try:
if handler.stream:
handler.stream.close()
handler.baseFilename = str(base.with_name(f"{base.stem}_{ts}{base.suffix}"))
handler.rolloverAt = handler.computeRollover(int(time.time()))
handler.doRollover = types.MethodType(_copytruncate_rollover, handler)
# Open the new file now (core constructs with delay=False). Core's
# shouldRollover calls os.path.getsize(baseFilename) on every record,
# so the file must exist before the first log line.
handler.stream = handler._open()
finally:
handler.release()
_make_core_rotation_windows_safe()
def _detect_windows_domain() -> Optional[str]:
"""Derive the AD domain from the current Windows user context.
Mirrors ``ConfigManBearPig.ps1``'s order:
1. ``$env:USERDNSDOMAIN`` (set by the LSA at logon for a domain-joined
session).
2. DNS suffix of ``socket.getfqdn()`` (the computer's domain), used as
a fallback when USERDNSDOMAIN is missing.
Returns ``None`` on non-Windows or when discovery fails — callers should
treat the field as still-unset and surface a clear required-flag error.
"""
if platform.system() != "Windows":
return None
domain = os.environ.get("USERDNSDOMAIN")
if not domain:
fqdn = socket.getfqdn()
if "." in fqdn:
domain = fqdn.split(".", 1)[1]
if not domain:
return None
return domain.strip().rstrip(".").lower()
def _resolve_dc_via_dns(domain: str, dns_resolver: Optional[str] = None) -> Optional[str]:
"""Resolve a domain controller FQDN from the domain via DNS SRV.
Looks up ``_ldap._tcp.dc._msdcs.<domain>`` — the path .NET's
``Domain.FindDomainController()`` ultimately takes via DC Locator.
Cross-platform: works wherever the host has DNS reachability to the AD
DNS zone, not just Windows.
The resolver is built via the shared ``discovery.dns.make_resolver`` (explicit
nameserver when ``dns_resolver`` is set, else the host's configured resolvers).
SCCM keeps its own SRV query here — ``_ldap._tcp.dc._msdcs.<domain>`` is the
precise DC-Locator record (.NET ``Domain.FindDomainController``), narrower than
the shared ``resolve_dc``'s general ``_ldap._tcp.<domain>``.
"""
try:
from openhound_collector_common.discovery.dns import make_resolver
from openhound_collector_common.proxy import active_proxy
resolver = make_resolver(dns_resolver, lifetime=5, force_tcp=active_proxy() is not None)
answers = resolver.resolve(f"_ldap._tcp.dc._msdcs.{domain}", "SRV")
srvs = sorted(answers, key=lambda r: (r.priority, -r.weight))
if srvs:
return str(srvs[0].target).rstrip(".")
except Exception as exc: # dnspython errors, timeouts, no SRV records
logger.warning("DNS SRV lookup for domain controller failed: %s", exc)
return None
def _apply_connection_context(flag_kwargs: dict) -> None:
"""Backfill domain (Windows current-user context) and domain controller
(DNS SRV from the resolved domain) when those values weren't supplied
via flag or env.
Domain auto-detection is Windows-only — Linux/macOS users must pass
``-d`` / ``--domain`` explicitly. DC resolution is cross-platform: as
long as the domain is known (from flag, env, or Windows auto-detect),
we try to resolve the DC via DNS SRV before failing.
"""
has_domain = bool(flag_kwargs.get("domain")) or bool(os.environ.get("SOURCES__SCCM__DOMAIN"))
has_dc = bool(flag_kwargs.get("domain_controller")) or bool(
os.environ.get("SOURCES__SCCM__DOMAIN_CONTROLLER")
)
if not has_domain:
domain = _detect_windows_domain()
if domain:
os.environ["SOURCES__SCCM__DOMAIN"] = domain
logger.info("Auto-detected domain from current user context: %s", domain)
has_domain = True
if has_domain and not has_dc:
# Prefer flag value, then env (already set above if auto-detected).
domain = (
flag_kwargs.get("domain")
or os.environ.get("SOURCES__SCCM__DOMAIN")
or ""
).strip().rstrip(".").lower()
if domain:
logger.verbose("No domain controller (--dc) provided, trying to find one via DNS")
dc = _resolve_dc_via_dns(domain, dns_resolver=flag_kwargs.get("dns_resolver"))
if dc:
os.environ["SOURCES__SCCM__DOMAIN_CONTROLLER"] = dc
logger.info("Resolved domain controller via DNS SRV: %s", dc)
else:
logger.error("Could not identify a domain controller via DNS. Try specifying a domain controller FQDN or IP with the --dc option.")
sys.exit(1)
def _require_domain_or_explain(flag_kwargs: dict) -> None:
"""Fail fast with a clear message if ``domain`` is still unresolved.
On Linux/macOS this is the dominant failure mode (no Windows current-user
context to fall back on). Surfacing it before dlt's config-resolver fires
avoids the noisy ``ConfigFieldMissingException`` traceback.
"""
if flag_kwargs.get("domain") or os.environ.get("SOURCES__SCCM__DOMAIN"):
return
if platform.system() == "Windows":
msg = (
"Could not auto-detect the Active Directory domain from the current "
"user context (USERDNSDOMAIN unset and FQDN has no DNS suffix). "
"Pass -d / --domain explicitly."
)
else:
msg = (
"Running on a non-Windows host: -d / --domain is required "
"(auto-detection of the current user's domain context is Windows-only). "
"--dc / --domain-controller will be resolved from the domain via DNS SRV "
"if omitted. -u / --username and -p / --password are also required for "
"any phase that needs AD/SCCM auth."
)
raise typer.BadParameter(msg, param_hint="--domain")
def _parse_proxy_or_exit(socks_proxy: Optional[str]) -> Optional["ProxyConfig"]:
"""Parse --proxy into a ProxyConfig, or exit(2) with a clear error."""
from openhound_collector_common.proxy import SocksError, parse_proxy_address
if not socks_proxy:
return None
try:
cfg = parse_proxy_address(socks_proxy)
logger.info("SOCKS5 proxy configured: %s:%s", cfg.host, cfg.port)
return cfg
except SocksError as ex:
logger.error("Invalid --proxy value %r: %s", socks_proxy, ex)
raise typer.Exit(2)
def _parse_http_proxy_or_exit(value: Optional[str]) -> Optional[str]:
"""Validate the HTTP-only proxy option before making any network calls."""
from .clients.http import parse_http_proxy
try:
proxy = parse_http_proxy(value)
except ValueError as ex:
logger.error("Invalid --http-proxy: %s", ex)
raise typer.Exit(2) from ex
if proxy == "auto":
logger.info("HTTP proxy: auto (environment or Windows user settings)")
elif proxy:
logger.info("HTTP proxy configured: %s", proxy.rsplit("@", 1)[-1].removeprefix("http://"))
return proxy
def _require_dc_or_dns_for_proxy(flag_kwargs: dict, proxy: Optional["ProxyConfig"]) -> None:
"""Under a proxy, we can't resolve internal names locally, so demand a pin."""
if proxy is None:
return # direct mode: nothing to enforce
if flag_kwargs.get("domain_controller") or flag_kwargs.get("dns_resolver"):
logger.debug("_require_dc_or_dns_for_proxy: DC/DNS pin present; ok")
return
logger.error(
"--proxy requires --dc <ip/host> or --dns <internal-resolver-ip>: "
"target names can't be resolved from the outside box under a pivot."
)
raise typer.Exit(2)
class _ImpacketNoiseFilter(logging.Filter):
"""Demote impacket's benign "no Kerberos credential cache" CRITICAL to DEBUG.
impacket's ``CCache.parseFile`` reads the ``KRB5CCNAME`` environment variable
to find a Kerberos credential cache. That variable is a Unix convention and is
essentially never set on Windows, so on every Windows run the WMI Kerberos rung
takes without ``--ticket``, impacket finds no cache, logs
``CRITICAL: CCache file is not found. Skipping...`` -- and then goes on to
request a fresh TGT with the supplied password or hash and succeed. Nothing is
skipped that we wanted; the word "Skipping" refers only to the cache lookup.
It fires once per host (nine CRITICALs in a nine-host lab run), it is the only
CRITICAL the collector ever shows, and an operator reasonably reads a red
CRITICAL as "collection is broken". So the record's level is rewritten in
place: handlers compare ``record.levelno`` when deciding what to emit, and the
``impacket`` logger has no handlers of its own (it propagates to root), so
lowering the level here is enough to keep it off the console and out of
``collect_issues_<ts>.log`` while ``--debug`` still shows it.
Deliberately matched on the one message rather than by capping the whole
``impacket`` logger: any *other* impacket CRITICAL still reaches the operator
at full volume. ``wmi.py``'s Kerberos rung logs its own verbose line saying
what actually happened, so the log explains itself rather than going silent.
"""
# Substring, not the whole line: impacket's wording around it has changed
# across releases, and this much has been stable.
BENIGN_MESSAGE = "CCache file is not found"
def filter(self, record: logging.LogRecord) -> bool:
if record.levelno > logging.DEBUG and self.BENIGN_MESSAGE in record.getMessage():
record.levelno = logging.DEBUG
record.levelname = "DEBUG"
return True # always keep the record; only its level changes
class _DiagnosticFileHandler(logging.FileHandler):
"""Writes WARNING+ records with full traceback to a per-run diagnostics file.
Attached to the root logger for the lifetime of ``collect_sccm``. Uses
``delay=True`` so the file is only created when at least one record is
emitted. Injects ``sys.exc_info()`` for its own formatting when the record
has no traceback attached, then restores both ``record.exc_info`` and
``record.exc_text`` (the formatter's cached string) so the console handler
is never affected.
"""
def __init__(self, path: pathlib.Path) -> None:
super().__init__(str(path), mode="w", encoding="utf-8", delay=True)
self.setLevel(logging.DEBUG)
self.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
))
self.warning_count = 0
self.error_count = 0
def emit(self, record: logging.LogRecord) -> None:
if record.levelno == logging.DEBUG:
# Only capture companion debug lines emitted from inside an except block.
# The purpose is to preserve contextual data (e.g. the raw entry that failed)
# alongside the WARNING/ERROR that precedes it in the file.
if sys.exc_info()[0] is None:
return
super().emit(record)
return
if record.levelno < logging.WARNING:
return # drop INFO / VERBOSE / etc.
if record.levelno >= logging.ERROR:
self.error_count += 1
else:
self.warning_count += 1
injected = False
if not record.exc_info:
exc = sys.exc_info()
if exc[0] is not None:
record.exc_info = exc
injected = True
super().emit(record)
if injected:
# None, not False: both are falsy so formatters skip the traceback either way,
# but None is the sentinel LogRecord.exc_info is actually declared to hold.
record.exc_info = None
record.exc_text = None # clear cached formatted traceback so console sees nothing
# ---------------------------------------------------------------------------
# Ordered-log file handler
# ---------------------------------------------------------------------------
# Buffers every log record in a per-resource dict keyed by the active
# _current_resource contextvar value. When a resource generator exhausts
# (signalled by the completion callback registered in gen_wrapper), the whole
# batch for that resource is appended to the file as a labelled section —
# giving human-readable, resource-sequential output regardless of how dlt
# interleaves the generators at runtime.
#
# Records that arrive outside any resource context (CLI setup, summary lines,
# etc.) are collected under the "__root__" key and flushed by flush_all().
# ---------------------------------------------------------------------------
_ORDERED_LEVEL_LABEL: dict[int, str] = {
logging.DEBUG: "DEBUG ",
logging.INFO: "INFO ",
logging.WARNING: "WARNING ",
logging.ERROR: "ERROR ",
logging.CRITICAL: "CRITICAL",
}
_ORDERED_TS_FMT = "%Y-%m-%d %H:%M:%S"
class _OrderedLogFileHandler(logging.Handler):
"""Buffers log records per resource; flushes each resource's batch to a
file in completion order when notified via flush_resource().
Register flush_resource as a resource-complete callback::
register_resource_complete_callback(_handler.flush_resource)
Call close() (which calls flush_all()) before removing the handler to
drain any in-flight buffers (e.g. resources that errored before exhausting).
"""
def __init__(self, path: pathlib.Path, level: int = logging.NOTSET) -> None:
super().__init__(level=level)
self._path = path.resolve() # absolute so CWD changes don't affect later writes
self._buffers: dict[str, list] = {}
self._lock = threading.Lock()
self._write_lock = threading.Lock() # serialize block writes across worker threads
# Import once at construction time so the relative import never runs
# inside emit() (which fires on every log record).
from .log_context import get_current_resource, get_current_target
self._get_current_resource = get_current_resource
self._get_current_target = get_current_target
def _bucket_key(self) -> str:
"""Key the current record's block by resource if one is active (discovery
/ DLT resources), else by per-host target (worker-pool records), else the
catch-all root bucket."""
resource = self._get_current_resource()
if resource:
return resource
return self._get_current_target() or "__root__"
def emit(self, record: logging.LogRecord) -> None:
try:
key = self._bucket_key()
# Freeze the message string now so args (possibly mutable) are no
# longer needed when we format at flush time.
rec = copy.copy(record)
try:
rec.msg = record.getMessage()
except Exception:
rec.msg = str(record.msg)
rec.args = None
with self._lock:
self._buffers.setdefault(key, []).append(rec)
except Exception:
self.handleError(record) # writes traceback to sys.stderr
def flush_resource(self, resource_name: str) -> None:
"""Write *resource_name*'s buffered records to the file and clear the buffer.
Records are only removed from the buffer on a successful write so that
flush_all() can retry them if the output directory didn't exist yet
(dlt creates it lazily during the load phase, after extraction).
"""
with self._lock:
records = list(self._buffers.get(resource_name, []))
if records and self._write_section(resource_name, records):
with self._lock:
self._buffers.pop(resource_name, None)
def flush_host(self, hostname: str) -> None:
"""Flush a host's buffered records as one labelled block.
Registered as a host-completion callback; fires (possibly from several
worker threads at once) when a target finishes its full phase sequence.
Block writes are serialized by ``_write_section`` so concurrent host
flushes never interleave in the file.
"""
self.flush_resource(hostname)
def flush_all(self) -> None:
"""Flush every remaining buffer — called at handler close time."""
with self._lock:
remaining = list(self._buffers.items())
self._buffers.clear()
for resource_name, records in remaining:
if records:
self._write_section(resource_name, records)
def _write_section(self, resource_name: str, records: list) -> bool:
"""Write records to file. Returns True on success, False on failure."""
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = [f"\n{'=' * 72}\n# {resource_name}\n{'=' * 72}\n"]
for rec in records:
# Fall back to the registered level name (padded) for any level not
# in the table above — notably VERBOSE (15), which the full log now
# captures — so it renders "VERBOSE" rather than a bare "L15".
label = _ORDERED_LEVEL_LABEL.get(rec.levelno) or f"{logging.getLevelName(rec.levelno):<8}"
ts = datetime.datetime.fromtimestamp(rec.created).strftime(_ORDERED_TS_FMT)
lines.append(f"{label} time={ts}, msg={rec.msg}\n")
if rec.exc_info and rec.exc_info[0] is not None:
lines.append(
"".join(_traceback.format_exception(*rec.exc_info))
)
with self._write_lock:
with open(self._path, "a", encoding="utf-8") as fh:
fh.writelines(lines)
return True
except Exception:
return False
def close(self) -> None: