From 9dc7289652ae16e8a31414d99ce320c4759682e4 Mon Sep 17 00:00:00 2001 From: 0x000 <1+0x000@noreply.localhost> Date: Thu, 15 Oct 2026 12:34:26 -0400 Subject: [PATCH] Initial RPCScope release --- .gitignore | 22 + LICENSE | 21 + README.md | 187 ++++++++ THIRD_PARTY_NOTICES.md | 36 ++ docs/ARCHITECTURE.md | 77 +++ docs/CHECKER_POLICY.md | 55 +++ docs/NETEXEC_REVIEW.md | 73 +++ pyproject.toml | 26 + rpcscope/__init__.py | 4 + rpcscope/__main__.py | 4 + rpcscope/advisories.py | 234 +++++++++ rpcscope/catalog.py | 260 ++++++++++ rpcscope/checks.py | 132 +++++ rpcscope/cli.py | 229 +++++++++ rpcscope/longlogon.py | 350 ++++++++++++++ rpcscope/models.py | 174 +++++++ rpcscope/netexec.py | 169 +++++++ rpcscope/parsers.py | 263 ++++++++++ rpcscope/progress.py | 216 +++++++++ rpcscope/reporting.py | 246 ++++++++++ rpcscope/rules.py | 242 ++++++++++ rpcscope/runner.py | 719 ++++++++++++++++++++++++++++ rpcscope/storage.py | 55 +++ rpcscope/targets.py | 264 ++++++++++ tests/test_advisories.py | 147 ++++++ tests/test_batch_execution.py | 209 ++++++++ tests/test_checks.py | 111 +++++ tests/test_cli.py | 130 +++++ tests/test_discovery_integration.py | 295 ++++++++++++ tests/test_execution.py | 284 +++++++++++ tests/test_longlogon.py | 262 ++++++++++ tests/test_models.py | 65 +++ tests/test_netexec.py | 120 +++++ tests/test_new_checks.py | 106 ++++ tests/test_parsers.py | 188 ++++++++ tests/test_progress.py | 165 +++++++ tests/test_reporting.py | 148 ++++++ tests/test_rules.py | 104 ++++ tests/test_runner.py | 66 +++ tests/test_targets.py | 230 +++++++++ 40 files changed, 6688 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CHECKER_POLICY.md create mode 100644 docs/NETEXEC_REVIEW.md create mode 100644 pyproject.toml create mode 100644 rpcscope/__init__.py create mode 100644 rpcscope/__main__.py create mode 100644 rpcscope/advisories.py create mode 100644 rpcscope/catalog.py create mode 100644 rpcscope/checks.py create mode 100644 rpcscope/cli.py create mode 100644 rpcscope/longlogon.py create mode 100644 rpcscope/models.py create mode 100644 rpcscope/netexec.py create mode 100644 rpcscope/parsers.py create mode 100644 rpcscope/progress.py create mode 100644 rpcscope/reporting.py create mode 100644 rpcscope/rules.py create mode 100644 rpcscope/runner.py create mode 100644 rpcscope/storage.py create mode 100644 rpcscope/targets.py create mode 100644 tests/test_advisories.py create mode 100644 tests/test_batch_execution.py create mode 100644 tests/test_checks.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_discovery_integration.py create mode 100644 tests/test_execution.py create mode 100644 tests/test_longlogon.py create mode 100644 tests/test_models.py create mode 100644 tests/test_netexec.py create mode 100644 tests/test_new_checks.py create mode 100644 tests/test_parsers.py create mode 100644 tests/test_progress.py create mode 100644 tests/test_reporting.py create mode 100644 tests/test_rules.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_targets.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0353927 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +rpcscope-output/ +rpcscope-analysis/ +*.egg-info/ +rpcscope-advisories.json +build/ +dist/ +*.whl +pen.zip +.env +.env.* +!.env.example +.coverage +htmlcov/ +.ruff_cache/ +.mypy_cache/ +.DS_Store +Thumbs.db + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..77c90c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RPCScope contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eeb0328 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# RPCScope + +RPCScope is a small Python orchestrator and correlation engine for authorized SMB and Windows RPC audits. It combines evidence from NetExec, enum4linux-ng, Impacket's `rpcdump`, and an optional Metasploit **scanner** into one JSON schema and a readable report. + +The normal CLI scan uses NetExec plus RPC endpoint collection. Full enum4linux-ng collection is now explicit with `--deep`, so broad enumeration does not delay dedicated checks. Add `--no-rpcdump` for SMB/check-only work. The Python API retains its earlier collector defaults for compatibility. + +The differentiator is not “run four tools.” It is the evidence layer between collection and conclusions: + +- normalize changing console output into host, SMB, share, and RPC-interface facts; +- match RPC UUIDs to security-relevant Windows protocols without claiming that exposure proves a CVE; +- distinguish a confirmed scanner result from an inferred risk signal; +- retain collector artifacts with supplied passwords redacted and report the evidence behind each finding; +- support offline analysis and dry runs for safe development and demos. + +## Scope and safety + +Use RPCScope only on systems you own or are explicitly authorized to assess. Live collection requires `--i-am-authorized`. Version banners and exposed interfaces are leads for validation, not automatic proof of vulnerability. The initial Metasploit integration uses only `auxiliary/scanner/smb/smb_ms17_010`; it does not run an exploit or payload. + +NDRaider is useful architectural inspiration, but it solves a different problem: it extracts NDR grammars from Windows PE files and performs structure-aware RPC fuzzing. RPCScope starts with remote enumeration and correlation. A future integration could ingest NDRaider JSON from authorized lab-side binary analysis, but it should not be a hard dependency. + +## Install + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +rpcscope doctor +``` + +External collectors are optional for offline analysis. Install the adapters you intend to use: `nxc` (or `netexec`), `enum4linux-ng`, and `impacket-rpcdump`; `msfconsole` is required only when its check is enabled. The native LongLogon precondition check does not require NetExec. + +## Usage + +Null-session discovery plus shares across one or more IPs/CIDRs: + +```bash +rpcscope scan 10.10.10.25 10.10.20.0/24 \ + --shares --i-am-authorized -o engagement-a +``` + +No credential flags are required: omitted `-u/-p` values intentionally select null/anonymous collection. + +Authenticated collection without placing the password in shell history: + +```bash +rpcscope scan targets.txt -u 'ACME\auditor' --password-prompt \ + --shares --i-am-authorized -o engagement-a +``` + +Enable the non-exploit MS17-010 auxiliary scanner: + +```bash +rpcscope scan 10.10.10.0/24 -u '' -p '' --shares \ + --msf-ms17-010 --i-am-authorized -o engagement-a +``` + +Run the credentialless vulnerability-check preset: + +```bash +rpcscope scan 10.10.10.0/24 --checks credentialless --shares \ + --authorised -o engagement-a +``` + +List the checker registry and credential requirements with `rpcscope catalog`. The CLI batches the same NetExec module across live hosts, while unrelated modules run in separate processes so a module exception cannot suppress later checks. `safe` remains an alias for `credentialless`; it describes the authentication requirement, not a guarantee about probe impact. + +For the expanded audit, `--checks recommended` adds these adapters to the original four credentialless checks: + +| Selector | Evidence collected | Positive label | +| --- | --- | --- | +| `petitpotam` | EFSRPC interface binding associated with PetitPotam | `EXPOSURE` | +| `dfscoerce` | DFS namespace RPC binding | `EXPOSURE` | +| `shadowcoerce` | File Server VSS Agent RPC binding | `EXPOSURE` | +| `printerbug` | Print Spooler RPC binding | `EXPOSURE` | +| `mseven` | Event Log RPC binding | `EXPOSURE` | +| `spooler` | Registered MS-RPRN endpoints | `INFO` | +| `oxid` | Anonymous IObjectExporter advertised interface IPs | `INFO` | +| `longlogon` | Guarded CLDAP naming-precondition estimate | `PRECONDITION` | + +The five bind adapters use NetExec `coerce_plus` with only `METHOD=...`. When all five are selected, the batch engine uses one `METHOD=All` invocation and attributes each method's evidence separately; subsets run only their selected methods. RPCScope never supplies `LISTENER` or `ALWAYS`. Upstream's “VULNERABLE” bind markers are normalized to `exposed`, since an accepted bind does not prove an authenticated callback, relay opportunity, or CVE. The current PetitPotam lookup may activate EFS through endpoint resolution. Anonymous method access still depends on server policy. The spooler/OXID requests use TCP/135 through NetExec's SMB wrapper, so those two adapters require both ports reachable. OXID-reported addresses are recorded only and never become scan targets. [Upstream implementation](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/coerce_plus.py). + +```bash +rpcscope scan 10.10.10.0/24 --checks recommended --shares --authorised -o audit-new +rpcscope scan 10.10.10.0/24 --checks exposure,info --authorised -o inventory-new +rpcscope scan 10.10.10.0/24 --checks recommended --deep --authorised -o deep-new +``` + +Omitting `--checks` still runs enumeration only. Presets `exposure` and `info` can be combined with explicit checks. Inventory results never cause `NOT VULNERABLE` by themselves. Each check attempt retains its backend and evidence; contradictory results appear as `CONFLICT` in minimal output. + +The current SMBGhost module tests dialect/compression support, PrintNightmare uses a driver-permission probe, and noPac uses ticket-size behavior. Positive results from these adapters are labelled `SUSPECTED` pending validation. The old NTLM reflection adapter is disabled because NetExec removed that module; it is listed as unsupported instead of silently appearing to run. These adapter assumptions follow the primary upstream [SMBGhost](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/smbghost.py), [PrintNightmare](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/printnightmare.py), [noPac](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/nopac.py), and [NTLM reflection](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/ntlm_reflection.py) implementations. Silence, access denial, and an unknown message format are inconclusive. + +LongLogon is included in `--checks recommended` (12 checks total). It remains a precondition-only check and can also run separately: + +```bash +rpcscope scan 10.10.10.25 --checks longlogon --authorised -o longlogon-lab +``` + +It uses unauthenticated UDP/389 CLDAP requests: an EX response identifies the DNS domain, then a guarded legacy baseline uses only the fixed tiny `User=x` value. It never sends an oversized payload. The response-size estimate is reported as `PRECONDITION: [LONGLOGON:MET]`, `NOT-MET`, or `BORDERLINE`; missing or invalid evidence is `UNKNOWN`. None of these results establishes patch state, code execution, or a confirmed CVE, and none becomes `VULN` or `NOT VULNERABLE`. This adapter is informed by the MIT-licensed [ADScanPro tester at revision 5c0b86f](https://github.com/ADScanPro/CVE-2026-41089-LongLogon/tree/5c0b86fb957e387c9c3f6d56f7d8ee35e159c2); attribution is retained in `THIRD_PARTY_NOTICES.md`. + +`-l/--lhost` is recorded in report metadata for future callback-based checks. It is deliberately unused by the current collectors because share enumeration, endpoint mapping, and the MS17-010 scanner do not need a callback listener. + +Preview collection without sending traffic: + +```bash +rpcscope scan 10.10.10.0/24 -u '' -p '' --shares --dry-run +``` + +Analyze previously captured artifacts: + +```bash +rpcscope analyze \ + --nxc evidence/nxc.txt \ + --rpcdump-dir evidence/rpcdump \ + --enum4linux-dir evidence/enum4linux \ + -o analysis +``` + +Outputs include a one-line-per-reachable-host `report.txt`, detailed `report.json` and `report.md`, and collector files under `raw/`. Progress, TXT, and Markdown omit unreachable addresses by default; JSON retains the expanded scope and its port outcomes. Unreachable hosts have no scheduled checks. Passwords are never included in report identity labels. Each live run requires an empty output directory to prevent stale evidence from earlier runs being mixed in. Files are written atomically, and reports refresh as hosts finish, with updates capped at twice per second. + +Execution coverage and evidence certainty are separate. A completed heuristic check is `SUSPECTED`; an executed check without a reliable verdict is `UNKNOWN`. Both count as completed. Skips appear separately, for example `CHECKS: 9/9 SKIPPED: 2` for eleven selected checks. Only real failures, unfinished work, or interruption produce partial coverage. A skipped or uncertain check still prevents an all-selected-checks clean claim: `NOT VULNERABLE (SELECTED CHECKS)` requires explicit negative vulnerability results and successful collection, with no unresolved selected checks. RPC tags describe endpoint-mapper registrations; bindings have not been independently tested for reachability. Conflicting evidence is preserved in the detailed report. + +```text +192.0.2.10 (LAB) SUSPECTED: [CVE-2020-0796/SMBGHOST] EXPOSURE: [SMB-SIGNING-NOT-REQUIRED] PORTS: 135/open,445/open CHECKS: 9/9 SKIPPED: 2 +192.0.2.20 CHECKED UNKNOWN: [MS17-010] PORTS: 135/open,445/open CHECKS: 1/1 +``` + +## Progress and performance + +The default progress display goes to stderr and shows the active stage/reachable host, completed task count, and elapsed time. Dead addresses advance discovery totals silently and never enter a host-check pipeline. Completed checks keep a compact result line. A heartbeat updates the display during slow collectors. Redirected output uses stable lines without terminal escape codes; stdout contains only the final minimal host report. A completed scan with no reachable SMB/RPC hosts prints a summary without per-IP lines; that is not itself an error. + +```text +[0/3] START discovery (elapsed 0s) +[1/3] DONE discovery success (0.3s) | 2 target(s) +[1/3] START batch:ms17-010 (elapsed 0s) +[2/3] DONE ms17-010 192.0.2.10 vulnerable (0.5s) +[3/3] DONE ms17-010 192.0.2.20 not-vulnerable (0.4s) +[3/3] SUMMARY check-batch complete (elapsed 1s) | ms17-010; 2 alive host(s) +[3/3] SUMMARY scan complete (elapsed 1s) | 2 target(s); 0 collection note(s) +``` + +Use `--progress plain` for log files or `--no-progress` for quiet operation. Lightweight TCP and NetExec inventory discovery use `--discovery-workers 256`, matching the reviewed NetExec thread default. `--jobs 8` limits heavier checks and host collectors separately. Each NetExec check batch contains at most `--jobs` live hosts (capped at 128 targets), with the same NetExec thread limit. Only one check batch runs at a time, avoiding multiplied/nested concurrency. Dedicated checks precede optional broad enumeration; native checks, optional MSF, RPC dump and finally `--deep` enum4linux run in bounded host pipelines. + +`--timeout 120` bounds each collector or single-check batch. The five combined bind checks receive five check budgets in total; this is an invocation ceiling, not an independently enforced per-method deadline. `--discovery-timeout 600` bounds discovery separately. Output is spooled to disk; the runner actively stops a noisy collector at its next poll once output exceeds 16 MiB, retains bounded output, and marks the invocation partial. + +`--jobs` means maximum concurrent heavy hosts, whether handled by NetExec's batch threads or Python's collector pool. It is optional: omitting it uses eight; `--jobs 1` runs heavy hosts sequentially. Tune discovery separately. Increasing either limit uses more local/network resources; it changes throughput, not detection criteria. `--isolated-checks` retains per-host/per-check execution for diagnosing backend compatibility, without automatic retries. + +The installed NetExec SMB help is inspected once and cached. Supported versions use `--no-admin-check` to avoid unnecessary Service Control Manager checks; none of our adapters needs an admin-only callback. Share enumeration uses legacy `--no-write-check` where supported, or current ACL-only defaults where `--file-write-check` is an explicit opt-in. RPCScope never enables file-write testing; unknown share semantics cause an explicit skip/error rather than a guessed flag. Existing filenames supplied as literal credentials are rejected because NetExec would treat them as credential lists. + +Accuracy safeguards include host/module/port-indexed output, explicit negative signatures, caught-host-exception detection even on exit zero, and IPv4-only gating for the reviewed upstream MS17-010/SMBGhost adapters. Missing output never implies patched. Failed batches are retained as unknown/partial without replaying probes. Local socket-resource exhaustion is a scan failure, not thousands of supposedly dead hosts. + +The offline 32-host `recommended` test reduces NetExec **check** process invocations from 352 to 28 while retaining twelve per-host check records. This measures orchestration work, not live throughput or CVE-detection accuracy. NetExec and the other external collectors are not installed in the development environment, so live version interoperability and network timing still require an authorized lab. A twelve-check audit plus RPC/optional deep inventory cannot be expected to take the same time as a bare `nxc smb` banner scan. See [the source review](docs/NETEXEC_REVIEW.md). + +Default discovery expands explicit IPs, CIDRs, DNS names, and UTF-8 target files, deduplicates them, and checks TCP/135 and TCP/445 independently. A host enters collection only if either port is open, retaining RPC-only systems. Closed and timed-out connections are completed reachability outcomes, not blanket partial coverage. For a reachable host, unavailable ports skip only dependent adapters; hosts with neither service reachable receive no collectors/checks. JSON retains all expanded addresses and discovery results. Files allow one IP/CIDR/hostname per line and `#` comments. Use `--connect-timeout 1` to tune connection probing and `--max-hosts 4096` to control the expansion cap. Discovery time is bounded by `--discovery-timeout`, including per-connection timeout clamping; unfinished probes remain distinguishable from completed timeouts. `--discovery smb` keeps an SMB-only workflow for environments where direct TCP probing is inappropriate. The Python `ScanConfig` API retains SMB-only discovery and isolated checks by default for compatibility; set `tcp_discovery=True, batch_checks=True, use_enum4linux=False` for the CLI's normal execution strategy. + +Discovery does not test UDP/389: a UDP-only domain controller with neither TCP/135 nor TCP/445 reachable is outside this workflow's discovery coverage, even when `longlogon` is selected. + +## Recent advisory review and metadata sync + +`rpcscope catalog --advisories` lists seven patch-review advisories: CVE-2020-1206, CVE-2022-26809, CVE-2022-37958, CVE-2024-49112, CVE-2025-47981, CVE-2025-49716, and CVE-2026-41089. These include SMBleed, RPC/NEGOEX, adjacent Active Directory LDAP, and newer Netlogon issues. The registry supplies metadata, never automatic per-host vulnerability claims. LongLogon has the precondition adapter above, included in `recommended`; it does not remotely verify patch state. The other entries have no executable remote adapter. No CVE is assigned to DFSCoerce or ShadowCoerce merely from its technique name. + +```bash +rpcscope catalog --advisories +rpcscope sync-cves -o rpcscope-advisories.json +``` + +Sync refreshes the fixed CVE Program Microsoft CNA JSON records and preserves affected-version metadata and vendor references. It validates record identity and publisher, bounds response size, and replaces the cache only after every record validates. Downloads never install or execute scanner code. A published unauthenticated RCE advisory does not imply a validated scanner or working exploit; the Netlogon [CVE-2026-41089 record](https://www.cve.org/CVERecord?id=CVE-2026-41089) remains patch-review metadata, separate from the precondition measurement. The [original SMBleed scanner](https://github.com/jamf/SMBGhost-SMBleed-scanner) warns that it can crash some unpatched systems, so it has not been added as a default probe. + +Ctrl+C stops active collector process groups and retains completed evidence. TCP/135 RPC enumeration runs without credentials, which also avoids an interactive prompt for named accounts with blank passwords. `--shares` uses the verified read-only mode described above, never file-write tests. With `--deep`, enum4linux JSON is exported in a private temporary directory and scrubbed before being retained. The external tools may maintain their own logs or credential databases outside RPCScope's output directory. + +Exit codes: `0` completed, `1` real execution/collection failures or unfinished work, `2` invalid input, `130` interrupted. Findings, inconclusive results, normal skips, and an empty reachable-host set do not by themselves cause a nonzero exit. Offline analysis returns `1` for malformed inputs while retaining any valid artifacts it could analyze. + +## Tests + +```bash +python3 -B -m unittest discover -s tests -v +``` + +Tests use synthetic artifacts, mocked tool invocations, and local subprocesses. No live network target is required. + +## Roadmap + +1. Harden parsers against a larger fixture corpus from different tool versions. +2. Move UUID knowledge into signed/versioned data files with protocol references and tests. +3. Add SARIF output and “compare two scans” drift reporting. +4. Add explicit profiles (`safe`, `standard`, `active`) rather than silently adding intrusive modules. +5. Optionally ingest NDRaider grammar JSON to correlate a remotely exposed UUID with a lab-analyzed vendor binary. +6. Add a plugin API for new collectors and rules, keeping the core dependency-free. + diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..e6b03c0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,36 @@ +# Third-party notices + +## ADScanPro LongLogon precondition tester + +RPCScope's optional LongLogon adapter adapts the minimal CLDAP request and +response-size precondition heuristic from: + +- https://github.com/ADScanPro/CVE-2026-41089-LongLogon +- Revision: `5c0b86fb957e387c9c3c3f6d56f7d8ee35e159c2` +- Original author: Yeray Martín Domínguez + +The adapter adds independent response validation, EX domain discovery, bounded +parsing and a conservative baseline guard. The estimate is not patch detection. +The original license follows in full. + +```text +MIT License + +Copyright (c) 2026 Yeray Martín Domínguez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..476dfc4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,77 @@ +# Architecture + +RPCScope separates collection from interpretation so that external tool changes do not silently change the meaning of a finding. + +```text +targets (expanded, resolved, deduplicated, capped) + | + +-- independent TCP/135 + TCP/445 discovery + | +-- RPC-only hosts remain in scope + | +-- unreachable: stop here, retain discovery in JSON only + | +-- reachable: queue host pipelines only + +-- NetExec SMB inventory -------+ + +-- enum4linux-ng JSON ----------+--> normalized HostEvidence + +-- Impacket rpcdump ------------+ | + +-- optional MSF scanner --------+ v + +-- opt-in CLDAP precondition ---+ (not a patch detector) + deterministic rules + | + +---------------+---------------+ + | | + report.json report.md +``` + +## Trust boundaries + +- Collector output is untrusted input. Parsers extract only labelled facts and never execute data from results. +- Credentials are passed to external tools only when the operator supplies them. Recorded command metadata redacts passwords. +- Live collection is gated by `--i-am-authorized`; offline analysis does not require the flag. +- A UUID match establishes interface exposure. It does not establish vulnerable code, reachable opnums, authorization, patch state, or exploitability. +- A CVE-level finding requires a purpose-built scanner result or a separately implemented, testable evidence predicate. + +## Data model + +`HostEvidence` is the stable seam of the project. Collectors contribute: + +- identity: address, hostname, domain, OS fingerprint; +- SMB posture: SMBv1, signing, null/guest session state; +- shares: name, read/write access, comment; +- RPC surface: UUID, version, protocol label, provider, binding/endpoint; +- checks: explicit scanner name and outcome; +- check details: the actual attributed result marker and any evidence conflicts; +- collection: pending, success, failure, or skipped state for each collector; +- sources: raw artifact paths. + +Rules consume only this model. They do not parse terminal output and do not invoke tools. + +Dedicated checks are registered in `rpcscope/catalog.py`. CLI NetExec checks run in single-module, bounded live-host batches; unrelated modules remain separate processes. The five bind-only exposure methods share `METHOD=All` only when the complete unchanged set was selected. Each check keeps a separate normalized result. `longlogon` is native Python and estimates a naming precondition over CLDAP, not vulnerability or patch status. + +The registry also distinguishes interface exposure and information gathering. Accepted RPC binds are `exposed`; spooler registrations and OXID interface results are `observed`. They become informational findings rather than CVE verdicts. `CheckAttempt` preserves backend-specific evidence, and conflicting positive/negative results are highlighted directly in minimal reports. + +`advisories.py` contains a separate review-only metadata registry for 2020–2026 issues. `sync-cves` refreshes fixed Microsoft CNA records without changing `catalog.CHECKS`, importing code, or generating host vulnerability verdicts. + +Discovery uses `--discovery-workers` (256 by default) for lightweight TCP probing and NetExec inventory. A check batch has at most `--jobs` live targets (8 by default, capped at 128 per command) and the same NetExec thread limit. Batches run sequentially; afterward the Python host pool has at most `--jobs` active futures. This avoids nested concurrency multiplication. Host pipelines run native/optional MSF checks, RPC dump, then optional full enum4linux. Full enum is enabled by `--deep` in the CLI; the library retains the old defaults. Dead targets never receive pending checks or host futures. TCP timeout/closed are completed reachability outcomes; an unprobed target indicates a real discovery interruption/deadline, while local resource exhaustion is explicitly a scan failure. + +A thread-safe progress callback emits task start/finish events to stderr for reachable hosts and silently advances counts for unreachable ones. A separate callback publishes atomic report snapshots as host jobs finish. Parsing uses original in-memory output, while saved artifacts and check details redact supplied credentials; redaction cannot alter target addresses or detection markers before classification. + +Execution completion is independent of evidence certainty. `inconclusive` and `suspected` are completed results; ordinary skipped checks are counted outside the eligible denominator. Failed processes, pending work and cancellation remain partial. Text and Markdown show reachable hosts; JSON retains the full expanded scope and all discovery states. + +`netexec.py` plans batches and inspects installed CLI capabilities without importing scanner code. Help inspection is cached; read-only share semantics must be known. Output is indexed once by IP/module/port, and known zero-exit host exceptions do not masquerade as successful collection. See `NETEXEC_REVIEW.md` for pinned upstream references and verification limits. + +## Rule quality bar + +Every rule should include a stable ID, severity, confidence, exact evidence, impact, remediation, and a primary reference when one exists. New rules need positive and negative fixtures. Avoid CVE claims based only on banners, UUIDs, OS names, or open ports. + +## NDRaider boundary + +NDRaider can statically recover interface UUIDs and typed NDR method grammars from Windows binaries, then generate structure-aware fuzz inputs. That complements RPCScope in a lab workflow: + +1. RPCScope observes a vendor UUID on an authorized target. +2. The assessor obtains the matching server binary through an approved process. +3. NDRaider analyzes the binary offline and exports grammar JSON. +4. A future importer correlates UUID/version/method counts with the remote observation. +5. Any live fuzzing remains a separate, explicit active-testing profile. + +Keeping that boundary prevents a fast inventory scan from unexpectedly becoming a fuzzer. + +See `CHECKER_POLICY.md` for the CVE synchronization and future proof-of-execution requirements. diff --git a/docs/CHECKER_POLICY.md b/docs/CHECKER_POLICY.md new file mode 100644 index 0000000..999e61a --- /dev/null +++ b/docs/CHECKER_POLICY.md @@ -0,0 +1,55 @@ +# Checker and CVE policy + +## Result states + +Every implemented check must return an explicit state: + +- `vulnerable`: the dedicated checker returned its documented positive marker; +- `not-vulnerable`: the checker completed and returned its documented negative marker; +- `suspected`: the checker found a capability or permission indicator requiring independent validation; +- `exposed`: the selected RPC interface accepted a bind; no coercion or exploitation was demonstrated; +- `observed`: inventory information was returned, such as registered spooler endpoints or advertised network interface addresses; +- `inconclusive`: the probe completed without enough evidence, including silence, access denial or an unsupported response; +- `precondition-met`, `precondition-not-met`, `precondition-borderline`: an empirical prerequisite estimate, not a vulnerability or patch verdict; +- `skipped-*`: the check could not run, for example because credentials were required. +- `pending`: the selected checker has not finished; used by live report checkpoints. + +An exposed RPC UUID, OS banner, open port, or legacy protocol is an `EXPOSURE`, not a confirmed vulnerability. + +`NOT VULNERABLE (SELECTED CHECKS)` is permitted only when selected vulnerability checks produced explicit negatives, no selected checks were skipped or unknown, and collection completed without evidence conflicts. Inventory and precondition results cannot independently produce that verdict. Result markers must match the expected module and host. Nonzero exits, contradictory messages, and silence cannot produce definitive results. + +Execution completion and evidence certainty are separate: `suspected` and `inconclusive` count as completed checks, with uncertainty printed explicitly. Ordinary `skipped-*` results are excluded from the execution denominator and counted as `SKIPPED`; they never imply a negative. Failed processes, pending stages and cancellation still mark the scan partial. Closed/timed-out discovery services are completed reachability outcomes, not failed vulnerability checks; no host pipeline is queued without a reachable TCP/135 or TCP/445 service. + +SMBGhost's dialect/compression indicator, PrintNightmare's driver-permission probe, and noPac's PAC-size heuristic produce `suspected` results. The removed NetExec NTLM reflection module is disabled pending an adapter for its replacement. + +## Catalog synchronization + +Metadata and executable checks have different lifecycles: + +1. CVE metadata should be refreshed from the NVD CVE API. +2. Exploitation-in-the-wild priority should be overlaid from CISA's Known Exploited Vulnerabilities JSON feed. +3. Microsoft Windows applicability and remediation should link to the MSRC Update Guide. +4. RPC interface identity should link to the relevant Microsoft Open Specifications document. +5. A new CVE remains `metadata-only` until a deterministic positive and negative probe, fixtures, and target-version notes exist. + +Keyword searches for “RPC,” “Netlogon,” or “spooler” are discovery aids only. They cannot safely create executable rules because CVE descriptions do not consistently identify a wire protocol, endpoint UUID, remotely observable patch signal, or non-destructive probe. + +The implemented `sync-cves` command refreshes curated Microsoft CNA records only. The downloaded dataset is explicitly review-only with `scanner_coverage=false`. It never expands executable CVE/patch detection coverage or labels an individual host vulnerable. LongLogon has a precondition-only adapter included in `recommended`; its existence does not turn this metadata into a patch detector. + +## LongLogon boundary + +`--checks recommended` includes LongLogon's minimal CLDAP DC-locator queries on UDP/389; `--checks longlogon` selects just that adapter. It first validates an EX identity response, then conditionally measures a fixed one-character-user legacy response after a conservative baseline-size guard. It never sends a maximum-length user field or an overflow trigger. The upstream response-size extrapolation is an empirical heuristic, retained as `PRECONDITION` only. Invalid, mismatched, unsupported, timed-out or EX-only responses are `UNKNOWN`, not negative. No remote patch-state claim is made. Default TCP discovery does not cover UDP-only domain controllers whose TCP/135 and TCP/445 are both unavailable. + +## Validation and exploitation + +Future proof-of-execution adapters should live behind a separate command, not the scan command. The intended guardrails are: + +- exactly one host and one check per invocation; +- a second explicit authorization flag; +- no CIDRs, target files, arbitrary shell commands, persistence, payload listeners, or credential dumping; +- a fixed proof that returns identity and OS/build only; +- no proof text in the report unless the target returned it; +- a target-change and cleanup declaration for every adapter; +- disabled adapters for checks whose only reliable proof risks corruption, password changes, denial of service, or persistent artifacts. + +This keeps the resume project useful for validation while preventing a routine audit from silently becoming an exploitation campaign. diff --git a/docs/NETEXEC_REVIEW.md b/docs/NETEXEC_REVIEW.md new file mode 100644 index 0000000..5a25328 --- /dev/null +++ b/docs/NETEXEC_REVIEW.md @@ -0,0 +1,73 @@ +# NetExec execution review + +Reviewed upstream revision: `89a698d93dfc101e7104d42523aac4765372cd7d`. +This is an interface/architecture review, not copied NetExec implementation code. +RPCScope continues to invoke the operator's installed executable. + +## Findings and implementation + +| Finding | RPCScope change | +| --- | --- | +| NetExec starts a protocol object per target in a thread pool, default 256 workers. | 256 lightweight discovery workers; bounded live-host batches for heavier checks. | +| Our old runner launched one NetExec process for every host/check pair. | One process per module/live-host batch, amortizing imports and initialization. | +| Full enum4linux `-A` preceded the dedicated probes. | Dedicated probes first; normal CLI leaves full enumeration to `--deep`. RPC dump remains enabled. | +| NetExec supports multiple modules per SMB session, but its module loop does not isolate arbitrary callback exceptions. | Keep unrelated modules in separate processes; no automatic retry of failed probes. | +| All five selected `coerce_plus` bind methods can use `METHOD=All` without a listener. | Combine only the complete, unchanged five-method selection. Subsets stay separate. Each method still has its own result record. | +| Current NetExec uses ACL-based share checks and opts into file writes with `--file-write-check`. Older versions offered `--no-write-check`. | Inspect installed help, select a known read-only mode, otherwise skip with an explicit error. Never enable file-write testing. | +| Admin checking adds an SCM RPC exchange; our adapters use `on_login`, not `on_admin_login`. | Use `--no-admin-check` only when the installed CLI advertises support. | +| NetExec may catch individual host exceptions and still exit zero. | Attribute known host exceptions and mark that host's check failed/unknown without invalidating other hosts. | +| Console output can contain credential echoes, truncated module labels and unrecognized messages. | Index by canonical IP/module/port once per batch; allow known label truncation; require documented full negative markers. | +| Reviewed MS17-010 and SMBGhost modules create IPv4 sockets. | Skip these adapters explicitly on IPv6 rather than pretending a checker executed successfully. | +| Local socket limits and noisy subprocesses could produce misleading outcomes or excessive temporary output. | Report local resource exhaustion as an execution failure, and actively stop over-limit output. | + +## Execution and budgets + +```text +scope -> TCP discovery -> NetExec inventory (open SMB listeners only) + -> same-module NetExec check batches (live, eligible hosts only) + -> bounded host pipelines: native / optional MSF / RPC / optional deep enum + -> evidence-backed reports +``` + +`--jobs` bounds each check batch and the later host pool; these phases do not +multiply their concurrency. Batches contain no more than 128 targets, even with +a larger jobs limit, and no more hosts than can execute in one NetExec thread +wave. A single-check process receives one check timeout. The combined five-bind +process receives five check timeouts in total; individual methods are not +independently timed inside that external process. Global batch failures remain +unknown/partial. Positive evidence is not inferred from process exit alone. + +The core library keeps legacy isolated execution and full enum defaults for +compatibility. CLI defaults select batching and leave full enum to `--deep`. +`--isolated-checks` is a diagnostic fallback, not an automatic retry policy. + +## Verification and limits + +The mocked 32-live-host recommended scan executes 28 NetExec check invocations +instead of the old 352 (seven module groups, four eight-host batches). All twelve +selected results per host remain represented; inventory and capability-help +invocations are separate from this count. This is not a wall-clock benchmark. + +Tests cover batching, selection boundaries, per-host result isolation, zero-exit +errors, cancellation, read-only share compatibility, output caps, and resource +failures. External tools are absent on the development host. Real version +interoperability, network throughput, and detection sensitivity/specificity are +not established by mocks and need an authorized, patched/unpatched lab matrix. +No live target scan was performed for this review. + +A bare `nxc smb` connection/banner scan does less work than twelve selected +checks plus RPC/deep enumeration. The optimization removes avoidable overhead; +it does not remove the protocol work or turn heuristic findings into patch +proof. No scanner can promise flawless results across every network/tool build. + +## Primary sources + +- [Threaded target runner](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/netexec.py) +- [CLI worker default](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/cli.py) +- [Connection/module callback flow](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/connection.py) +- [SMB CLI and share options](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/protocols/smb/proto_args.py) +- [Share and admin checks](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/protocols/smb.py) +- [Combined bind-method interface](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/modules/coerce_plus.py) +- [Logger format](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/logger.py) +- [MS17-010 IPv4 implementation](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/modules/ms17-010.py) +- [SMBGhost IPv4 implementation](https://github.com/Pennyw0rth/NetExec/blob/89a698d93dfc101e7104d42523aac4765372cd7d/nxc/modules/smbghost.py) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a9507f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "rpcscope" +version = "0.1.0" +description = "Evidence-driven SMB and Windows RPC exposure auditing" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "RPCScope contributors" }] +dependencies = [] + +[project.scripts] +rpcscope = "rpcscope.cli:main" + +[tool.setuptools] +license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"] + +[tool.setuptools.packages.find] +include = ["rpcscope*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + diff --git a/rpcscope/__init__.py b/rpcscope/__init__.py new file mode 100644 index 0000000..24dbcde --- /dev/null +++ b/rpcscope/__init__.py @@ -0,0 +1,4 @@ +"""RPCScope: normalize and correlate SMB/RPC enumeration evidence.""" + +__version__ = "0.1.0" + diff --git a/rpcscope/__main__.py b/rpcscope/__main__.py new file mode 100644 index 0000000..8f0c1d5 --- /dev/null +++ b/rpcscope/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +raise SystemExit(main()) + diff --git a/rpcscope/advisories.py b/rpcscope/advisories.py new file mode 100644 index 0000000..ba68dbc --- /dev/null +++ b/rpcscope/advisories.py @@ -0,0 +1,234 @@ +"""Curated Microsoft advisory metadata; these entries are not executable checks. + +Sync downloads only the fixed CVE Program JSON records below. It never imports +code, fetches referenced URLs, or evaluates a host against affected versions. +""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable +from urllib.error import URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + +from .storage import atomic_text + + +@dataclass(frozen=True) +class Advisory: + key: str + title: str + year: int + surface: str + reference: str + cna_url: str + notes: str + auth_requirement: str = "none per advisory" + status: str = "review-only" + + +def _advisory(key: str, title: str, surface: str, notes: str) -> Advisory: + _, year, number = key.split("-") + group = f"{int(number) // 1000}xxx" + return Advisory( + key=key, title=title, year=int(year), surface=surface, + reference=f"https://msrc.microsoft.com/update-guide/vulnerability/{key}", + cna_url=("https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/" + f"{year}/{group}/{key}.json"), + notes=notes, + ) + + +# Review candidates are deliberately separate from catalog.CHECKS: listing an +# advisory or an RPC interface must never produce a host vulnerability verdict. +ADVISORIES: dict[str, Advisory] = { + "CVE-2020-1206": _advisory( + "CVE-2020-1206", "SMBleed / SMBv3 information disclosure", "SMBv3.1.1", + "Information disclosure advisory. No memory-disclosure probe or patch detector is implemented.", + ), + "CVE-2022-26809": _advisory( + "CVE-2022-26809", "RPC Runtime remote code execution", "Windows RPC runtime", + "RPC reachability alone does not establish patch status; requires separate patch verification.", + ), + "CVE-2022-37958": _advisory( + "CVE-2022-37958", "SPNEGO / NEGOEX remote code execution", "SPNEGO / NEGOEX", + "Authentication negotiation advisory; advertised mechanisms do not establish vulnerability.", + ), + "CVE-2024-49112": _advisory( + "CVE-2024-49112", "Windows LDAP remote code execution", "LDAP / Active Directory (adjacent)", + "Broader Active Directory advisory, not an SMB or RPC interface detector. No active probe implemented.", + ), + "CVE-2025-47981": _advisory( + "CVE-2025-47981", "SPNEGO / NEGOEX remote code execution", "SPNEGO / NEGOEX", + "Unauthenticated network RCE advisory; deployment prerequisites and patches need independent review.", + ), + "CVE-2025-49716": _advisory( + "CVE-2025-49716", "Windows Netlogon denial of service", "MS-NRPC / Netlogon", + "Availability and Netlogon hardening review. No denial-of-service probe is implemented.", + ), + "CVE-2026-41089": _advisory( + "CVE-2026-41089", "Windows Netlogon remote code execution", "MS-NRPC / Netlogon", + "Published Microsoft advisory. The opt-in longlogon check estimates a CLDAP naming precondition only; it cannot establish patch state or vulnerability.", + ), +} + +_MICROSOFT_ORG_ID = "f38d906d-7342-40ea-92c1-6c4a2c6478c8" +_MAX_BYTES = 2 * 1024 * 1024 +_MAX_ITEMS = 512 +_MAX_TEXT = 4096 + + +def _download(url: str) -> bytes: + request = Request(url, headers={"Accept": "application/json", "User-Agent": "RPCScope/advisory-sync"}) + with urlopen(request, timeout=15) as response: + # Avoid following a moved record to an unreviewed destination. + if response.geturl() != url: + raise ValueError("CNA download redirected away from its fixed source") + payload = response.read(_MAX_BYTES + 1) + return payload + + +def _mapping(value: Any, field: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"invalid {field}: expected object") + return value + + +def _items(value: Any, field: str) -> list[Any]: + if not isinstance(value, list) or len(value) > _MAX_ITEMS: + raise ValueError(f"invalid or excessive {field}") + return value + + +def _text(value: Any, field: str, *, maximum: int = _MAX_TEXT) -> str: + if not isinstance(value, str) or not value or len(value) > maximum: + raise ValueError(f"invalid or excessive {field}") + # Metadata may be printed in a terminal. Reject terminal control characters; + # keep normal whitespace available for vendor descriptions. + if any(ord(char) < 32 and char not in "\n\r\t" for char in value) or "\x7f" in value: + raise ValueError(f"control characters in {field}") + return value + + +def _date(value: Any, field: str) -> str: + result = _text(value, field, maximum=64) + try: + parsed = datetime.fromisoformat(result.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timezone required") + except ValueError as exc: + raise ValueError(f"invalid {field}: expected timestamp with timezone") from exc + return result + + +def _vendor_url(value: Any) -> str | None: + value = _text(value, "reference URL", maximum=2048) + parsed = urlsplit(value) + hostname = parsed.hostname or "" + if (parsed.scheme == "https" and not parsed.username and not parsed.password + and parsed.port in (None, 443) + and (hostname == "microsoft.com" or hostname.endswith(".microsoft.com"))): + return value + return None + + +def _affected(value: Any) -> list[dict[str, Any]]: + affected: list[dict[str, Any]] = [] + for raw_product in _items(value, "affected products"): + product = _mapping(raw_product, "affected product") + selected: dict[str, Any] = {} + for key in ("vendor", "product", "defaultStatus"): + if key in product: + selected[key] = _text(product[key], f"affected.{key}") + if "platforms" in product: + selected["platforms"] = [ + _text(item, "affected platform") + for item in _items(product["platforms"], "affected platforms") + ] + versions: list[dict[str, Any]] = [] + for raw_version in _items(product.get("versions", []), "affected versions"): + version = _mapping(raw_version, "affected version") + selected_version = { + key: _text(version[key], f"version.{key}") + for key in ("version", "status", "lessThan", "lessThanOrEqual", "versionType") + if key in version + } + if "changes" in version: + changes = [] + for raw_change in _items(version["changes"], "version changes"): + change = _mapping(raw_change, "version change") + changes.append({key: _text(change[key], f"version change.{key}") + for key in ("at", "status") if key in change}) + selected_version["changes"] = changes + versions.append(selected_version) + selected["versions"] = versions + affected.append(selected) + return affected + + +def _snapshot(advisory: Advisory, payload: bytes) -> dict[str, Any]: + if not isinstance(payload, bytes) or len(payload) > _MAX_BYTES: + raise ValueError("CNA response must be bytes and at most 2 MiB") + try: + record = _mapping(json.loads(payload), "CNA record") + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc: + raise ValueError("invalid CNA JSON") from exc + metadata = _mapping(record.get("cveMetadata"), "cveMetadata") + if metadata.get("cveId") != advisory.key: + raise ValueError(f"CVE ID mismatch: expected {advisory.key}") + if metadata.get("state") != "PUBLISHED" or record.get("dataType") != "CVE_RECORD": + raise ValueError("record is not a published CVE record") + cna = _mapping(_mapping(record.get("containers"), "containers").get("cna"), "cna") + publisher = _mapping(cna.get("providerMetadata"), "CNA publisher") + if (publisher.get("orgId") != _MICROSOFT_ORG_ID + or str(publisher.get("shortName", "")).casefold() != "microsoft" + or metadata.get("assignerOrgId") != _MICROSOFT_ORG_ID): + raise ValueError("CNA publisher is not Microsoft") + references = [] + for raw_reference in _items(cna.get("references", []), "CNA references"): + reference = _mapping(raw_reference, "CNA reference") + vendor_url = _vendor_url(reference.get("url")) + if vendor_url and vendor_url not in references: + references.append(vendor_url) + if not references: + raise ValueError("CNA record has no HTTPS Microsoft reference") + result = asdict(advisory) + result.update({ + "title": _text(cna.get("title", advisory.title), "CNA title"), + "published": _date(metadata.get("datePublished"), "datePublished"), + "updated": _date(metadata.get("dateUpdated"), "dateUpdated"), + "publisher": "Microsoft", + "vendorReferences": references, + "affected": _affected(cna.get("affected", [])), + }) + return result + + +def sync_advisories(path: Path, fetch: Callable[[str], bytes] | None = None) -> list[dict[str, Any]]: + """Refresh all curated metadata atomically, preserving destination on failure. + + ``fetch`` is an optional URL-to-bytes adapter for offline tests. Network + fetching uses a 15-second timeout and a 2 MiB response limit per record. + Nothing from the result is loaded into the executable checker registry. + """ + fetch = fetch or _download + snapshots = [] + for advisory in ADVISORIES.values(): + try: + snapshots.append(_snapshot(advisory, fetch(advisory.cna_url))) + except (OSError, URLError, ValueError, RecursionError) as exc: + raise ValueError(f"cannot sync {advisory.key}: {exc}") from exc + dataset = { + "schema_version": 1, + "kind": "review-only-advisory-metadata", + "scanner_coverage": False, + "synced_at": datetime.now(timezone.utc).isoformat(), + "advisories": snapshots, + } + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_text(path, json.dumps(dataset, indent=2, sort_keys=True) + "\n") + return snapshots diff --git a/rpcscope/catalog.py b/rpcscope/catalog.py new file mode 100644 index 0000000..50c68a2 --- /dev/null +++ b/rpcscope/catalog.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CheckDefinition: + key: str + label: str + cves: tuple[str, ...] + surface: str + nxc_module: str + requires_credentials: bool + severity: str + reference: str + positive_status: str = "vulnerable" + enabled: bool = True + note: str = "" + kind: str = "vulnerability" + nxc_options: tuple[str, ...] = () + required_ports: tuple[int, ...] = (445,) + related_cves: tuple[str, ...] = () + auth_notes: str = "" + backend: str = "netexec" + ip_versions: tuple[int, ...] = (4, 6) + + +# This is a checker registry, not a claim that every listed CVE can be inferred +# from a banner or an exposed UUID. A check becomes "vulnerable" only when its +# named implementation returns its positive evidence marker. +CHECKS: dict[str, CheckDefinition] = { + "ms17-010": CheckDefinition( + key="ms17-010", + label="MS17-010", + cves=("CVE-2017-0143", "CVE-2017-0144", "CVE-2017-0145", "CVE-2017-0146", "CVE-2017-0147", "CVE-2017-0148"), + surface="SMBv1", + nxc_module="ms17-010", + ip_versions=(4,), + requires_credentials=False, + severity="critical", + reference="https://learn.microsoft.com/security-updates/securitybulletins/2017/ms17-010", + ), + "zerologon": CheckDefinition( + key="zerologon", + label="CVE-2020-1472/ZEROLOGON", + cves=("CVE-2020-1472",), + surface="MS-NRPC / Netlogon", + nxc_module="zerologon", + requires_credentials=False, + required_ports=(135, 445), + severity="critical", + reference="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2020-1472", + ), + "printnightmare": CheckDefinition( + key="printnightmare", + label="PRINTNIGHTMARE", + cves=("CVE-2021-1675", "CVE-2021-34527"), + surface="MS-RPRN / Print Spooler", + nxc_module="printnightmare", + requires_credentials=False, + severity="critical", + reference="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-34527", + positive_status="suspected", + note="Driver-install permission probe; positive output requires patch and privilege validation.", + ), + "smbghost": CheckDefinition( + key="smbghost", + label="CVE-2020-0796/SMBGHOST", + cves=("CVE-2020-0796",), + surface="SMBv3 compression", + nxc_module="smbghost", + ip_versions=(4,), + requires_credentials=False, + severity="critical", + reference="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2020-0796", + positive_status="suspected", + note="Dialect/compression indicator only; does not distinguish patched SMB 3.1.1 servers.", + ), + "nopac": CheckDefinition( + key="nopac", + label="NOPAC", + cves=("CVE-2021-42278", "CVE-2021-42287"), + surface="Active Directory Kerberos/SAM", + nxc_module="nopac", + requires_credentials=True, + severity="critical", + reference="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-42287", + positive_status="suspected", + note="PAC ticket-size heuristic; does not validate every prerequisite of the noPac chain.", + ), + "ntlm-reflection": CheckDefinition( + key="ntlm-reflection", + label="CVE-2025-33073/NTLM-REFLECTION", + cves=("CVE-2025-33073",), + surface="SMB / NTLM reflection", + nxc_module="ntlm_reflection", + requires_credentials=True, + severity="high", + reference="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-33073", + enabled=False, + note="Upstream removed ntlm_reflection in favor of enum_cve; adapter needs revalidation.", + ), + "petitpotam": CheckDefinition( + key="petitpotam", + label="PETITPOTAM-RPC", + cves=(), + surface="MS-EFSRPC / EFS named pipes", + nxc_module="coerce_plus", + requires_credentials=False, + severity="info", + reference="https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/coerce_plus.py", + positive_status="exposed", + kind="exposure", + nxc_options=("METHOD=PetitPotam",), + related_cves=("CVE-2021-36942",), + note="RPC bind exposure only. Upstream may activate EFS through endpoint resolution; no listener or coercion request is supplied.", + auth_notes="Anonymous access is possible on some domain controllers; policy and patch level determine access.", + ), + "dfscoerce": CheckDefinition( + key="dfscoerce", + label="DFSCOERCE-RPC", + cves=(), + surface="MS-DFSNM / netdfs", + nxc_module="coerce_plus", + requires_credentials=False, + severity="info", + reference="https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/coerce_plus.py", + positive_status="exposed", + kind="exposure", + nxc_options=("METHOD=DFSCoerce",), + note="RPC bind exposure only; does not trigger or confirm coerced authentication.", + auth_notes="Anonymous binding is attempted; many hosts require domain credentials for method access.", + ), + "shadowcoerce": CheckDefinition( + key="shadowcoerce", + label="SHADOWCOERCE-RPC", + cves=(), + surface="MS-FSRVP / File Server VSS Agent", + nxc_module="coerce_plus", + requires_credentials=False, + severity="info", + reference="https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/coerce_plus.py", + positive_status="exposed", + kind="exposure", + nxc_options=("METHOD=ShadowCoerce",), + note="RPC bind exposure only; the File Server VSS Agent service must be available.", + auth_notes="Anonymous binding is attempted; service and policy commonly require credentials for method access.", + ), + "printerbug": CheckDefinition( + key="printerbug", + label="PRINTERBUG-RPC", + cves=(), + surface="MS-RPRN / Print Spooler", + nxc_module="coerce_plus", + requires_credentials=False, + severity="info", + reference="https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/coerce_plus.py", + positive_status="exposed", + kind="exposure", + nxc_options=("METHOD=PrinterBug",), + note="RPC bind exposure only; does not confirm notification privileges or relay feasibility.", + auth_notes="Anonymous binding is attempted; method authorization is not established by a bind.", + ), + "mseven": CheckDefinition( + key="mseven", + label="MSEVEN-RPC", + cves=(), + surface="MS-EVEN / Event Log RPC", + nxc_module="coerce_plus", + requires_credentials=False, + severity="info", + reference="https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/coerce_plus.py", + positive_status="exposed", + kind="exposure", + nxc_options=("METHOD=MSEven",), + note="RPC bind exposure only; outbound authentication is not requested or observed.", + auth_notes="Anonymous binding is attempted; method access depends on host policy and credentials.", + ), + "spooler": CheckDefinition( + key="spooler", + label="SPOOLER-RPC", + cves=(), + surface="MS-RPRN / endpoint mapper inventory", + nxc_module="spooler", + requires_credentials=False, + severity="info", + reference="https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/spooler.py", + positive_status="observed", + kind="info", + required_ports=(135, 445), + related_cves=("CVE-2021-1675", "CVE-2021-34527"), + note="Reports registered MS-RPRN endpoints; does not establish a PrintNightmare vulnerability.", + auth_notes="Endpoint lookup can be anonymous; this adapter uses the NetExec SMB login wrapper.", + ), + "oxid": CheckDefinition( + key="oxid", + label="OXID-INTERFACES", + cves=(), + surface="DCOM IObjectExporter / ServerAlive2", + nxc_module="ioxidresolver", + requires_credentials=False, + severity="info", + reference="https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/ioxidresolver.py", + positive_status="observed", + kind="info", + required_ports=(135, 445), + note="Returns advertised network interface IPs; addresses are evidence only and are never added to scan targets.", + auth_notes="The RPC request uses anonymous authentication over TCP/135; the NetExec SMB wrapper still needs to run.", + ), + "longlogon": CheckDefinition( + key="longlogon", + label="CVE-2026-41089/LONGLOGON", + cves=("CVE-2026-41089",), + surface="CLDAP / UDP389 Netlogon DC locator", + nxc_module="", + requires_credentials=False, + severity="info", + reference="https://github.com/ADScanPro/CVE-2026-41089-LongLogon/blob/5c0b86fb957e387c9c3c3f6d56f7d8ee35e159c2/longlogon.py", + positive_status="precondition-met", + kind="precondition", + required_ports=(), + backend="native", + note="Minimal-input naming precondition estimate, not patch detection. Included in recommended; no oversized user field is sent, and unusual baseline sizes skip the legacy query.", + auth_notes="Anonymous UDP/389; domain identity is discovered with an EX DC-locator query. Default discovery still requires TCP/135 or TCP/445 reachable.", + ), +} + +CREDENTIALLESS_CHECKS = ("ms17-010", "zerologon", "printnightmare", "smbghost") +# Backward-compatible name; this is an authentication preset, not a guarantee +# that every probe is low impact on every legacy implementation. +SAFE_CHECKS = CREDENTIALLESS_CHECKS +EXPOSURE_CHECKS = ("petitpotam", "dfscoerce", "shadowcoerce", "printerbug", "mseven") +INFO_CHECKS = ("spooler", "oxid") +RECOMMENDED_CHECKS = (*CREDENTIALLESS_CHECKS, *EXPOSURE_CHECKS, *INFO_CHECKS, "longlogon") +CHECK_PRESETS = { + "safe": CREDENTIALLESS_CHECKS, + "credentialless": CREDENTIALLESS_CHECKS, + "exposure": EXPOSURE_CHECKS, + "info": INFO_CHECKS, + "recommended": RECOMMENDED_CHECKS, +} + + +def resolve_checks(values: list[str]) -> list[CheckDefinition]: + requested: list[str] = [] + for value in values: + requested.extend(part.strip().lower() for part in value.split(",") if part.strip()) + unknown = sorted(set(requested) - set(CHECKS) - set(CHECK_PRESETS) - {"all"}) + if unknown: + raise ValueError(f"unknown check(s): {', '.join(unknown)}") + expanded: list[str] = [] + for key in requested: + if key == "all": + expanded.extend(CHECKS) + elif key in CHECK_PRESETS: + expanded.extend(CHECK_PRESETS[key]) + else: + expanded.append(key) + return [CHECKS[key] for key in dict.fromkeys(expanded)] + diff --git a/rpcscope/checks.py b/rpcscope/checks.py new file mode 100644 index 0000000..d34c517 --- /dev/null +++ b/rpcscope/checks.py @@ -0,0 +1,132 @@ +"""Interpret attributed checker messages; silence never means patched.""" +from __future__ import annotations + +import ipaddress +import re +from dataclasses import dataclass + +from .catalog import CHECKS + + +ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +@dataclass(frozen=True) +class CheckResult: + status: str + evidence: str + + +MessageIndex = dict[str, dict[tuple[str, int], list[str]]] + + +def index_messages(text: str) -> MessageIndex: + """Parse batch output once, not once per host/check pair.""" + result: MessageIndex = {} + for line in ANSI.sub("", text).splitlines(): + parts = line.split(None, 4) + if len(parts) != 5 or not parts[2].isdigit() or not 1 <= int(parts[2]) <= 65535: + continue + token = parts[0].casefold() + try: + address = str(ipaddress.ip_address(parts[1].strip("[]"))) + except ValueError: + continue + message = re.sub(r"^\[(?:\+|-|\*)\]\s*", "", parts[4]).strip() + result.setdefault(address, {}).setdefault((token, int(parts[2])), []).append(message) + return result + + +def _messages(text: str, module: str, host: str, ports: tuple[int, ...] = (445,), + index: MessageIndex | None = None) -> list[str]: + """Require module, canonical target address and allowed port attribution.""" + indexed = index if index is not None else index_messages(text) + address = str(ipaddress.ip_address(host)) + expected = module.casefold() + messages = [] + for (token, port), values in indexed.get(address, {}).items(): + prefix = token[:-3] if token.endswith("...") else token[:-1] if token.endswith("…") else "" + if port in ports and ( + token == expected or (len(prefix) >= 4 and expected.startswith(prefix)) + ): + messages.extend(values) + return messages + + +def classify_nxc(key: str, host: str, text: str, returncode: int = 0, *, + index: MessageIndex | None = None) -> CheckResult: + definition = CHECKS[key] + if returncode: + return CheckResult("inconclusive", f"checker exited with status {returncode}; inspect its log") + messages = _messages(text, definition.nxc_module, host, definition.required_ports, index) + if definition.kind == "exposure": + method = definition.nxc_options[0].partition("=")[2] + expected = f"VULNERABLE, {method}".casefold() + if any(message.casefold() == expected for message in messages): + return CheckResult("exposed", f"{method} RPC interface bind accepted; coercion and CVE exploitability were not tested") + return CheckResult("inconclusive", messages[-1] if messages else "no recognized target/module result; RPC bind access was not established") + if key == "spooler": + if any(message == "Spooler service enabled" for message in messages): + return CheckResult("observed", "MS-RPRN endpoint registered in the endpoint mapper; PrintNightmare patch status was not established") + return CheckResult("inconclusive", messages[-1] if messages else "no attributed MS-RPRN endpoint result") + if key == "oxid": + addresses: list[str] = [] + for message in messages: + if not message.startswith("Address:"): + continue + try: + address = str(ipaddress.ip_address(message.partition(":")[2].strip().rstrip("\x00"))) + except ValueError: + continue + if address not in addresses: + addresses.append(address) + if addresses: + return CheckResult("observed", "Advertised interface IPs: " + ", ".join(addresses)) + return CheckResult("inconclusive", "no valid attributed interface IPs returned by IObjectExporter") + positives: list[str] = [] + negatives: list[str] = [] + for message in messages: + if "[REMOVED]" in message or "module has been integrated" in message.casefold(): + return CheckResult("skipped-unsupported", message) + if key == "ms17-010": + if re.fullmatch(r"(?:\[\+\]\s*)?" + re.escape(host) + r" is likely VULNERABLE to MS17-010!.*", message, re.I) or message.upper() == "VULNERABLE": + positives.append(message) + elif re.fullmatch(re.escape(host) + r" does NOT appear vulnerable[.!]?", message, re.I): + negatives.append(message) + elif key == "zerologon" and message.upper() == "VULNERABLE": + positives.append(message) + elif key == "printnightmare": + if re.fullmatch(r"Vulnerable, next step https://github\.com/ly4k/PrintNightmare/?", message, re.I): + positives.append(message) + elif message.casefold() == "not vulnerable :'(" or message.casefold() == "not vulnerable": + negatives.append(message) + elif key == "smbghost" and message == "Potentially vulnerable to SMBGhost (CVE-2020-0796)": + positives.append(message) + elif key == "nopac" and message.upper() == "VULNERABLE": + positives.append(message) + if positives and negatives: + return CheckResult("inconclusive", "conflicting positive and negative checker messages") + if positives: + return CheckResult(definition.positive_status, positives[0]) + if negatives: + return CheckResult("not-vulnerable", negatives[0]) + if messages: + return CheckResult("inconclusive", messages[-1]) + return CheckResult("inconclusive", "no recognized target/module result; checker may not have run") + + +def classify_msf(host: str, text: str, returncode: int = 0) -> CheckResult: + if returncode: + return CheckResult("inconclusive", f"checker exited with status {returncode}; inspect its log") + lines = [line.strip() for line in ANSI.sub("", text).splitlines()] + positive = re.compile(r"^\[\+\]\s+" + re.escape(host) + r":445\s+-\s+Host is likely VULNERABLE to MS17-010!.*$", re.I) + negative = re.compile(r"^\[-\]\s+" + re.escape(host) + r":445\s+-\s+Host does NOT appear vulnerable\.?$", re.I) + positives = [line for line in lines if positive.fullmatch(line)] + negatives = [line for line in lines if negative.fullmatch(line)] + if positives and negatives: + return CheckResult("inconclusive", "conflicting positive and negative checker messages") + if positives: + return CheckResult("vulnerable", positives[0]) + if negatives: + return CheckResult("not-vulnerable", negatives[0]) + return CheckResult("inconclusive", "no recognized MS17-010 result for this target") diff --git a/rpcscope/cli.py b/rpcscope/cli.py new file mode 100644 index 0000000..2d780cc --- /dev/null +++ b/rpcscope/cli.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import argparse +import getpass +import json +import math +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +from . import __version__ +from .models import Report +from .reporting import address_key, coverage, minimal, write_json, write_markdown, write_minimal +from .rules import evaluate +from .runner import ScanConfig, analyze_artifacts, doctor, normalize_credentials, scan +from .catalog import CHECKS, resolve_checks +from .progress import ProgressReporter +from .storage import prepare_output +from .advisories import ADVISORIES, sync_advisories + + +def _positive_integer(value: str) -> int: + try: + number = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a positive integer") from exc + if number <= 0: + raise argparse.ArgumentTypeError("must be a positive integer") + return number + + +def _positive_seconds(value: str) -> float: + try: + number = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a positive number of seconds") from exc + if not math.isfinite(number) or number <= 0: + raise argparse.ArgumentTypeError("must be a positive number of seconds") + return number + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="rpcscope", + description="Normalize SMB/RPC enumeration and correlate defensible findings.", + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + sub = parser.add_subparsers(dest="command", required=True) + + scan_p = sub.add_parser("scan", help="collect evidence from authorized targets") + scan_p.add_argument("targets", nargs="+", help="IP address, hostname, CIDR, or NetExec target file") + scan_p.add_argument("-u", "--username", default="", help="SMB username (default: null/anonymous)") + scan_p.add_argument("-d", "--domain", default="", help="authentication domain") + password = scan_p.add_mutually_exclusive_group() + password.add_argument("-p", "--password", default="", help="SMB password (visible in shell history)") + password.add_argument("--password-prompt", action="store_true", help="prompt without echoing the password") + scan_p.add_argument("--shares", action="store_true", help="ask NetExec to enumerate shares") + scan_p.add_argument("-l", "--lhost", help="record callback/listener IP for future active checks") + scan_p.add_argument("--msf-ms17-010", action="store_true", help="run Metasploit's non-exploit MS17-010 scanner") + scan_p.add_argument( + "--checks", action="append", default=[], metavar="LIST", + help="checks or preset: recommended, credentialless, exposure, info, all; recommended includes the LongLogon precondition check (default: enumeration only)", + ) + scan_p.add_argument("--deep", action="store_true", help="add full enum4linux-ng collection after the dedicated checks") + scan_p.add_argument("--no-enum4linux", action="store_true", help="disable enum4linux-ng even with --deep") + scan_p.add_argument("--isolated-checks", action="store_true", help="diagnostic fallback: run each NetExec check separately per host") + scan_p.add_argument("--no-rpcdump", action="store_true") + scan_p.add_argument("-j", "--jobs", type=_positive_integer, default=8, help="maximum concurrent hosts (default: 8)") + scan_p.add_argument("--discovery-workers", type=_positive_integer, default=256, + help="lightweight TCP/SMB discovery concurrency, separate from --jobs (default: 256)") + scan_p.add_argument("--timeout", type=_positive_integer, default=120, help="per-collector/check timeout in seconds") + scan_p.add_argument("--discovery-timeout", type=_positive_integer, default=600, help="discovery timeout in seconds (default: 600)") + scan_p.add_argument("--discovery", choices=("tcp", "smb"), default="tcp", + help="independent TCP/135+445 discovery, or legacy SMB-only discovery (default: tcp)") + scan_p.add_argument("--connect-timeout", type=_positive_seconds, default=1.0, + help="TCP connection timeout in seconds (default: 1)") + scan_p.add_argument("--max-hosts", type=_positive_integer, default=4096, + help="maximum expanded targets with TCP discovery (default: 4096)") + scan_p.add_argument("-o", "--output", type=Path, default=Path("rpcscope-output")) + scan_p.add_argument("--dry-run", action="store_true", help="show the collector/check plan without creating output or sending traffic") + scan_p.add_argument("--progress", choices=("auto", "plain", "quiet"), default="auto", + help="live progress on stderr; auto adapts to terminals (default: auto)") + scan_p.add_argument("--no-progress", dest="progress", action="store_const", const="quiet") + scan_p.add_argument( + "--i-am-authorized", "--authorised", "--authorized", dest="authorized", action="store_true", + help="confirm authorization to send SMB/RPC and selected CLDAP traffic to every supplied target", + ) + + analyze = sub.add_parser("analyze", help="analyze existing output without network traffic") + analyze.add_argument("--nxc", type=Path, help="NetExec text output") + analyze.add_argument("--rpcdump-dir", type=Path, help="directory of rpcdump .txt files named with host IPs") + analyze.add_argument("--enum4linux-dir", type=Path, help="directory of enum4linux-ng JSON files") + analyze.add_argument("-o", "--output", type=Path, default=Path("rpcscope-analysis")) + + sub.add_parser("doctor", help="show external collector availability") + catalog = sub.add_parser("catalog", help="list implemented checks or review-only CVE advisories") + catalog.add_argument("--advisories", action="store_true", help="show review-only 2020–2026 CVEs without remote scanners") + sync = sub.add_parser("sync-cves", help="refresh curated Microsoft CNA advisory metadata only") + sync.add_argument("-o", "--output", type=Path, default=Path("rpcscope-advisories.json")) + return parser + + +def _write_report(output: Path, mode: str, targets: list[str], lhost: str | None, + principal: str | None, + hosts, errors: list[str], commands: list[list[str]]) -> Report: + output.mkdir(parents=True, exist_ok=True) + host_list = sorted(hosts.values(), key=lambda item: address_key(item.address)) + report = Report( + generated_at=datetime.now(timezone.utc).isoformat(), mode=mode, targets=targets, + lhost=lhost, principal=principal, hosts=host_list, findings=evaluate(host_list), errors=list(errors), + commands=list(commands), + ) + write_json(report, output / "report.json") + write_markdown(report, output / "report.md") + write_minimal(report, output / "report.txt") + return report + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + if args.command == "doctor": + for name, path in doctor().items(): + print(f"{name:16} {path or 'NOT FOUND'}") + return 0 + if args.command == "catalog": + if args.advisories: + for advisory in ADVISORIES.values(): + print(f"{advisory.key:16} {advisory.status:12} {advisory.title}") + print(f" {advisory.notes}") + return 0 + for definition in CHECKS.values(): + auth = "credentials" if definition.requires_credentials else "no credentials" + state = definition.positive_status if definition.enabled else "disabled" + print(f"{definition.key:18} {definition.label:32} {auth:14} {state:12} {definition.kind:13} {definition.surface}") + if definition.note: + print(f" {definition.note}") + if definition.auth_notes: + print(f" Authentication: {definition.auth_notes}") + return 0 + if args.command == "sync-cves": + try: + snapshots = sync_advisories(args.output) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"Updated {len(snapshots)} review-only advisory records: {args.output.resolve()}") + return 0 + + if args.command == "analyze": + if not any((args.nxc, args.rpcdump_dir, args.enum4linux_dir)): + print("error: provide at least one artifact source", file=sys.stderr) + return 2 + for source in (args.rpcdump_dir, args.enum4linux_dir): + if source and not source.is_dir(): + print(f"error: artifact directory does not exist: {source}", file=sys.stderr) + return 2 + errors: list[str] = [] + try: + hosts = analyze_artifacts(args.nxc, args.rpcdump_dir, args.enum4linux_dir, errors) + prepare_output(args.output) + report = _write_report(args.output, "offline-analysis", [], None, None, hosts, errors, []) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(minimal(report), end="") + print(f"Reports: {args.output.resolve()}", file=sys.stderr) + return 1 if errors else 0 + + if not args.authorized and not args.dry_run: + print("error: live collection requires --i-am-authorized", file=sys.stderr) + return 2 + password = getpass.getpass("SMB password: ") if args.password_prompt else args.password + try: + checks = resolve_checks(args.checks) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + config = ScanConfig( + targets=args.targets, username=args.username, password=password, domain=args.domain, + shares=args.shares, + lhost=args.lhost, jobs=args.jobs, discovery_workers=args.discovery_workers, + timeout=args.timeout, discovery_timeout=args.discovery_timeout, + tcp_discovery=args.discovery == "tcp", connect_timeout=args.connect_timeout, max_hosts=args.max_hosts, + use_enum4linux=args.deep and not args.no_enum4linux, use_rpcdump=not args.no_rpcdump, + batch_checks=not args.isolated_checks, + use_msf=args.msf_ms17_010, checks=checks, dry_run=args.dry_run, out_dir=args.output, + ) + try: + normalize_credentials(config) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + principal = f"{config.domain}\\{config.username}" if config.domain and config.username else (config.username or None) + last_checkpoint = float("-inf") + + def checkpoint(hosts) -> None: + nonlocal last_checkpoint + now = time.monotonic() + # At most two rewrites per second; avoid quadratic report work when + # hundreds of small host jobs finish together. + if now - last_checkpoint >= 0.5: + _write_report(args.output, "interrupted" if config.cancelled else "in-progress", + args.targets, args.lhost, principal, hosts, config.errors, config.commands) + last_checkpoint = now + + try: + with ProgressReporter(mode=args.progress) as progress: + hosts = scan(config, progress=progress, checkpoint=checkpoint) + if not args.dry_run: + report = _write_report(args.output, "interrupted" if config.cancelled else "live-scan", + args.targets, args.lhost, principal, hosts, config.errors, config.commands) + except KeyboardInterrupt: + print("Interrupted; existing report checkpoints were retained.", file=sys.stderr) + return 130 + except (OSError, RuntimeError, ValueError) as exc: + message = str(exc).replace(password, "") if password else str(exc) + print(f"error: {message}", file=sys.stderr) + return 1 + if args.dry_run: + print(json.dumps(config.commands, indent=2)) + return 0 + print(minimal(report), end="") + print(f"Reports: {args.output.resolve()}", file=sys.stderr) + if config.cancelled: + return 130 + incomplete = any(coverage(host)[2] for host in report.hosts) + return 1 if config.errors or incomplete else 0 + diff --git a/rpcscope/longlogon.py b/rpcscope/longlogon.py new file mode 100644 index 0000000..3812131 --- /dev/null +++ b/rpcscope/longlogon.py @@ -0,0 +1,350 @@ +"""Bounded CLDAP LongLogon precondition measurements, never a patch detector. + +The fixed, one-character queries and empirical length thresholds are adapted +from ADScanPro's MIT-licensed LongLogon tester; see THIRD_PARTY_NOTICES.md. +Only the EX control is sent before validating the identity and bounding the +minimal legacy response. No long user field or overflow payload is generated. +""" +from __future__ import annotations + +import ipaddress +import math +import re +import secrets +import socket +import struct +import time +from dataclasses import dataclass + +from .checks import CheckResult + + +_MAX_PACKET = 8192 +_BASELINE_LIMIT = 464 # 64-byte margin below the upstream 528-byte buffer. +_SCOPE = "empirical name-length precondition only; patch state is not determined" + + +class _Invalid(ValueError): + pass + + +class _Cancelled(Exception): + pass + + +class _Reader: + """Fixed-depth BER reader; indefinite and oversized lengths are rejected.""" + + def __init__(self, data: bytes): + self.data = data + self.pos = 0 + + def take(self, tag: int) -> bytes: + if self.pos + 2 > len(self.data) or self.data[self.pos] != tag: + raise _Invalid("unexpected or truncated BER tag") + self.pos += 1 + length = self.data[self.pos] + self.pos += 1 + if length & 0x80: + count = length & 0x7F + if not 1 <= count <= 2 or self.pos + count > len(self.data): + raise _Invalid("invalid BER length") + length = int.from_bytes(self.data[self.pos:self.pos + count], "big") + self.pos += count + end = self.pos + length + if end > len(self.data): + raise _Invalid("truncated BER value") + result = self.data[self.pos:end] + self.pos = end + return result + + def done(self) -> None: + if self.pos != len(self.data): + raise _Invalid("unexpected trailing BER fields") + + +def _tlv(tag: int, value: bytes) -> bytes: + size = len(value) + length = bytes([size]) if size < 128 else b"\x81" + bytes([size]) if size < 256 else b"\x82" + size.to_bytes(2, "big") + return bytes([tag]) + length + value + + +def _dns_domain(value: str) -> str: + """Accept a bounded DNS name, not LDAP syntax or an authentication domain.""" + try: + result = value.rstrip(".").encode("idna").decode("ascii").lower() + except (UnicodeError, AttributeError) as exc: + raise _Invalid("unsupported DNS domain") from exc + labels = result.split(".") + if not result or len(result) > 253 or any( + not 1 <= len(label) <= 63 + or re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", label) is None + for label in labels + ): + raise _Invalid("unsupported DNS domain") + return result + + +def _request(domain: str, message_id: int, *, extended: bool) -> bytes: + """Private fixed-shape ping: user is always x and NtVer is 0x06 or 0x02.""" + if not 1 <= message_id <= 127: + raise _Invalid("message ID outside fixed one-byte framing") + + def equality(name: bytes, value: bytes) -> bytes: + return _tlv(0xA3, _tlv(4, name) + _tlv(4, value)) + + terms = equality(b"DnsDomain", _dns_domain(domain).encode("ascii")) if domain else b"" + terms += equality(b"User", b"x") + terms += equality(b"NtVer", struct.pack(" bytes: + """Require the attributed Netlogon value, not merely any UDP response.""" + if not data or len(data) > _MAX_PACKET: + raise _Invalid("empty or oversized CLDAP response") + packet = _Reader(data) + value = None + done = False + count = 0 + while packet.pos < len(data): + count += 1 + if count > 2: + raise _Invalid("too many LDAP messages") + message = _Reader(packet.take(0x30)) + raw_id = message.take(2) + if not 1 <= len(raw_id) <= 4 or raw_id[0] & 0x80 or int.from_bytes(raw_id, "big") != message_id: + raise _Invalid("LDAP transaction ID mismatch") + if message.pos == len(message.data): + raise _Invalid("missing LDAP operation") + tag = message.data[message.pos] + operation = _Reader(message.take(tag)) + message.done() + if tag == 0x64 and value is None and not done: + if operation.take(4) != b"": + raise _Invalid("unexpected LDAP object name") + attributes = _Reader(operation.take(0x30)) + operation.done() + attribute = _Reader(attributes.take(0x30)) + attributes.done() + if attribute.take(4).lower() != b"netlogon": + raise _Invalid("missing Netlogon attribute") + values = _Reader(attribute.take(0x31)) + attribute.done() + value = values.take(4) + values.done() + elif tag == 0x65 and not done: + if operation.take(10) != b"\0": + raise _Invalid("LDAP server returned an error") + operation.take(4) # matchedDN + operation.take(4) # diagnosticMessage + operation.done() + done = True + else: + raise _Invalid("unexpected LDAP response operation") + if value is None: + raise _Invalid("no Netlogon search result") + return value + + +def _name(data: bytes, offset: int, limit: int) -> tuple[str, int]: + """Bounded RFC1035 name decompression, relative to the Netlogon blob.""" + labels = [] + end = None + seen = set() + total = 0 + for _ in range(128): + if offset in seen or not 0 <= offset < limit: + raise _Invalid("invalid DNS compression pointer") + seen.add(offset) + size = data[offset] + if size == 0: + return ".".join(labels), end if end is not None else offset + 1 + if size & 0xC0 == 0xC0: + if offset + 1 >= limit: + raise _Invalid("truncated DNS compression pointer") + pointer = ((size & 0x3F) << 8) | data[offset + 1] + if pointer >= offset: + raise _Invalid("forward DNS compression pointer") + if end is None: + end = offset + 2 + offset = pointer + continue + if size & 0xC0 or offset + 1 + size >= limit: + raise _Invalid("invalid DNS label") + label = data[offset + 1:offset + 1 + size].decode("utf-8") + if any(ord(char) < 32 or ord(char) == 127 or char == "." for char in label): + raise _Invalid("unsafe character in identity") + labels.append(label) + total += size + 1 + if total > 255: + raise _Invalid("oversized decompressed name") + offset += 1 + size + raise _Invalid("excessive DNS compression chain") + + +@dataclass(frozen=True) +class _Identity: + forest: str + domain: str + host: str + netbios_domain: str + netbios_host: str + guid: bytes + + +def _identity(names: list[str], guid: bytes) -> _Identity: + forest, domain, host, nb_domain, nb_host = names + # Unsupported naming forms are not a clean/precondition-negative verdict. + forest, domain, host = map(_dns_domain, (forest, domain, host)) + for name in (nb_domain, nb_host): + if not name or len(name.encode("utf-16-le")) > 30 or any(not char.isprintable() for char in name): + raise _Invalid("unsupported NetBIOS identity") + if not host.endswith("." + domain): + raise _Invalid("DNS host/domain mismatch") + return _Identity(forest, domain, host, nb_domain.casefold(), nb_host.casefold(), guid) + + +def _parse_ex(data: bytes) -> _Identity: + if len(data) < 40 or struct.unpack_from(" tuple[str, int]: + start = offset + while offset + 1 < limit and offset - start <= 260: + if data[offset:offset + 2] == b"\0\0": + value = data[start:offset].decode("utf-16-le") + if any(not char.isprintable() for char in value): + raise _Invalid("unsafe Unicode identity") + return value, offset + 2 + offset += 2 + raise _Invalid("unterminated or oversized Unicode name") + + +def _parse_legacy(data: bytes) -> _Identity: + if len(data) < 60 or struct.unpack_from("= len(data) - 16: + raise _Invalid("unexpected legacy echoed user or GUID") + guid = data[offset:offset + 16] + if data[offset + 16:offset + 32] != bytes(16): + raise _Invalid("nonzero legacy NullGuid") + offset += 32 + dns_names = [] + for _ in range(3): + name, offset = _name(data, offset, len(data) - 16) + dns_names.append(name) + if offset != len(data) - 16: + raise _Invalid("unexpected trailing legacy fields") + return _identity([*dns_names, unicode_names[2], unicode_names[0]], guid) + + +def _baseline_bound(identity: _Identity) -> int: + """Conservative uncompressed minimal-user wire size, including BER slack.""" + unicode_size = sum(len(name.encode("utf-16-le")) + 2 for name in + (identity.netbios_host, "x", identity.netbios_domain)) + dns_size = sum(len(name.encode("utf-8")) + 2 for name in + (identity.forest, identity.domain, identity.host)) + return 2 + unicode_size + 32 + dns_size + 16 + 64 + + +def _cancelled(cancel) -> bool: + return cancel is not None and cancel.is_set() + + +def _exchange(sock, request: bytes, timeout: float, cancel) -> bytes: + if _cancelled(cancel): + raise _Cancelled + deadline = time.monotonic() + timeout + sock.settimeout(min(timeout, 0.2)) + sock.send(request) + while True: + if _cancelled(cancel): + raise _Cancelled + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("UDP/389 response timed out") + sock.settimeout(min(remaining, 0.2)) + try: + response = sock.recv(_MAX_PACKET + 1) + if _cancelled(cancel): + raise _Cancelled + return response + except socket.timeout: + continue + + +def _result(domain: str, ex_length: int, legacy_length: int, bound: int) -> CheckResult: + estimate = legacy_length + 258 + status = "precondition-met" if estimate >= 536 else "precondition-borderline" if estimate >= 528 else "precondition-not-met" + return CheckResult(status, f"DNS domain={domain}; UDP/389 EX={ex_length} B; legacy={legacy_length} B; " + f"minimal-baseline upper bound={bound} B; projected full-user length={estimate} B " + f"(legacy datagram +258; thresholds 528/536 B); {_SCOPE}") + + +def probe_longlogon(address: str, *, timeout: float = 3.0, domain: str = "", cancel=None) -> CheckResult: + """Measure a validated minimal legacy response after a bounded EX control. + + A timeout, EX-only result, unknown format, or unsafe baseline is UNKNOWN. + Positive and negative preconditions both leave actual patch state unknown. + UDP is connected to the explicit literal target; discovered names never + become network targets. Random one-byte IDs retain the reference framing. + """ + lengths = "UDP/389 EX=not received; legacy=not sent" + if _cancelled(cancel): + return CheckResult("skipped-cancelled", "LongLogon check cancelled; no query sent") + try: + target = ipaddress.ip_address(address) + if not math.isfinite(timeout) or not 0 < timeout <= 60: + raise _Invalid("timeout must be between 0 and 60 seconds") + selected_domain = _dns_domain(domain) if domain else "" + ex_id = secrets.randbelow(127) + 1 + legacy_id = ex_id % 127 + 1 + family = socket.AF_INET6 if target.version == 6 else socket.AF_INET + peer = (str(target), 389, 0, 0) if target.version == 6 else (str(target), 389) + with socket.socket(family, socket.SOCK_DGRAM) as sock: + sock.connect(peer) + control = _exchange(sock, _request(selected_domain, ex_id, extended=True), timeout, cancel) + lengths = f"UDP/389 EX={len(control)} B; legacy=not sent" + identity = _parse_ex(_ldap_netlogon(control, ex_id)) + if selected_domain and selected_domain != identity.domain: + raise _Invalid("EX DNS domain does not match requested domain") + bound = _baseline_bound(identity) + lengths += f"; minimal-baseline upper bound={bound} B" + if bound > _BASELINE_LIMIT: + raise _Invalid(f"legacy query withheld: baseline guard exceeds {_BASELINE_LIMIT} B") + lengths = f"UDP/389 EX={len(control)} B; legacy=no valid response; minimal-baseline upper bound={bound} B" + legacy = _exchange(sock, _request(identity.domain, legacy_id, extended=False), timeout, cancel) + lengths = f"UDP/389 EX={len(control)} B; legacy={len(legacy)} B; minimal-baseline upper bound={bound} B" + if _parse_legacy(_ldap_netlogon(legacy, legacy_id)) != identity: + raise _Invalid("legacy/EX DNS, NetBIOS, or GUID identity mismatch") + if len(legacy) > bound: + raise _Invalid("legacy response exceeded guarded baseline estimate") + return _result(identity.domain, len(control), len(legacy), bound) + except _Cancelled: + return CheckResult("skipped-cancelled", f"{lengths}; LongLogon check cancelled; {_SCOPE}") + except (OSError, ValueError, UnicodeError, struct.error) as exc: + return CheckResult("inconclusive", f"{lengths}; {exc}; {_SCOPE}") diff --git a/rpcscope/models.py b/rpcscope/models.py new file mode 100644 index 0000000..b9c9394 --- /dev/null +++ b/rpcscope/models.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + + +@dataclass +class RpcInterface: + uuid: str + version: str = "" + protocol: str = "" + endpoint: str = "" + annotation: str = "" + provider: str = "" + bindings: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + self.uuid = self.uuid.casefold() + self.bindings = list(dict.fromkeys( + binding for binding in [self.endpoint, *self.bindings] if binding + )) + if self.bindings: + self.endpoint = self.bindings[0] + + +@dataclass +class Share: + name: str + access: list[str] = field(default_factory=list) + comment: str = "" + + def __post_init__(self) -> None: + self.access = sorted({permission.upper() for permission in self.access}) + + +@dataclass(frozen=True) +class CheckAttempt: + key: str + backend: str + status: str + evidence: str + source: str = "" + + +@dataclass +class HostEvidence: + address: str + hostname: str = "" + domain: str = "" + os: str = "" + signing: bool | None = None + smb1: bool | None = None + null_session: bool | None = None + guest_session: bool | None = None + shares: list[Share] = field(default_factory=list) + rpc_interfaces: list[RpcInterface] = field(default_factory=list) + checks: dict[str, str] = field(default_factory=dict) + sources: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + collection: dict[str, str] = field(default_factory=dict) + check_details: dict[str, str] = field(default_factory=dict) + services: dict[str, str] = field(default_factory=dict) + check_attempts: list[CheckAttempt] = field(default_factory=list) + + def record_boolean(self, attribute: str, value: bool | None) -> None: + """Keep conflicting observations visible instead of trusting input order.""" + if attribute not in {"signing", "smb1", "null_session", "guest_session"}: + raise ValueError(f"Unsupported boolean evidence field: {attribute}") + conflict = f"Conflicting {attribute} observations (true and false); verification required." + if conflict in self.notes: + setattr(self, attribute, None) + elif value is not None: + previous = getattr(self, attribute) + if previous is not None and previous != value: + setattr(self, attribute, None) + self.notes.append(conflict) + else: + setattr(self, attribute, value) + + def merge(self, other: "HostEvidence") -> None: + self.notes.extend(note for note in other.notes if note not in self.notes) + for attr in ("hostname", "domain", "os"): + if not getattr(self, attr) and getattr(other, attr): + setattr(self, attr, getattr(other, attr)) + for attr in ("signing", "smb1", "null_session", "guest_session"): + self.record_boolean(attr, getattr(other, attr)) + known_shares = {share.name.casefold(): share for share in self.shares} + for share in other.shares: + key = share.name.casefold() + if key in known_shares: + existing = known_shares[key] + existing.access = sorted(set(existing.access) | set(share.access)) + if not existing.comment: + existing.comment = share.comment + else: + existing = Share(share.name, list(share.access), share.comment) + self.shares.append(existing) + known_shares[key] = existing + + known_rpc = {(item.uuid.casefold(), item.version): item for item in self.rpc_interfaces} + for item in other.rpc_interfaces: + key = (item.uuid.casefold(), item.version) + if key in known_rpc: + existing = known_rpc[key] + existing.bindings = list(dict.fromkeys( + binding for binding in [existing.endpoint, *existing.bindings, + item.endpoint, *item.bindings] if binding + )) + if existing.bindings: + existing.endpoint = existing.bindings[0] + for attribute in ("protocol", "annotation", "provider"): + if not getattr(existing, attribute): + setattr(existing, attribute, getattr(item, attribute)) + else: + existing = RpcInterface(item.uuid, item.version, item.protocol, item.endpoint, + item.annotation, item.provider, list(item.bindings)) + self.rpc_interfaces.append(existing) + known_rpc[key] = existing + + # A later inconclusive run must never erase a confirmed positive result. + rank = {"vulnerable": 5, "suspected": 4, "exposed": 3, "observed": 3, + "not-vulnerable": 3, "precondition-met": 4, "precondition-borderline": 3, + "precondition-not-met": 3, "inconclusive": 2} + selected_incoming: set[str] = set() + for key, status in other.checks.items(): + previous = self.checks.get(key) + if {previous, status} == {"vulnerable", "not-vulnerable"}: + note = f"Conflicting {key} checker results (vulnerable and not-vulnerable); verify raw evidence." + if note not in self.notes: + self.notes.append(note) + if {previous, status} == {"precondition-met", "precondition-not-met"}: + note = f"Conflicting {key} checker results (precondition met and not met); verify raw evidence." + if note not in self.notes: + self.notes.append(note) + if previous is None or rank.get(status, 1) >= rank.get(previous, 1): + self.checks[key] = status + selected_incoming.add(key) + for key, detail in other.check_details.items(): + if key not in self.check_details or key in selected_incoming: + self.check_details[key] = detail + self.collection.update(other.collection) + self.services.update(other.services) + self.check_attempts.extend(other.check_attempts) + self.sources.extend(source for source in other.sources if source not in self.sources) + + +@dataclass +class Finding: + rule_id: str + host: str + title: str + severity: str + confidence: str + evidence: str + impact: str + recommendation: str + references: list[str] = field(default_factory=list) + + +@dataclass +class Report: + generated_at: str + mode: str + targets: list[str] + lhost: str | None + principal: str | None + hosts: list[HostEvidence] + findings: list[Finding] + errors: list[str] = field(default_factory=list) + commands: list[list[str]] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + diff --git a/rpcscope/netexec.py b/rpcscope/netexec.py new file mode 100644 index 0000000..6fc022e --- /dev/null +++ b/rpcscope/netexec.py @@ -0,0 +1,169 @@ +"""NetExec CLI compatibility and bounded, single-module batch planning. + +This module never imports NetExec or runs target traffic. Capability discovery +only invokes the installed executable's SMB help; execution stays in runner. +""" +from __future__ import annotations + +import ipaddress +import re +import subprocess +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +from .catalog import CHECKS, CheckDefinition, EXPOSURE_CHECKS +from .checks import ANSI, CheckResult +from .models import HostEvidence + + +@dataclass(frozen=True) +class NxcCapabilities: + flags: frozenset[str] = frozenset() + + @classmethod + def from_help(cls, text: str) -> "NxcCapabilities": + return cls(frozenset(re.findall(r"(? list[str]: + # Our adapters use on_login, not an administrator-only callback. + return ["--no-admin-check"] if "--no-admin-check" in self.flags else [] + + def readonly_shares(self) -> list[str] | None: + if "--shares" in self.flags and "--no-write-check" in self.flags: + return ["--shares", "--no-write-check"] + if "--shares" in self.flags and "--file-write-check" in self.flags: + # Current NetExec is ACL-based unless file writes are opted into. + return ["--shares"] + return None + + +@lru_cache(maxsize=8) +def _inspect(path: str, mtime: int, size: int) -> NxcCapabilities: + try: + result = subprocess.run([path, "smb", "--help"], stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=10, check=False) + except (OSError, subprocess.TimeoutExpired): + return NxcCapabilities() + if result.returncode or len(result.stdout) > 1024 * 1024: + return NxcCapabilities() + return NxcCapabilities.from_help(result.stdout.decode("utf-8", errors="replace")) + + +def inspect_capabilities(tool: str) -> NxcCapabilities: + try: + path = Path(tool).resolve() + stat = path.stat() + except OSError: + return NxcCapabilities() + return _inspect(str(path), stat.st_mtime_ns, stat.st_size) + + +def validate_literal_credentials(username: str, password: str) -> None: + # NetExec accepts credential files in the same arguments as literal values. + # Refuse ambiguity instead of unexpectedly expanding a single credential. + for label, value in (("username", username), ("password", password)): + try: + is_file = bool(value) and Path(value).is_file() + except OSError: + is_file = False + if is_file: + raise ValueError(f"the {label} matches an existing file; NetExec would interpret it as a credential list, not a literal") + + +@dataclass(frozen=True) +class CheckBatch: + checks: tuple[CheckDefinition, ...] + targets: tuple[str, ...] + module: str + options: tuple[str, ...] + + +def skip_reason(definition: CheckDefinition, host: HostEvidence, *, + tcp_discovery: bool, has_nxc: bool, username: str) -> CheckResult | None: + if not definition.enabled: + return CheckResult("skipped-unsupported", definition.note) + if tcp_discovery and any(host.services.get(str(port)) != "open" for port in definition.required_ports): + return CheckResult("skipped-service-unavailable", "required TCP listener(s) were not reachable") + if ipaddress.ip_address(host.address).version not in definition.ip_versions: + return CheckResult("skipped-unsupported", "this upstream checker does not support the target IP version") + if definition.backend == "netexec" and not has_nxc: + return CheckResult("skipped-missing-tool", "NetExec is not installed") + if definition.requires_credentials and not username: + return CheckResult("skipped-no-credentials", "this checker requires a username") + return None + + +def plan_batches(checks: list[CheckDefinition], hosts: dict[str, HostEvidence], *, + jobs: int, tcp_discovery: bool, has_nxc: bool, username: str + ) -> tuple[list[CheckBatch], list[tuple[str, CheckDefinition, CheckResult]]]: + """One module per batch: an exception cannot suppress unrelated modules. + + Coerce_plus's five bind-only methods may share METHOD=All *only* when all + five were selected and have identical eligible targets. No listener is set. + Each batch has at most jobs hosts (and at most 128 command-line targets), + so a single process timeout does not cover multiple queued host waves. + """ + if jobs < 1: + raise ValueError("jobs must be positive") + selected = {item.key: item for item in checks if item.backend == "netexec"} + eligible: dict[str, tuple[str, ...]] = {} + skipped = [] + for key, definition in selected.items(): + addresses = [] + for address, host in hosts.items(): + reason = skip_reason(definition, host, tcp_discovery=tcp_discovery, + has_nxc=has_nxc, username=username) + if reason: + skipped.append((address, definition, reason)) + else: + addresses.append(address) + eligible[key] = tuple(addresses) + combine_binds = all(selected.get(key) == CHECKS[key] for key in EXPOSURE_CHECKS) and all( + eligible[key] == eligible[EXPOSURE_CHECKS[0]] for key in EXPOSURE_CHECKS if key in eligible + ) + consumed = set() + batches = [] + limit = min(jobs, 128) + for key, definition in selected.items(): + if key in consumed: + continue + group = tuple(selected[item] for item in EXPOSURE_CHECKS) if combine_binds and key in EXPOSURE_CHECKS else (definition,) + consumed.update(item.key for item in group) + options = ("METHOD=All",) if len(group) > 1 else definition.nxc_options + targets = eligible[key] + for index in range(0, len(targets), limit): + batches.append(CheckBatch(group, targets[index:index + limit], definition.nxc_module, options)) + return batches, skipped + + +def host_failures(text: str, targets: tuple[str, ...]) -> set[str]: + """NetExec can catch a host exception yet exit zero. Do not call that success.""" + failures = set() + pattern = re.compile(r"(?:Exception while calling proto_flow\(\) on target|Exception for target|File not found error on target|NetBIOSTimeout on target|NetBIOSError on target|Target(?=\s+\S+\s+refused the connection))\s+(\S+)", re.I) + # Root-level host errors are not module rows or authentication echoes. + root_lines = "\n".join(line for line in ANSI.sub("", text).splitlines() + if not re.match(r"^\s*\S+\s+\S+\s+\d+\s+", line)) + for match in pattern.finditer(root_lines): + address = None + token = match.group(1) + for candidate in (token, token.removesuffix(":").removesuffix(",")): + try: + address = str(ipaddress.ip_address(candidate)) + break + except ValueError: + pass + if address is None: + continue + if address in targets: + failures.add(address) + return failures + + +def invocation_failed(text: str, targets: tuple[str, ...] = ()) -> bool: + """Recognize un-attributed startup/CLI failures even if a wrapper exits zero.""" + cleaned = re.sub(r"(?m)^\[\d{2}:\d{2}:\d{2}\]\s+(?:ERROR|CRITICAL)\s+", "", ANSI.sub("", text)) + if re.search(r"(?m)^(?:(?:nxc|netexec)(?:\.exe)?: error:|Module not found:|Failed to load protocol\b)", cleaned): + return True + return "\nTraceback (most recent call last):" in "\n" + cleaned and not host_failures(cleaned, targets) diff --git a/rpcscope/parsers.py b/rpcscope/parsers.py new file mode 100644 index 0000000..f466da3 --- /dev/null +++ b/rpcscope/parsers.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import ipaddress +import json +import re +from pathlib import Path +from typing import Any + +from .models import HostEvidence, RpcInterface, Share + + +IP_RE = re.compile(r"(?\S+)\s+(?P\d+)\s+\S+(?:\s+(?P.*))?$") +BINDING_RE = re.compile(r"(?i)^(?:ncacn_[a-z0-9_]+|ncadg_[a-z0-9_]+|ncalrpc):") +SHARE_PERMISSION = r"(?:READ|WRITE(?: \((?:ACL|SUBDIR)\))?)" +SHARE_ROW_RE = re.compile( + rf"(?i)^(.+?\S)\s+({SHARE_PERMISSION}(?:\s*,\s*{SHARE_PERMISSION})*)(?=\s|$)\s*(.*)$" +) + + +def _valid_ip(value: str) -> str | None: + try: + return str(ipaddress.ip_address(value)) + except ValueError: + return None + + +def _bool_token(line: str, name: str) -> bool | None: + match = re.search(rf"(?i)\({re.escape(name)}\s*:\s*(true|false)\)", line) + return None if not match else match.group(1).lower() == "true" + + +def parse_nxc(text: str, source: str = "nxc") -> dict[str, HostEvidence]: + """Parse stable facts from human-readable NetExec SMB output. + + NetExec's console format changes over time, so this intentionally extracts only + strongly labelled fields and retains the raw file as the evidence source. + """ + hosts: dict[str, HostEvidence] = {} + share_permission_columns: dict[str, int] = {} + for raw_line in text.splitlines(): + line = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", raw_line) + row = NXC_LINE_RE.match(line) + if not row: + continue + address = _valid_ip(row.group("address").strip("[]")) + port = row.group("port") + if not address or not 1 <= int(port) <= 65535: + continue + payload = row.group("payload") or "" + host = hosts.setdefault(address, HostEvidence(address=address, sources=[source])) + + if payload.startswith("[*]"): + if re.search(r"(?i)\((?:name|domain|signing|SMBv1):", payload) or re.match( + r"(?i)^\[\*\]\s+(?:Windows|Samba|Linux)\b", payload + ): + host.services[port] = "open" + for label, attr in (("name", "hostname"), ("domain", "domain")): + match = re.search(rf"(?i)\({label}:([^)]*)\)", payload) + if match and not getattr(host, attr): + setattr(host, attr, match.group(1).strip()) + for label, attr in (("signing", "signing"), ("SMBv1", "smb1"), + ("Null Auth", "null_session"), ("Guest Auth", "guest_session")): + host.record_boolean(attr, _bool_token(payload, label)) + os_match = re.search(r"(?i)^\[\*\]\s+(.+?)\s+\(name:", payload) + if os_match: + host.os = os_match.group(1).strip() + + if payload.startswith("[+]"): + host.services[port] = "open" + success = payload[3:].strip() + # Names such as 'annulled' or 'guestadmin', and passwords containing + # these words, are not evidence of anonymous or guest authentication. + if re.match(r"[^\\\s]*\\:(?:\s|$)", success): + host.record_boolean("null_session", True) + if re.match(r"(?i)[^\\\s]*\\guest(?::|\s|$)", success) or re.search( + r"(?i)(?:^|\s)\(Guest\)(?:\s|$)", success + ): + host.record_boolean("guest_session", True) + + # NetExec pads the share name to 15 columns. Remember an observed header + # for format changes, and reject remarks shifted past an empty access + # column even if the remark happens to start with READ or WRITE. + if re.match(r"(?i)^Share\s+Permissions\s+Remark\s*$", payload): + share_permission_columns[address] = payload.lower().index("permissions") + share_match = None + if not payload.startswith("["): + share_match = SHARE_ROW_RE.match(payload) + if share_match: + name = share_match.group(1).strip() + start = share_match.start(2) + padding = start - len(name) + expected = max(share_permission_columns.get(address, 16), len(name) + 1) + if (address in share_permission_columns or padding > 1) and start != expected: + continue + access = sorted({item.strip().upper() for item in share_match.group(2).split(",")}) + evidence = HostEvidence(address, shares=[ + Share(name, access, share_match.group(3).strip()) + ], services={port: "open"}) + host.merge(evidence) + return hosts + + +def parse_rpcdump(text: str, address: str, source: str = "rpcdump") -> HostEvidence: + host = HostEvidence(address=address, sources=[source]) + current: RpcInterface | None = None + pending_protocol = "" + pending_provider = "" + in_bindings = False + + for raw_line in text.splitlines(): + line = raw_line.strip() + protocol = re.match(r"(?i)Protocol\s*:\s*(.*)", line) + if protocol: + pending_protocol = protocol.group(1).strip() + pending_provider = "" + in_bindings = False + continue + provider = re.match(r"(?i)Provider\s*:\s*(.*)", line) + if provider: + pending_provider = provider.group(1).strip() + continue + uuid_match = UUID_RE.search(line) + if uuid_match and (uuid_match.start() == 0 or re.match(r"(?i)^(?:UUID|Interface)\b", line)): + version = "" + suffix = line[uuid_match.end():] + version_match = re.match(r"(?i)\s+(?:v(?:ersion)?\s*)?([0-9]+(?:\.[0-9]+)?)(?=\s|$)", suffix) + if version_match: + version = version_match.group(1) + annotation_text = line[uuid_match.end():].strip() + if version_match: + annotation_text = suffix[version_match.end():].strip() + current = RpcInterface( + uuid=uuid_match.group(1).lower(), + version=version, + protocol=pending_protocol, + provider=pending_provider, + annotation=annotation_text, + ) + host.rpc_interfaces.append(current) + in_bindings = False + continue + if re.match(r"(?i)Bindings?\s*:", line): + in_bindings = True + inline_binding = line.split(":", 1)[1].strip() + if current and BINDING_RE.match(inline_binding): + current.bindings.append(inline_binding) + if not current.endpoint: + current.endpoint = inline_binding + continue + if current and in_bindings and BINDING_RE.match(line): + if line not in current.bindings: + current.bindings.append(line) + if not current.endpoint: + current.endpoint = line + continue + annotation = re.match(r"(?i)Annotation\s*:\s*(.*)", line) + if current and annotation: + current.annotation = annotation.group(1).strip() + + normalized = HostEvidence(address=address, sources=[source]) + normalized.merge(host) + return normalized + + +def _mapping(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _first_string(*values: Any) -> str: + return next((value.strip() for value in values if isinstance(value, str) + and value.strip().casefold() not in {"", "unknown", "not supported"}), "") + + +def _share_access(value: Any) -> list[str]: + """Use explicit permission results, never key names or arbitrary prose.""" + if isinstance(value, dict): + access = [] + # Tree connection success is not proof that contents can be listed/read. + if str(value.get("listing", "")).casefold() == "ok" or value.get("read") is True: + access.append("READ") + if value.get("write") is True: + access.append("WRITE") + return access + if isinstance(value, str): + values = re.split(r"[,\s]+", value.upper()) + elif isinstance(value, list): + values = [item.upper() for item in value if isinstance(item, str)] + else: + values = [] + return sorted({item for item in values if item in {"READ", "WRITE"}}) + + +def parse_enum4linux(data: dict[str, Any], source: str) -> HostEvidence: + if not isinstance(data, dict): + raise ValueError("enum4linux-ng JSON must contain an object") + target = data.get("target", {}) if isinstance(data.get("target"), dict) else {} + address = str(target.get("host") or target.get("ip") or "unknown") + host = HostEvidence(address=address, sources=[source]) + + sessions = data.get("sessions", {}) + if isinstance(sessions, dict): + if isinstance(sessions.get("null"), bool): + host.null_session = sessions["null"] + if isinstance(sessions.get("guest"), bool): + host.guest_session = sessions["guest"] + + # enum4linux-ng uses descriptive keys with spaces. Read the known result + # sections so credentials/errors cannot accidentally become server facts. + dialects = _mapping(data.get("smb_dialects")) + supported = _mapping(dialects.get("Supported dialects")) + smb1 = supported.get("SMB 1.0") + if isinstance(smb1, bool): + host.smb1 = smb1 + elif dialects.get("SMB1 only") is True: + host.smb1 = True + required = dialects.get("SMB signing required") + if isinstance(required, bool): + host.signing = required + + # Compatibility with older normalized artifacts. Client 'ipc_signing' is + # deliberately excluded: it does not establish server signing requirements. + legacy_smb = _mapping(data.get("smb")) + legacy_smb1 = legacy_smb.get("smb1_supported") + if isinstance(legacy_smb1, bool): + host.record_boolean("smb1", legacy_smb1) + elif legacy_smb.get("smb1_only") is True: + host.record_boolean("smb1", True) + for key in ("signing_required", "smb_signing"): + if isinstance(legacy_smb.get(key), bool): + host.record_boolean("signing", legacy_smb[key]) + + os_info = _mapping(data.get("os_info")) + host.os = _first_string(os_info.get("OS"), os_info.get("Native OS"), + data.get("os_info"), data.get("os"), data.get("native_os")) + smb_domain = _mapping(data.get("smb_domain_info")) + rpc_domain = _mapping(data.get("rpc_domain_info")) + host.hostname = _first_string(smb_domain.get("NetBIOS computer name"), + smb_domain.get("FQDN"), data.get("hostname"), + data.get("netbios_name")) + host.domain = _first_string(rpc_domain.get("Domain"), smb_domain.get("NetBIOS domain name"), + smb_domain.get("Derived domain"), smb_domain.get("DNS domain"), + data.get("domain"), data.get("workgroup")) + + shares = data.get("shares") + if isinstance(shares, dict): + for name, details in shares.items(): + access: list[str] = [] + comment = "" + if isinstance(details, dict): + comment = str(details.get("comment") or "") + access = _share_access(details.get("access")) + host.shares.append(Share(str(name), sorted(set(access)), comment)) + return host + + +def load_enum4linux(path: Path) -> HostEvidence: + with path.open(encoding="utf-8", errors="replace") as handle: + return parse_enum4linux(json.load(handle), str(path)) + diff --git a/rpcscope/progress.py b/rpcscope/progress.py new file mode 100644 index 0000000..4c446aa --- /dev/null +++ b/rpcscope/progress.py @@ -0,0 +1,216 @@ +"""Small, thread-safe progress output for collectors and checks.""" + +from __future__ import annotations + +import re +import shutil +import sys +import threading +import time +import unicodedata +from dataclasses import dataclass +from typing import Callable, TextIO + + +@dataclass(frozen=True) +class ProgressEvent: + kind: str + stage: str + host: str = "" + status: str = "" + completed: int = 0 + total: int = 0 + detail: str = "" + duration: float = 0.0 + + +_ANSI_ESCAPE = re.compile( + r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[ -/]*[@-~])" +) + + +def _clean(value: str, limit: int = 240) -> str: + """Keep tool/target text from controlling the terminal or adding fake lines.""" + value = _ANSI_ESCAPE.sub("", str(value)) + value = "".join(" " if unicodedata.category(char).startswith("C") else char for char in value) + value = " ".join(value.split()) + return value if len(value) <= limit else value[: limit - 3] + "..." + + +def _elapsed(seconds: float) -> str: + seconds = max(0, int(seconds)) + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours}h {minutes:02d}m {seconds:02d}s" + if minutes: + return f"{minutes}m {seconds:02d}s" + return f"{seconds}s" + + +class ProgressReporter: + """Render events to stderr, with a heartbeat while subprocesses are quiet. + + ``auto`` uses a single-line dashboard on terminals and stable lines when + redirected. ``plain`` always emits stable lines; ``quiet`` emits nothing. + The context manager owns the heartbeat thread. Events can arrive from any + collector thread; task counts supplied by the caller never move backwards. + """ + + def __init__( + self, + stream: TextIO | None = None, + mode: str = "auto", + clock: Callable[[], float] = time.monotonic, + interval: float = 10.0, + ) -> None: + if mode not in {"auto", "plain", "quiet"}: + raise ValueError("progress mode must be auto, plain, or quiet") + if interval <= 0: + raise ValueError("progress interval must be positive") + self.stream = stream if stream is not None else sys.stderr + self.mode = mode + self.clock = clock + self.interval = interval + try: + self._tty = mode == "auto" and self.stream.isatty() + except (AttributeError, OSError): + self._tty = False + self._lock = threading.RLock() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._started_at = clock() + self._last_output_at = self._started_at + self._active: dict[tuple[str, str], float] = {} + self._completed = 0 + self._total = 0 + self._last_event: ProgressEvent | None = None + self._dashboard_visible = False + self._finished = False + self._closed = False + + def __enter__(self) -> ProgressReporter: + if self._thread is not None or self._closed: + raise RuntimeError("progress reporter contexts cannot be reused") + self._started_at = self.clock() + self._last_output_at = self._started_at + if self.mode != "quiet": + self._thread = threading.Thread( + target=self._heartbeat_loop, name="rpcscope-progress", daemon=True + ) + self._thread.start() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join() + with self._lock: + if self._dashboard_visible: + self._write("\n") + self._dashboard_visible = False + self._closed = True + + def __call__(self, event: ProgressEvent) -> None: + if self.mode == "quiet": + return + with self._lock: + if self._closed: + return + if event.kind not in {"start", "finish", "summary", "advance"}: + raise ValueError(f"unknown progress event kind: {event.kind!r}") + now = self.clock() + self._completed = max(self._completed, event.completed) + self._total = max(self._total, event.total, self._completed) + key = (event.stage, event.host) + if event.kind == "advance": + # Discovery can finish thousands of unreachable targets. Count + # their work without printing addresses or delaying heartbeats. + if event.host: + self._active.pop(key, None) + return + duration = event.duration + if event.kind == "start": + self._active[key] = now + self._finished = False + elif event.kind == "finish": + started = self._active.pop(key, None) + if duration <= 0 and started is not None: + duration = max(0.0, now - started) + else: + self._active.clear() + self._finished = True + self._last_event = event + if self._tty: + if event.kind in {"finish", "summary"}: + self._write("\r\x1b[2K" + self._event_line(event, duration, now) + "\n") + self._dashboard_visible = False + if event.kind != "summary": + self._render_locked(now) + else: + self._write(self._event_line(event, duration, now) + "\n") + self._last_output_at = now + + def _counts(self) -> str: + return f"[{self._completed}/{self._total}]" if self._total else f"[{self._completed}/?]" + + def _event_line(self, event: ProgressEvent, duration: float, now: float) -> str: + label = {"start": "START", "finish": "DONE", "summary": "SUMMARY"}[event.kind] + parts = [self._counts(), label, _clean(event.stage, 80)] + if event.host: + parts.append(_clean(event.host, 120)) + if event.status: + parts.append(_clean(event.status, 80)) + if event.kind == "finish": + parts.append(f"({max(0.0, duration):.1f}s)") + else: + parts.append(f"(elapsed {_elapsed(now - self._started_at)})") + line = " ".join(parts) + if event.detail: + line += " | " + _clean(event.detail) + return line + + def _dashboard(self, now: float) -> str: + parts = [self._counts(), "RUNNING" if self._active else "WAITING"] + if self._active: + stage, host = next(iter(self._active)) + parts.extend(filter(None, (_clean(stage, 80), _clean(host, 120)))) + if len(self._active) > 1: + parts.append(f"(+{len(self._active) - 1} active)") + elif self._last_event is not None: + parts.append(_clean(self._last_event.stage, 80)) + parts.append(f"(elapsed {_elapsed(now - self._started_at)})") + return " ".join(parts) + + def _render(self, heartbeat: bool = True) -> None: + """Render a heartbeat; factored out so timing is testable without sleeps.""" + with self._lock: + if self.mode == "quiet" or self._closed or self._finished or not self._active: + return + now = self.clock() + if heartbeat and not self._tty and now - self._last_output_at < self.interval: + return + self._render_locked(now) + + def _render_locked(self, now: float) -> None: + line = self._dashboard(now) + if self._tty: + width = max(1, shutil.get_terminal_size(fallback=(100, 24)).columns - 1) + self._write("\r\x1b[2K" + line[:width]) + self._dashboard_visible = True + else: + self._write(line + "\n") + self._last_output_at = now + + def _heartbeat_loop(self) -> None: + while not self._stop.wait(min(1.0, self.interval)): + self._render() + + def _write(self, value: str) -> None: + try: + self.stream.write(value) + self.stream.flush() + except (OSError, ValueError): + # Progress output must not abort collection if its stream closes. + self._closed = True + self._stop.set() diff --git a/rpcscope/reporting.py b/rpcscope/reporting.py new file mode 100644 index 0000000..908a7d4 --- /dev/null +++ b/rpcscope/reporting.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import json +import ipaddress +import re +from collections import Counter +from pathlib import Path + +from .models import Report +from .catalog import CHECKS +from .storage import atomic_text + + +def display(value: str) -> str: + """Untrusted names must not inject terminal lines or Markdown/HTML.""" + value = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", str(value)) + return " ".join("".join(char if char.isprintable() else " " for char in value).split()) + + +def address_key(value: str) -> tuple: + try: + address = ipaddress.ip_address(value) + return (address.version, int(address)) + except ValueError: + return (9, value.casefold()) + + +def _label(key: str) -> str: + return CHECKS[key].label if key in CHECKS else key.upper() + + +COMPLETED_CHECK_STATUSES = frozenset({ + "vulnerable", "not-vulnerable", "suspected", "exposed", "observed", "inconclusive", + "precondition-met", "precondition-not-met", "precondition-borderline", +}) + + +def _skipped(status: str) -> bool: + return status.startswith("skipped-") and status != "skipped-cancelled" + + +def reachable(host) -> bool: + """Preserve old/offline evidence without service discovery information.""" + return not host.services or any(state == "open" for state in host.services.values()) + + +def coverage(host) -> tuple[int, int, bool]: + """Count execution separately from certainty and checks that could not apply. + + A heuristic or inconclusive response still completes a check. A skipped + check has no execution to complete, so it is reported outside the fraction. + Notes contain useful context and are not themselves collection failures. + """ + completed = sum(status in COMPLETED_CHECK_STATUSES for status in host.checks.values()) + selected = sum(not _skipped(status) for status in host.checks.values()) + check_failed = any(status not in COMPLETED_CHECK_STATUSES and not _skipped(status) + for status in host.checks.values()) + collection_failed = any(status not in {"success", "missing", *COMPLETED_CHECK_STATUSES} + and not _skipped(status) for status in host.collection.values()) + return completed, selected, check_failed or collection_failed + + +def _code(value: str) -> str: + # HTML-escape '<' and '>' even inside code spans: some renderers turn inline + # HTML on by default. Backticks in a share name must not close the span. + return "`" + display(value).replace("&", "&").replace("<", "<").replace(">", ">").replace("`", "`") + "`" + + +def write_json(report: Report, path: Path) -> None: + atomic_text(path, json.dumps(report.as_dict(), indent=2, sort_keys=True) + "\n") + + +def markdown(report: Report, include_unreachable: bool = False) -> str: + visible_hosts = [host for host in report.hosts if include_unreachable or reachable(host)] + hidden = {host.address for host in report.hosts if not include_unreachable and not reachable(host)} + visible_findings = [finding for finding in report.findings if finding.host not in hidden] + counts = Counter(finding.severity for finding in visible_findings) + lines = [ + "# RPCScope report", + "", + f"Generated: {_code(report.generated_at)} ", + f"Mode: {_code(report.mode)} ", + f"Targets: {_code(', '.join(report.targets))} ", + f"Hosts observed: **{len(visible_hosts)}** ", + f"Unreachable targets omitted: **{len(hidden)}** (retained in `report.json`) ", + f"Findings: **{len(visible_findings)}** " + f"(critical {counts['critical']}, high {counts['high']}, medium {counts['medium']}, low {counts['low']})", + "", + "## Host summary", + "", + "```text", + minimal(report, include_unreachable=include_unreachable).rstrip() or "No reachable hosts observed.", + "```", + "", + "## Findings", + "", + ] + if not visible_findings: + lines.append("No rule matches were produced from the available evidence.") + for finding in visible_findings: + lines.extend([ + f"### [{finding.severity.upper()}] {_code(finding.title)}", + "", + f"- Host: {_code(finding.host)}", + f"- Rule: {_code(finding.rule_id)}", + f"- Confidence: `{finding.confidence}`", + f"- Evidence: {_code(finding.evidence)}", + f"- Impact: {finding.impact}", + f"- Recommendation: {finding.recommendation}", + ]) + if finding.references: + lines.append(f"- Reference: {finding.references[0]}") + lines.append("") + lines.extend(["## Host inventory", ""]) + for host in sorted(visible_hosts, key=lambda item: address_key(item.address)): + lines.append( + f"- {_code(host.address)} {_code(host.hostname or '-')} — {_code(host.os or 'OS unknown')}; " + f"{len(host.shares)} share(s), {len(host.rpc_interfaces)} RPC interface(s)" + ) + if host.checks: + for key, status in sorted(host.checks.items()): + detail = host.check_details.get(key, "") + lines.append(f" - {_code(_label(key))}: {_code(status)}" + (f" — {_code(detail)}" if detail else "")) + for note in host.notes: + lines.append(f" - Note: {_code(note)}") + for attempt in host.check_attempts: + lines.append(f" - Attempt: {_code(attempt.key)} / {_code(attempt.backend)} / {_code(attempt.status)} — {_code(attempt.evidence)}") + if host.services: + lines.append(" - TCP services: " + ", ".join(f"{_code(port)}: {_code(status)}" for port, status in sorted(host.services.items()))) + for source in host.sources: + lines.append(f" - Source: {_code(source)}") + if report.errors: + lines.extend(["", "## Collection notes", ""]) + lines.extend(f"- {_code(error)}" for error in report.errors) + return "\n".join(lines) + "\n" + + +def write_markdown(report: Report, path: Path) -> None: + atomic_text(path, markdown(report)) + + +def minimal(report: Report, include_unreachable: bool = False) -> str: + """One reachable host per line, distinguishing results from execution status.""" + lines: list[str] = [] + exposure_titles = { + "SMB-001": "SMBv1", + "SMB-002": "SMB-SIGNING-NOT-REQUIRED", + "SMB-003": "NULL-SESSION", + "SMB-004": "GUEST-SESSION", + "SMB-005": "SHARE-WRITE-CAPABILITY", + "SMB-006": "READABLE-SHARE", + "SMB-007": "LEGACY-OS", + } + registered_titles = { + "RPC-12345678": "MS-RPRN", "RPC-76F03F96": "MS-PAR", "RPC-C681D488": "EFSRPC", + "RPC-DF1941C5": "EFSRPC", "RPC-4FC742E0": "MS-DFSNM", "RPC-E3514235": "DRSUAPI", + "RPC-367ABB81": "SCMR", "RPC-12345778": "SAMR", + "RPC-NETLOGON": "MS-NRPC", "RPC-A8E0653C": "MS-FSRVP", + } + # Index findings once; avoid O(hosts * findings) on large inventories. + by_host: dict[str, list] = {} + for finding in report.findings: + by_host.setdefault(finding.host, []).append(finding) + for host in sorted(report.hosts, key=lambda item: address_key(item.address)): + if not include_unreachable and not reachable(host): + continue + identity = report.principal or host.domain + prefix = display(host.address) + (f" ({display(identity)})" if identity else "") + positives = [_label(key) for key, status in sorted(host.checks.items()) if status == "vulnerable"] + suspected = [_label(key) for key, status in sorted(host.checks.items()) if status == "suspected"] + exposed = [_label(key) for key, status in sorted(host.checks.items()) if status == "exposed"] + observed = [_label(key) for key, status in sorted(host.checks.items()) if status == "observed"] + unknown = [_label(key) for key, status in sorted(host.checks.items()) + if status in {"inconclusive", "unknown", "failed", "timeout"}] + preconditions = [("LONGLOGON" if key == "longlogon" else _label(key), status.removeprefix("precondition-").upper()) + for key, status in sorted(host.checks.items()) if status.startswith("precondition-")] + skipped = sum(_skipped(status) for status in host.checks.values()) + vulnerability_checks = {key: status for key, status in host.checks.items() + if key not in CHECKS or CHECKS[key].kind == "vulnerability"} + completed, selected, collection_incomplete = coverage(host) + pending = any(status == "pending" for status in [*host.checks.values(), *host.collection.values()]) + partial = completed != selected or collection_incomplete + if positives: + verdict = "VULN: " + " ".join(f"[{item}]" for item in positives) + elif not reachable(host): + verdict = "NO REACHABLE SMB/RPC SERVICES" + elif suspected: + verdict = "SUSPECTED: " + " ".join(f"[{item}]" for item in suspected) + elif (vulnerability_checks and all(status == "not-vulnerable" for status in vulnerability_checks.values()) + and not partial and not skipped and not unknown and not preconditions): + verdict = "NOT VULNERABLE (SELECTED CHECKS)" + elif pending: + verdict = "IN PROGRESS" + elif selected and not vulnerability_checks and not partial and not unknown and not preconditions: + verdict = "INVENTORY ONLY" + elif not selected and skipped: + verdict = "NO CHECKS RUN" + elif unknown or preconditions: + verdict = "CHECKED" + elif partial: + verdict = "PARTIAL RESULTS" + elif host.checks: + verdict = "CHECKED" + else: + verdict = "INVENTORY ONLY" + if suspected and positives: + verdict += " SUSPECTED: " + " ".join(f"[{item}]" for item in suspected) + if unknown: + verdict += " UNKNOWN: " + " ".join(f"[{item}]" for item in unknown) + if preconditions: + verdict += " PRECONDITION: " + " ".join(f"[{label}:{status}]" for label, status in preconditions) + exposures = sorted(set(exposed) | { + exposure_titles[finding.rule_id] + for finding in by_host.get(host.address, []) if finding.rule_id in exposure_titles + }) + if exposures: + verdict += " EXPOSURE: " + " ".join(f"[{item}]" for item in exposures) + registered = sorted({registered_titles[finding.rule_id] for finding in by_host.get(host.address, []) + if finding.rule_id in registered_titles}) + if registered: + verdict += " RPC: " + " ".join(f"[{item}]" for item in registered) + if observed: + verdict += " INFO: " + " ".join(f"[{item}]" for item in observed) + conflicts = sorted({key for key in host.checks + if any(note.startswith(f"Conflicting {key} checker results") for note in host.notes)}) + if conflicts: + verdict += " CONFLICT: " + " ".join(f"[{_label(key)}]" for key in conflicts) + if host.services: + verdict += " PORTS: " + ",".join(f"{port}/{status}" for port, status in sorted(host.services.items())) + if selected: + verdict += f" CHECKS: {completed}/{selected}" + if partial: + verdict += " (IN PROGRESS)" if pending else " (PARTIAL)" + elif partial: + verdict += " (IN PROGRESS)" if pending else " (PARTIAL)" + if skipped: + verdict += f" SKIPPED: {skipped}" + if report.mode == "interrupted": + verdict += " (INTERRUPTED)" + lines.append(f"{prefix} {verdict}") + return "\n".join(lines) + ("\n" if lines else "") + + +def write_minimal(report: Report, path: Path) -> None: + atomic_text(path, minimal(report)) + diff --git a/rpcscope/rules.py b/rpcscope/rules.py new file mode 100644 index 0000000..4da494f --- /dev/null +++ b/rpcscope/rules.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import re + +from .models import Finding, HostEvidence +from .catalog import CHECKS + + +RPC_KNOWLEDGE = { + "12345678-1234-abcd-ef00-01234567cffb": ( + "MS-NRPC Netlogon interface registered", + "info", + "Endpoint mapper advertises Netlogon RPC. Registration does not establish authentication policy or patch state for Zerologon or newer Netlogon advisories.", + "Review Netlogon access policy and current Microsoft updates, including CVE-2025-49716 and CVE-2026-41089; validate applicability independently.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-nrpc/", + ), + "a8e0653c-2744-4389-a61d-7373df8b2292": ( + "MS-FSRVP File Server VSS Agent interface registered", + "info", + "Endpoint mapper advertises the File Server VSS Agent RPC surface. A registration does not prove method access, coerced authentication, or a vulnerability.", + "Restrict the File Server VSS Agent service to its intended backup management systems and review whether it is required.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-fsrvp/", + ), + "12345678-1234-abcd-ef00-0123456789ab": ( + "MS-RPRN print spooler RPC interface registered", + "medium", + "Endpoint mapper advertises print-system RPC; registration alone does not prove network reachability or a vulnerability.", + "Disable the Print Spooler where it is not required and restrict RPC/SMB access to trusted management networks.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-rprn/", + ), + "76f03f96-cdfd-44fc-a22c-64950a001209": ( + "MS-PAR asynchronous print RPC interface registered", + "medium", + "Endpoint mapper advertises the asynchronous print interface; its reachability, authentication policy and patch state require separate verification.", + "Disable unnecessary print services and restrict access with host and network firewalls.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-par/", + ), + "c681d488-d850-11d0-8c52-00c04fd90f7e": ( + "EFSRPC interface registered", + "medium", + "Endpoint mapper advertises EFSRPC, an interface historically associated with authentication coercion; registration does not establish remote reachability or a working coercion path.", + "Restrict EFSRPC exposure to required administrators and segment SMB/RPC from untrusted clients.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-efsr/", + ), + "df1941c5-fe89-4e79-bf10-463657acf44d": ( + "EFSRPC interface registered", + "medium", + "Endpoint mapper advertises EFSRPC, an interface historically associated with authentication coercion; registration does not establish remote reachability or a working coercion path.", + "Restrict EFSRPC exposure to required administrators and segment SMB/RPC from untrusted clients.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-efsr/", + ), + "4fc742e0-4a10-11cf-8273-00aa004ae673": ( + "DFS namespace RPC interface registered", + "medium", + "Endpoint mapper advertises DFS namespace management RPC; registration alone does not establish remote reachability, coercion or management access.", + "Restrict DFS management RPC to authorized systems and verify current security updates.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-dfsnm/", + ), + "e3514235-4b06-11d1-ab04-00c04fc2dcd2": ( + "Directory Replication Service RPC interface registered", + "medium", + "Endpoint mapper advertises DRSUAPI, normally expected on domain controllers; network reachability and replication permissions require separate verification.", + "Limit domain-controller RPC access by network tier and monitor replication-rights assignments.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-drsr/", + ), + "367abb81-9844-35f1-ad32-98f038001003": ( + "Service Control Manager RPC interface registered", + "low", + "Endpoint mapper advertises service-management RPC; network reachability and permission to change services have not been established.", + "Restrict remote service administration to management hosts and privileged identities.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-scmr/", + ), + "12345778-1234-abcd-ef00-0123456789ac": ( + "SAMR interface registered", + "low", + "Endpoint mapper advertises SAMR; access to account or domain metadata depends on endpoint reachability and host authorization policy.", + "Block anonymous SAMR enumeration and limit RPC access to trusted management networks.", + "https://learn.microsoft.com/openspecs/windows_protocols/ms-samr/", + ), +} + + +def _finding(host: HostEvidence, rule_id: str, title: str, severity: str, evidence: str, + impact: str, recommendation: str, references: list[str] | None = None, + confidence: str = "high") -> Finding: + return Finding(rule_id, host.address, title, severity, confidence, evidence, impact, + recommendation, references or []) + + +def evaluate_host(host: HostEvidence) -> list[Finding]: + findings: list[Finding] = [] + if host.smb1 is True: + findings.append(_finding( + host, "SMB-001", "SMBv1 is enabled", "high", "SMBv1: true", + "SMBv1 is obsolete and lacks protections present in modern SMB dialects.", + "Disable SMBv1, validate application dependencies, and require SMB 2/3.", + ["https://learn.microsoft.com/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3"], + )) + if host.signing is False: + findings.append(_finding( + host, "SMB-002", "SMB signing is not required", "medium", "signing: false", + "Unsigned SMB sessions can increase relay and tampering risk in a compromised network.", + "Require SMB signing through centrally managed client and server policy.", + ["https://learn.microsoft.com/windows-server/storage/file-server/smb-signing"], + )) + if host.null_session is True: + findings.append(_finding( + host, "SMB-003", "Anonymous/null SMB session accepted", "medium", "null session: accepted", + "Anonymous users may enumerate information or reach shares/RPC interfaces allowed by host policy.", + "Disable unnecessary anonymous access and review RestrictAnonymous and named-pipe/share policies.", + )) + if host.guest_session is True: + findings.append(_finding( + host, "SMB-004", "Guest SMB session accepted", "medium", "guest session: accepted", + "Guest fallback can expose shares or metadata without an attributable identity.", + "Disable insecure guest logons unless explicitly required and tightly segmented.", + )) + + for share in host.shares: + if any(permission.startswith("WRITE") for permission in share.access): + title = f"Writable SMB share: {share.name}" + impact = "Writable network shares can enable malware staging, tampering, or lateral-movement workflows." + if "WRITE" not in share.access: + if "WRITE (ACL)" in share.access: + title = f"SMB share permission-control access: {share.name}" + impact = "The supplied identity can change permissions or ownership at the share root, potentially granting itself further access." + else: + title = f"SMB share directory-creation access: {share.name}" + impact = "The supplied identity can create subdirectories in the share; direct file-write access was not established." + findings.append(_finding( + host, "SMB-005", title, "high", + f"{share.name}: {', '.join(share.access)}", + impact, + "Remove unnecessary write access and apply least-privilege share and NTFS permissions.", + )) + elif "READ" in share.access and share.name.upper() not in {"IPC$"}: + findings.append(_finding( + host, "SMB-006", f"Readable SMB share: {share.name}", "low", + f"{share.name}: READ", + "Accessible shares may disclose operational or sensitive data.", + "Review exposed content and limit share and NTFS permissions to intended identities.", + )) + + if re.search(r"(?i)\bwindows\s+(?:xp|vista|7|8(?:\.1)?)(?![\w.])|\bserver\s+200(?:3|8)\b|\bsamba\s+[0-3]\.\d", host.os): + findings.append(_finding( + host, "SMB-007", "Legacy operating-system fingerprint", "high", host.os, + "The remote banner resembles a legacy platform with elevated patching and compatibility risk.", + "Verify the fingerprint, inventory support status, and migrate or isolate the system.", + confidence="medium", + )) + + advertised: dict[str, list[str]] = {} + binding_sets: dict[str, set[str]] = {} + registrations: dict[str, list[str]] = {} + for interface in host.rpc_interfaces: + uuid = interface.uuid.casefold() + advertised.setdefault(uuid, []) + binding_sets.setdefault(uuid, set()) + bindings = list(dict.fromkeys(binding for binding in [interface.endpoint, *interface.bindings] + if binding)) + registration = f"v{interface.version or '?'}" + if bindings: + registration += f" advertised via {'; '.join(bindings)}" + registrations.setdefault(uuid, []).append(registration) + for binding in bindings: + if binding and binding not in binding_sets[uuid]: + advertised[uuid].append(binding) + binding_sets[uuid].add(binding) + + seen: set[str] = set() + for interface in host.rpc_interfaces: + uuid = interface.uuid.casefold() + if uuid in seen or uuid not in RPC_KNOWLEDGE: + continue + seen.add(uuid) + title, severity, impact, recommendation, reference = RPC_KNOWLEDGE[uuid] + bindings = advertised[uuid] + local_only = bool(bindings) and all(binding.casefold().startswith("ncalrpc:") + for binding in bindings) + if local_only: + severity = "info" + title += " (local bindings only)" + findings.append(_finding( + host, "RPC-NETLOGON" if uuid == "12345678-1234-abcd-ef00-01234567cffb" else f"RPC-{uuid[:8].upper()}", title, severity, + f"{uuid}; {' | '.join(registrations[uuid])}; endpoint reachability untested", impact, + recommendation, [reference], confidence="medium", + )) + + for check_key, status in host.checks.items(): + status = status.casefold() + if status not in {"vulnerable", "suspected", "exposed", "observed", "precondition-met", "precondition-borderline"}: + continue + definition = CHECKS.get(check_key) + label = definition.label if definition else check_key.upper() + reference = [definition.reference] if definition else [] + severity = definition.severity if definition else "high" + suspected = status == "suspected" + if status.startswith("precondition-"): + findings.append(_finding( + host, f"CHECK-{check_key.upper()}", f"{label} naming precondition {status.removeprefix('precondition-')}", "info", + host.check_details.get(check_key) or f"{check_key}: {status}", + "An empirical response-size estimate suggests a naming precondition may apply. It does not establish patch state or a working vulnerability.", + "Verify installed security updates through trusted host inventory; review the vendor advisory independently of the naming estimate.", + reference, confidence="low", + )) + continue + if status in {"exposed", "observed"}: + findings.append(_finding( + host, f"CHECK-{check_key.upper()}", f"{label} {'interface exposed' if status == 'exposed' else 'inventory observed'}", "info", + host.check_details.get(check_key) or f"{check_key}: {status}", + definition.note if definition else "The checker returned interface or inventory information.", + "Review whether this interface and its network exposure are required; validate authorization and patch policy separately.", + reference, + )) + continue + title = (f"{label} indicator requires validation" if suspected + else f"{label} check reported vulnerable") + if suspected: + impact = (definition.note if definition and definition.note else + "The checker returned an indicator that requires validation of patch state and prerequisites.") + recommendation = "Verify installed security updates, configuration and checker prerequisites before assigning a vulnerability verdict." + else: + impact = "The dedicated checker returned its positive vulnerability marker." + recommendation = "Isolate or restrict the host, confirm patch state, remediate using the vendor advisory, and investigate possible exposure." + findings.append(_finding( + host, f"CHECK-{check_key.upper()}", title, severity, + host.check_details.get(check_key) or f"{check_key}: {status}", + impact, + recommendation, + reference, + confidence="low" if suspected else "high", + )) + return findings + + +def evaluate(hosts: list[HostEvidence]) -> list[Finding]: + order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + return sorted( + (finding for host in hosts for finding in evaluate_host(host)), + key=lambda item: (order.get(item.severity, 9), item.host, item.rule_id), + ) + diff --git a/rpcscope/runner.py b/rpcscope/runner.py new file mode 100644 index 0000000..cfaf075 --- /dev/null +++ b/rpcscope/runner.py @@ -0,0 +1,719 @@ +from __future__ import annotations + +import ipaddress +import json +import math +import os +import re +import shutil +import signal +import subprocess +import tempfile +import threading +import time +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from .catalog import CheckDefinition +from .checks import CheckResult, classify_msf, classify_nxc, index_messages +from .longlogon import probe_longlogon +from .models import CheckAttempt, HostEvidence +from .netexec import (NxcCapabilities, host_failures, inspect_capabilities, invocation_failed, + plan_batches, skip_reason, validate_literal_credentials) +from .parsers import load_enum4linux, parse_enum4linux, parse_nxc, parse_rpcdump +from .progress import ProgressEvent +from .reporting import coverage +from .storage import atomic_text, prepare_output, redact_text, write_sanitized_json +from .targets import discover_services, expand_targets + + +@dataclass +class ScanConfig: + targets: list[str] + username: str = "" + password: str = field(default="", repr=False) + domain: str = "" + shares: bool = False + lhost: str | None = None + jobs: int = 8 + discovery_workers: int = 256 + timeout: int = 120 + discovery_timeout: int = 600 + tcp_discovery: bool = False + batch_checks: bool = False # CLI uses batches; preserve isolated library behavior. + connect_timeout: float = 1.0 + max_hosts: int = 4096 + use_enum4linux: bool = True + use_rpcdump: bool = True + use_msf: bool = False + checks: list[CheckDefinition] = field(default_factory=list) + dry_run: bool = False + out_dir: Path = Path("rpcscope-output") + commands: list[list[str]] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + cancelled: bool = False + + +def _which(*names: str) -> str | None: + return next((found for name in names if (found := shutil.which(name))), None) + + +def doctor() -> dict[str, str | None]: + return { + "nxc": _which("nxc", "netexec"), + "enum4linux-ng": _which("enum4linux-ng", "enum4linux-ng.py"), + "rpcdump": _which("impacket-rpcdump", "rpcdump.py", "rpcdump"), + "msfconsole": _which("msfconsole"), + } + + +def _redacted(command: list[str], password: str) -> list[str]: + redacted = [redact_text(item, password) for item in command] + if "-x" in command: + index = command.index("-x") + 1 + if index < len(redacted): + redacted[index] = "" + return redacted + + +def _terminate(process: subprocess.Popen) -> None: + """Stop the collector and its children, including inherited log handles.""" + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.poll() is None: + try: + subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5, check=False) + except (OSError, subprocess.TimeoutExpired): + process.kill() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +def _run(command: list[str], timeout: int, log: Path, password: str, + cancel: threading.Event | None = None) -> tuple[int, str]: + """Spool output to disk; avoid pipe deadlocks and retain timed-out output.""" + with tempfile.TemporaryFile() as capture: + limit = 16 * 1024 * 1024 + kwargs = {"start_new_session": True} if os.name == "posix" else { + "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP, + } + try: + process = subprocess.Popen(command, stdout=capture, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + env={**os.environ, "NO_COLOR": "1", "COLUMNS": "512", "PYTHONUNBUFFERED": "1"}, + **kwargs) + except OSError as exc: + text = f"Could not start collector: {exc}\n" + text = redact_text(text, password) + atomic_text(log, text) + return 127, text + deadline = time.monotonic() + timeout + reason = "" + try: + while process.poll() is None: + if cancel and cancel.is_set(): + reason = "RPCScope collection cancelled" + break + if os.fstat(capture.fileno()).st_size > limit: + reason = "RPCScope output limit (16 MiB) reached" + break + remaining = deadline - time.monotonic() + if remaining <= 0: + reason = f"RPCScope timeout after {timeout}s" + break + try: + process.wait(timeout=min(0.2, remaining)) + except subprocess.TimeoutExpired: + pass + except KeyboardInterrupt: + if cancel: + cancel.set() + reason = "RPCScope collection cancelled" + except BaseException: + _terminate(process) + raise + if reason: + _terminate(process) + capture.seek(0) + data = capture.read(limit + 1) + rc = process.returncode or 0 + if len(data) > limit: + data = data[:limit] + if "output limit" not in reason: + reason += "\nRPCScope output limit (16 MiB) reached" + rc = 125 + if reason: + rc = 130 if cancel and cancel.is_set() else (rc if rc == 125 else 124) + text = data.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n") + if reason: + text += "\n" + reason.strip() + "\n" + saved = redact_text(text, password) + atomic_text(log, saved) + return rc, text + + +def normalize_credentials(config: ScanConfig) -> None: + for separator in ("\\", "/"): + if separator in config.username: + domain, username = config.username.split(separator, 1) + if config.domain and config.domain.casefold() != domain.casefold(): + raise ValueError("domain conflicts with the domain in the username") + config.domain, config.username = domain, username + break + if config.password and not config.username: + raise ValueError("a password requires a username") + if any(ord(char) < 32 for char in config.username + config.domain + config.password): + raise ValueError("credentials cannot contain control characters") + + +def _nxc_auth(config: ScanConfig) -> list[str]: + # Equals form preserves empty credentials and passwords beginning with '-'. + result = [f"-u={config.username}", f"-p={config.password}"] + if config.domain: + result.extend(["-d", config.domain]) + return result + + +def _msf_command(tool: str, address: str, config: ScanConfig) -> list[str]: + def quote(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + settings = ["use auxiliary/scanner/smb/smb_ms17_010", f"set RHOSTS {address}"] + if config.username: + settings.extend([f"set SMBUser {quote(config.username)}", f"set SMBPass {quote(config.password)}"]) + if config.domain: + settings.append(f"set SMBDomain {quote(config.domain)}") + settings.extend(["run", "exit -y"]) + return [tool, "-q", "-x", "; ".join(settings)] + + +def _record_check(host: HostEvidence, key: str, outcome: CheckResult, source: Path, + backend: str, password: str) -> None: + detail = redact_text(outcome.evidence, password) + saved = str(source) if source.exists() else "" + host.merge(HostEvidence(host.address, checks={key: outcome.status}, sources=[saved] if saved else [], + check_details={key: detail}, + check_attempts=[CheckAttempt(key, backend, outcome.status, detail, saved)])) + + +def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None = None, + checkpoint: Callable[[dict[str, HostEvidence]], None] | None = None) -> dict[str, HostEvidence]: + for value in (config.jobs, config.discovery_workers, config.timeout, config.discovery_timeout, config.max_hosts): + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError("workers, jobs, execution timeouts and host limits must be positive integers") + if (isinstance(config.connect_timeout, bool) or not isinstance(config.connect_timeout, (int, float)) + or not math.isfinite(config.connect_timeout) or config.connect_timeout <= 0): + raise ValueError("connection timeout must be positive and finite") + normalize_credentials(config) + # Public API callers may supply duplicate definitions; account/execute once. + unique_checks = {} + for definition in config.checks: + if definition.key in unique_checks and unique_checks[definition.key] != definition: + raise ValueError(f"conflicting definitions for check {definition.key}") + unique_checks[definition.key] = definition + config.checks = list(unique_checks.values()) + tools = doctor() + nxc = str(tools["nxc"] or "nxc") + discovery = [nxc, "smb", *config.targets, *_nxc_auth(config), "-t", str(config.discovery_workers), "--no-progress"] + if config.shares: + discovery.extend(["--shares", ""]) + if config.dry_run: + if config.tcp_discovery: + config.commands.append(_redacted(["", *config.targets, "--ports", "135,445", + "--workers", str(config.discovery_workers), "--connect-timeout", str(config.connect_timeout), + "--max-hosts", str(config.max_hosts)], config.password)) + discovery = [nxc, "smb", "", *_nxc_auth(config), "-t", str(config.discovery_workers), "--no-progress", + *(["--shares", ""] if config.shares else [])] + config.commands.append(_redacted(discovery, config.password)) + if config.dry_run: + address = "" + if config.batch_checks: + dummy = HostEvidence("192.0.2.255", services={"135": "open", "445": "open"}) + batches, _ = plan_batches(config.checks, {dummy.address: dummy}, jobs=config.jobs, + tcp_discovery=True, has_nxc=True, username=config.username) + for batch in batches: + config.commands.append(_redacted([nxc, "smb", "", *_nxc_auth(config), + "-t", str(config.jobs), "--no-progress", "-M", batch.module, + *(["-o", *batch.options] if batch.options else [])], config.password)) + for definition in config.checks: + if definition.enabled and definition.backend == "native": + config.commands.append(["", address, "UDP/389", "EX-control+guarded-minimal-legacy"]) + elif not config.batch_checks and definition.enabled and (not definition.requires_credentials or config.username): + config.commands.append(_redacted([nxc, "smb", address, *_nxc_auth(config), + "-M", definition.nxc_module, + *(["-o", *definition.nxc_options] if definition.nxc_options else [])], config.password)) + if config.use_msf: + config.commands.append([str(tools["msfconsole"] or "msfconsole"), "-q", "-x", + ""]) + if config.use_rpcdump: + config.commands.append([str(tools["rpcdump"] or "impacket-rpcdump"), "-port", "135", address]) + if config.use_enum4linux: + config.commands.append(_redacted([ + str(tools["enum4linux-ng"] or "enum4linux-ng"), "-A", f"-u={config.username}", + f"-p={config.password}", *(["-w", config.domain] if config.domain else []), + "-oJ", "", address, + ], config.password)) + return {} + if not tools["nxc"] and not config.tcp_discovery: + raise RuntimeError("NetExec was not found (expected nxc or netexec in PATH)") + if tools["nxc"]: + validate_literal_credentials(config.username, config.password) + scope = expand_targets(config.targets, max_hosts=config.max_hosts) if config.tcp_discovery else [] + prepare_output(config.out_dir) + raw = config.out_dir / "raw" + raw.mkdir(mode=0o700) + cancel = threading.Event() + lock = threading.Lock() + total = len(scope) + 1 if config.tcp_discovery else 1 + completed = 0 + + def emit(kind: str, stage: str, host: str = "", status: str = "", detail: str = "", duration: float = 0.0) -> None: + nonlocal completed + with lock: + if kind in {"finish", "advance"}: + completed += 1 + if progress: + progress(ProgressEvent(kind, stage, host, status, completed, total, detail, duration)) + + def error(message: str) -> None: + with lock: + message = redact_text(message, config.password) + if message not in config.errors: + config.errors.append(message) + + capabilities: NxcCapabilities | None = None + + def nxc_command(addresses, threads: int, *, shares: bool = False) -> list[str]: + nonlocal capabilities + if capabilities is None: + capabilities = inspect_capabilities(nxc) if tools["nxc"] else NxcCapabilities() + command = [nxc, "smb", *addresses, *_nxc_auth(config), "-t", str(threads), "--no-progress", + *capabilities.performance_options()] + if shares: + share_options = capabilities.readonly_shares() + if share_options is None: + error("Share enumeration skipped: installed NetExec help does not establish read-only share-check support") + else: + command.extend(share_options) + return command + + def run(stage: str, address: str, command: list[str], path: Path, timeout: int) -> tuple[int, str]: + emit("start", stage, address) + with lock: + if command != discovery: + config.commands.append(_redacted(command, config.password)) + code, text = _run(command, timeout, path, config.password, cancel=cancel) + if code: + error(f"{stage}{' for ' + address if address else ''} exited with status {code}; see {path.name}") + return code, text + + discovery_start = time.monotonic() + if config.tcp_discovery: + hosts = {address: HostEvidence(address, services={"135": "unprobed", "445": "unprobed"}) + for address in scope} + emit("start", "tcp-discovery", detail=f"{len(scope)} target(s); TCP/135 and TCP/445; {config.discovery_workers} workers") + + def discovered(address: str, services: dict[int, str]) -> None: + hosts[address].services.update({str(port): state for port, state in services.items()}) + if "open" in services.values(): + emit("finish", "tcp-discovery", address, "alive", + detail=" ".join(f"{port}:{state}" for port, state in services.items())) + else: + emit("advance", "tcp-discovery") + + discovery_failed = False + try: + discover_services(scope, jobs=config.discovery_workers, timeout=config.connect_timeout, + cancel=cancel, on_result=discovered, + deadline=discovery_start + config.discovery_timeout) + except KeyboardInterrupt: + cancel.set() + except (OSError, RuntimeError) as exc: + discovery_failed = True + error(f"TCP discovery failed locally: {exc}") + # NetExec discovery gets only SMB listeners. RPC-only hosts stay in scope. + smb_targets = [address for address, host in hosts.items() if host.services.get("445") == "open"] + rc = 1 if discovery_failed else 0 + if (any("unprobed" in host.services.values() for host in hosts.values()) + and not cancel.is_set() and not discovery_failed): + error("TCP discovery deadline reached; unprobed targets remain incomplete") + rc = 124 + emit("summary", "tcp-discovery", status="complete" if rc == 0 else "incomplete", + detail=f"{sum('open' in host.services.values() for host in hosts.values())} alive; {len(smb_targets)} SMB listener(s)") + emit("start", "discovery", detail=f"{len(smb_targets)} SMB listener(s)") + if smb_targets and not tools["nxc"]: + error("NetExec was not found; SMB collection/checks are incomplete") + rc = 127 + for address in smb_targets: + hosts[address].collection["discovery"] = "missing" + elif smb_targets and not cancel.is_set(): + for index in range(0, len(smb_targets), 128): + batch = smb_targets[index:index + 128] + command = nxc_command(batch, config.discovery_workers, shares=config.shares) + remaining = config.discovery_timeout - int(time.monotonic() - discovery_start) + if remaining <= 0: + error("Discovery timeout reached; partial host inventory retained") + rc = 124 + break + path = raw / f"nxc-{index // 128 + 1:03d}.txt" + # One discovery stage may have several command-line-sized batches. + config.commands.append(_redacted(command, config.password)) + code, output = _run(command, remaining, path, config.password, cancel=cancel) + if not code and invocation_failed(output, tuple(batch)): + code = 1 + error(f"SMB discovery invocation reported an error; see {path.name}") + rc = code or rc + failed_hosts = host_failures(output, tuple(batch)) + for address in batch: + hosts[address].collection["discovery"] = "success" if code == 0 and address not in failed_hosts else "failed" + if address in failed_hosts: + error(f"NetExec caught an exception for {address} during discovery; see {path.name}") + rc = rc or 1 + for address, evidence in parse_nxc(output, str(path)).items(): + if address in batch: + hosts[address].merge(evidence) + if code: + error(f"SMB discovery batch exited with status {code}; see {path.name}") + if cancel.is_set(): + break + for host in hosts.values(): + host.collection["tcp-discovery"] = ( + "success" if "unprobed" not in host.services.values() + else "incomplete" + ) + if "open" in host.services.values(): + host.collection.setdefault("discovery", "pending" if host.services.get("445") == "open" + else "skipped-service-unavailable") + else: + discovery = nxc_command(config.targets, config.discovery_workers, shares=config.shares) + config.commands.append(_redacted(discovery, config.password)) + rc, output = run("discovery", "", discovery, raw / "nxc.txt", config.discovery_timeout) + parsed_discovery = parse_nxc(output, str(raw / "nxc.txt")) + if not rc and invocation_failed(output, tuple(parsed_discovery)): + rc = 1 + error("SMB discovery invocation reported an error; see nxc.txt") + hosts = {address: host for address, host in parsed_discovery.items() + if "open" in host.services.values()} + failed_hosts = host_failures(output, tuple(hosts)) + for address, host in hosts.items(): + host.collection["discovery"] = "success" if rc == 0 and address not in failed_hosts else "failed" + if address in failed_hosts: + error(f"NetExec caught an exception for {address} during discovery; see nxc.txt") + active_hosts = {address: host for address, host in hosts.items() + if not config.tcp_discovery or "open" in host.services.values()} + for host in active_hosts.values(): + if config.shares and capabilities is not None and capabilities.readonly_shares() is None: + host.collection["shares"] = "skipped-unsupported" + host.notes.append("NetExec share enumeration was skipped because a read-only mode could not be verified.") + if config.use_enum4linux: + host.collection["enum4linux"] = "pending" + if config.use_rpcdump: + host.collection["rpcdump"] = "pending" + if config.use_msf: + host.collection["msf-ms17-010"] = "pending" + host.checks.update({definition.key: "pending" for definition in config.checks}) + if config.use_msf: + host.checks.setdefault("ms17-010", "pending") + per_host = int(config.use_enum4linux) + int(config.use_rpcdump) + len(config.checks) + int(config.use_msf) + total += len(active_hosts) * per_host + emit("finish", "discovery", status="success" if rc == 0 else "failed", + detail=f"{len(active_hosts)} alive; {len(hosts) - len(active_hosts)} unreachable omitted", + duration=time.monotonic() - discovery_start) + if checkpoint: + checkpoint(hosts) + if cancel.is_set(): + config.cancelled = True + error("Scan interrupted during discovery; partial evidence was retained") + for host in hosts.values(): + host.checks = {key: "skipped-cancelled" for key in host.checks} + host.collection = {key: "skipped-cancelled" if status == "pending" else status + for key, status in host.collection.items()} + if checkpoint: + checkpoint(hosts) + emit("summary", "scan", status="interrupted", detail=f"{len(hosts)} partial target(s)") + return hosts + if not active_hosts: + emit("summary", "scan", status="incomplete" if rc else "complete", detail="no reachable SMB/RPC hosts observed") + return hosts + smb_available = not config.tcp_discovery or any(host.services.get("445") == "open" for host in hosts.values()) + rpc_available = not config.tcp_discovery or any(host.services.get("135") == "open" for host in hosts.values()) + for tool, enabled in [("enum4linux-ng", config.use_enum4linux and smb_available), + ("rpcdump", config.use_rpcdump and rpc_available), + ("msfconsole", config.use_msf and smb_available)]: + if enabled and not tools[tool]: + error(f"{tool} was requested but not found") + + if config.batch_checks: + batches, skipped = plan_batches(config.checks, active_hosts, jobs=config.jobs, + tcp_discovery=config.tcp_discovery, has_nxc=bool(tools["nxc"]), username=config.username) + for address, definition, outcome in skipped: + host = hosts[address] + _record_check(host, definition.key, outcome, raw / "not-run", "netexec", config.password) + host.collection[f"check:{definition.key}"] = outcome.status + emit("finish", definition.key, address, outcome.status) + try: + for number, batch in enumerate(batches, 1): + if cancel.is_set(): + break + started = time.monotonic() + path = raw / f"batch-{number:04d}-{batch.module}.txt" + command = [*nxc_command(batch.targets, min(config.jobs, len(batch.targets))), "-M", batch.module] + if batch.options: + command.extend(["-o", *batch.options]) + code, text = run(f"batch:{batch.module}", "", command, path, config.timeout * len(batch.checks)) + if not code and invocation_failed(text, batch.targets): + code = 1 + error(f"NetExec invocation reported an error in {batch.module}; see {path.name}") + indexed = index_messages(text) + failed_hosts = host_failures(text, batch.targets) + for address, evidence in parse_nxc(text, str(path)).items(): + if address in batch.targets: + hosts[address].merge(evidence) + for address in batch.targets: + host = hosts[address] + effective_code = code or (1 if address in failed_hosts else 0) + if address in failed_hosts: + error(f"NetExec caught an exception for {address} in {batch.module}; see {path.name}") + for definition in batch.checks: + outcome = classify_nxc(definition.key, address, "", effective_code, index=indexed) + _record_check(host, definition.key, outcome, path, "netexec", config.password) + host.collection[f"check:{definition.key}"] = "failed" if effective_code else "success" + emit("finish", definition.key, address, outcome.status, duration=time.monotonic() - started) + emit("summary", "check-batch", status="failed" if code or failed_hosts else "complete", + detail=f"{batch.module}; {len(batch.targets)} alive host(s)") + if checkpoint: + checkpoint(hosts) + except KeyboardInterrupt: + cancel.set() + if cancel.is_set(): + config.cancelled = True + error("Scan interrupted; completed batch evidence was retained") + for host in hosts.values(): + host.checks = {key: "skipped-cancelled" if status == "pending" else status + for key, status in host.checks.items()} + host.collection = {key: "skipped-cancelled" if status == "pending" else status + for key, status in host.collection.items()} + if checkpoint: + checkpoint(hosts) + emit("summary", "scan", status="interrupted", detail="completed batch evidence retained") + return hosts + + def collect(address: str) -> HostEvidence: + result = HostEvidence(address) + safe = address.replace(":", "_") + + def unavailable(ports: tuple[int, ...]) -> bool: + return config.tcp_discovery and any(hosts[address].services.get(str(port)) != "open" for port in ports) + + def collector(stage: str, tool: str, action: Callable[[], str], ports: tuple[int, ...]) -> None: + if cancel.is_set(): + status = "skipped-cancelled" + elif unavailable(ports): + status = "skipped-service-unavailable" + elif not tools[tool]: + status = "missing" + else: + started = time.monotonic() + try: + status = action() + except (OSError, ValueError, TypeError) as exc: + status = "failed" + error(f"{stage} for {address}: {exc}") + result.collection[stage] = status + emit("finish", stage, address, status, duration=time.monotonic() - started) + return + result.collection[stage] = status + emit("finish", stage, address, status) + + def enum_action() -> str: + destination = raw / f"enum4linux-{safe}.json" + with tempfile.TemporaryDirectory(prefix=".enum-", dir=raw) as temporary: + prefix = Path(temporary) / "result" + command = [str(tools["enum4linux-ng"]), "-A", f"-u={config.username}", + f"-p={config.password}", "-t", str(min(config.timeout, 60)), + "-oJ", str(prefix), address] + if config.domain: + command.extend(["-w", config.domain]) + code, _ = run("enum4linux", address, command, raw / f"enum4linux-{safe}.log", config.timeout) + exported = Path(str(prefix) + ".json") + if not exported.exists(): + if code == 0: + error(f"enum4linux for {address} produced no JSON export") + return "failed" + data = json.loads(exported.read_text(encoding="utf-8")) + write_sanitized_json(destination, data, config.password) + result.merge(parse_enum4linux(data, str(destination))) + return "success" if code == 0 else "failed" + + def rpc_action() -> str: + path = raw / f"rpcdump-{safe}.txt" + # TCP endpoint mapper does not authenticate. Omitting credentials + # also prevents rpcdump from prompting for a legitimate blank password. + command = [str(tools["rpcdump"]), "-port", "135", address] + code, text = run("rpcdump", address, command, path, config.timeout) + result.merge(parse_rpcdump(text, address, str(path))) + return "success" if code == 0 else "failed" + + def store_check(key: str, outcome: CheckResult, source: Path, backend: str = "netexec") -> None: + _record_check(result, key, outcome, source, backend, config.password) + + for definition in config.checks: + if config.batch_checks and definition.backend == "netexec": + continue + started = time.monotonic() + path = raw / f"check-{definition.key}-{safe}.txt" + execution = "success" + if cancel.is_set(): + outcome = CheckResult("skipped-cancelled", "scan interrupted") + elif (reason := skip_reason(definition, hosts[address], tcp_discovery=config.tcp_discovery, + has_nxc=bool(tools["nxc"]), username=config.username)): + outcome = reason + elif definition.backend == "native": + emit("start", definition.key, address) + with lock: + config.commands.append(["", address, "UDP/389", "EX-control+guarded-minimal-legacy"]) + outcome = probe_longlogon(address, timeout=min(3.0, config.timeout / 2), cancel=cancel) + atomic_text(path, redact_text(f"{outcome.status}\n{outcome.evidence}\n", config.password)) + else: + command = [*nxc_command([address], 1), "-M", definition.nxc_module] + if definition.nxc_options: + command.extend(["-o", *definition.nxc_options]) + try: + code, text = run(definition.key, address, command, path, config.timeout) + if not code and (invocation_failed(text, (address,)) or host_failures(text, (address,))): + code = 1 + error(f"NetExec reported an error for {address} in {definition.key}; see {path.name}") + outcome = classify_nxc(definition.key, address, text, code) + execution = "success" if code == 0 else "failed" + except (OSError, ValueError) as exc: + error(f"{definition.key} for {address}: {exc}") + outcome = CheckResult("inconclusive", "checker failed; inspect collection notes") + execution = "failed" + store_check(definition.key, outcome, path, backend=definition.backend) + result.collection[f"check:{definition.key}"] = outcome.status if outcome.status.startswith("skipped-") else execution + emit("finish", definition.key, address, outcome.status, duration=time.monotonic() - started) + + if config.use_msf: + started = time.monotonic() + path = raw / f"msf-ms17-010-{safe}.txt" + execution = "success" + if cancel.is_set(): + outcome = CheckResult("skipped-cancelled", "scan interrupted") + elif unavailable((445,)): + outcome = CheckResult("skipped-service-unavailable", "TCP/445 was not reachable") + elif not tools["msfconsole"]: + outcome = CheckResult("skipped-missing-tool", "msfconsole is not installed") + elif ";" in config.username + config.password + config.domain: + outcome = CheckResult("skipped-unsupported", "credentials contain a Metasploit command separator") + else: + try: + code, text = run("msf-ms17-010", address, + _msf_command(str(tools["msfconsole"]), address, config), path, config.timeout) + outcome = classify_msf(address, text, code) + execution = "success" if code == 0 else "failed" + except (OSError, ValueError) as exc: + error(f"msf-ms17-010 for {address}: {exc}") + outcome = CheckResult("inconclusive", "checker failed; inspect collection notes") + execution = "failed" + store_check("ms17-010", outcome, path, backend="metasploit") + result.collection["msf-ms17-010"] = outcome.status if outcome.status.startswith("skipped-") else execution + emit("finish", "msf-ms17-010", address, outcome.status, duration=time.monotonic() - started) + if config.use_rpcdump: + collector("rpcdump", "rpcdump", rpc_action, (135,)) + if config.use_enum4linux: + collector("enum4linux", "enum4linux-ng", enum_action, (445,)) + return result + + pool = ThreadPoolExecutor(max_workers=config.jobs) + addresses = iter(active_hosts) + pending = {} + try: + for _ in range(min(config.jobs, len(active_hosts))): + address = next(addresses) + pending[pool.submit(collect, address)] = address + while pending: + done, _ = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED) + for future in done: + address = pending.pop(future) + try: + hosts[address].merge(future.result()) + except Exception as exc: + hosts[address].notes.append("host collection failed; remaining results are incomplete") + hosts[address].collection["host"] = "failed" + hosts[address].checks = {key: "inconclusive" if status == "pending" else status + for key, status in hosts[address].checks.items()} + error(f"collection error for {address}: {exc}") + if checkpoint: + checkpoint(hosts) + next_address = next(addresses, None) + if next_address is not None: + pending[pool.submit(collect, next_address)] = next_address + except KeyboardInterrupt: + config.cancelled = True + cancel.set() + error("Scan interrupted; completed evidence was retained") + for future in pending: + future.cancel() + except BaseException: + cancel.set() + for future in pending: + future.cancel() + raise + finally: + pool.shutdown(wait=True, cancel_futures=True) + if config.cancelled: + for future, address in pending.items(): + if not future.cancelled() and future.done(): + try: + hosts[address].merge(future.result()) + except Exception: + pass + for host in hosts.values(): + host.checks = {key: "skipped-cancelled" if status == "pending" else status + for key, status in host.checks.items()} + host.collection = {key: "skipped-cancelled" if status == "pending" else status + for key, status in host.collection.items()} + if checkpoint: + checkpoint(hosts) + incomplete = config.errors or any(coverage(host)[2] for host in hosts.values()) + emit("summary", "scan", status="interrupted" if config.cancelled else ("incomplete" if incomplete else "complete"), + detail=f"{len(active_hosts)} alive; {len(hosts) - len(active_hosts)} unreachable omitted; {len(config.errors)} collection note(s)") + return hosts + + +def analyze_artifacts(nxc: Path | None, rpcdump_dir: Path | None, + enum4linux_dir: Path | None, errors: list[str] | None = None) -> dict[str, HostEvidence]: + hosts: dict[str, HostEvidence] = {} + notes = errors if errors is not None else [] + if nxc: + hosts.update(parse_nxc(nxc.read_text(encoding="utf-8", errors="replace"), str(nxc))) + if enum4linux_dir: + for path in sorted(enum4linux_dir.glob("*.json")): + try: + evidence = load_enum4linux(path) + if evidence.address == "unknown": + raise ValueError("missing target identity") + hosts.setdefault(evidence.address, HostEvidence(evidence.address)).merge(evidence) + except (OSError, ValueError, TypeError) as exc: + notes.append(f"{path.name}: {exc}") + if rpcdump_dir: + for path in sorted(rpcdump_dir.glob("*.txt")): + match = re.search(r"(? str: + if password: + variants = {password, json.dumps(password)[1:-1], + password.replace("\\", "\\\\").replace('"', '\\"')} + for secret in sorted(variants, key=len, reverse=True): + value = value.replace(secret, "") + return value + + +def prepare_output(path: Path) -> None: + if path.exists() and (not path.is_dir() or any(path.iterdir())): + raise ValueError(f"output path is not an empty directory: {path}; choose a new -o path") + path.mkdir(parents=True, exist_ok=True, mode=0o700) + + +def atomic_text(path: Path, text: str) -> None: + temporary: str | None = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", + dir=path.parent, prefix=".rpcscope-", delete=False) as handle: + temporary = handle.name + handle.write(text) + os.replace(temporary, path) + finally: + if temporary and os.path.exists(temporary): + os.unlink(temporary) + + +def scrub(value: Any, password: str) -> Any: + if isinstance(value, dict): + return { + key: "" if key.casefold() in {"password", "pw", "passwd", "smbpass"} + else item if key.casefold() in {"host", "ip", "address", "hostname"} + else scrub(item, password) + for key, item in value.items() + } + if isinstance(value, list): + return [scrub(item, password) for item in value] + if isinstance(value, str) and password: + return value.replace(password, "") + return value + + +def write_sanitized_json(path: Path, value: Any, password: str) -> None: + atomic_text(path, json.dumps(scrub(value, password), indent=2, sort_keys=True) + "\n") diff --git a/rpcscope/targets.py b/rpcscope/targets.py new file mode 100644 index 0000000..e4e8a27 --- /dev/null +++ b/rpcscope/targets.py @@ -0,0 +1,264 @@ +"""Bounded target expansion and independent TCP service discovery.""" + +from __future__ import annotations + +import errno +import ipaddress +import math +import re +import socket +import threading +import time +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from pathlib import Path +from typing import Callable + + +_HOST_LABEL = re.compile(r"(?!-)[a-z0-9-]{1,63}(? tuple[int, int]: + parsed = ipaddress.ip_address(address) + return parsed.version, int(parsed) + + +def expand_targets(targets: list[str], max_hosts: int = 4096, + resolver: Callable = socket.getaddrinfo) -> list[str]: + """Expand IPs, CIDRs, hostnames and UTF-8 target files into unique IPs. + + CIDRs follow ``ip_network.hosts()`` semantics, including /31 and /127. + Files contain one target per line, with optional ``#`` comments. Nested + files are rejected so an accidental file cycle cannot broaden a scan. + """ + if isinstance(max_hosts, bool) or not isinstance(max_hosts, int) or max_hosts < 1: + raise ValueError("max_hosts must be a positive integer") + if not targets: + raise ValueError("at least one target is required") + addresses: set[str] = set() + + def add(address: str) -> None: + try: + canonical = str(ipaddress.ip_address(address)) + except ValueError as exc: + raise ValueError(f"resolver returned an invalid IP address: {address!r}") from exc + addresses.add(canonical) + if len(addresses) > max_hosts: + raise ValueError(f"target expansion exceeds the {max_hosts}-host limit") + + def is_file(path: Path) -> bool: + try: + return path.is_file() + except (OSError, ValueError) as exc: + raise ValueError(f"cannot inspect target file {str(path)!r}: {exc}") from exc + + def expand(raw: str, *, source: Path | None = None) -> None: + value = raw.strip() + if not value or any(ord(char) < 32 for char in value): + raise ValueError("targets cannot be empty or contain control characters") + if value.startswith("-"): + raise ValueError(f"target cannot be an option: {value!r}") + + candidate = Path(value) + if source is not None and not candidate.is_absolute(): + candidate = source.parent / candidate + if is_file(candidate): + if source is not None: + raise ValueError(f"nested target files are not supported: {str(candidate)!r}") + try: + with candidate.open(encoding="utf-8-sig") as stream: + for line_number, line in enumerate(stream, 1): + entry = line.partition("#")[0].strip() + if not entry: + continue + try: + expand(entry, source=candidate) + except ValueError as exc: + raise ValueError(f"{candidate}:{line_number}: {exc}") from exc + except (OSError, UnicodeError) as exc: + raise ValueError(f"cannot read target file {str(candidate)!r}: {exc}") from exc + return + + try: + parsed = ipaddress.ip_address(value) + except ValueError: + parsed = None + if parsed is not None: + add(str(parsed)) + return + + if "/" in value: + try: + network = ipaddress.ip_network(value, strict=False) + except ValueError as exc: + raise ValueError(f"invalid IP/CIDR or missing target file: {value!r}") from exc + host_count = network.num_addresses + if network.version == 4 and network.prefixlen < 31: + host_count -= 2 + elif network.version == 6 and network.prefixlen < 127: + host_count -= 1 + if host_count > max_hosts: + raise ValueError(f"CIDR {value!r} contains {host_count} hosts; limit is {max_hosts}") + for address in network.hosts(): + add(str(address)) + return + + try: + hostname = value.rstrip(".").encode("idna").decode("ascii") + except UnicodeError as exc: + raise ValueError(f"invalid hostname: {value!r}") from exc + if (not hostname or len(hostname) > 253 + or not all(_HOST_LABEL.fullmatch(label) for label in hostname.split(".")) + or re.fullmatch(r"[0-9.]+", hostname)): + raise ValueError(f"invalid IP, hostname or missing target file: {value!r}") + try: + records = resolver(value, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except OSError as exc: + raise ValueError(f"could not resolve target {value!r}: {exc}") from exc + found = False + for family, _, _, _, sockaddr in records: + if family in (socket.AF_INET, socket.AF_INET6): + add(sockaddr[0]) + found = True + if not found: + raise ValueError(f"target {value!r} did not resolve to an IPv4 or IPv6 address") + + for target in targets: + if not isinstance(target, str): + raise ValueError("targets must be strings") + expand(target) + if not addresses: + raise ValueError("target files did not contain any targets") + return sorted(addresses, key=_address_key) + + +def _connect(address: str, port: int, timeout: float) -> str: + """Check a TCP listener with a connection only; send no protocol data.""" + with socket.create_connection((address, port), timeout=timeout): + return "open" + + +def discover_services(addresses: list[str], jobs: int = 8, timeout: float = 1.0, + cancel: threading.Event | None = None, + probe: Callable[[str, int, float], str] | None = None, + on_result: Callable[[str, dict[int, str]], None] | None = None, + deadline: float | None = None, + ) -> dict[str, dict[int, str]]: + """Probe TCP/135 and TCP/445 with at most ``jobs`` active connections. + + Each worker probes both ports of one host, then immediately takes the next + host. Only ``jobs`` host futures are retained, so a large scope does not + create an unbounded task queue. Lightweight discovery can use a higher + worker limit than the much heavier per-host protocol checks. + + ``probe`` is injectable for tests. It returns open/closed/timeout/error or + raises the corresponding socket exception. ``on_result`` runs on the + caller's thread. Cancellation stops scheduling hosts and may return a + partial port dictionary if a host was already in progress. ``deadline`` is + an absolute ``time.monotonic()`` value; expiry stops new probes and waits + for in-flight connections, bounded by their socket timeout. Deadline + expiry never sets the caller's cancellation event. Each connection timeout + is capped to the remaining deadline budget. + """ + if isinstance(jobs, bool) or not isinstance(jobs, int) or jobs < 1: + raise ValueError("jobs must be a positive integer") + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0: + raise ValueError("discovery timeout must be a positive finite number") + if deadline is not None and (isinstance(deadline, bool) or not isinstance(deadline, (int, float)) or not math.isfinite(deadline)): + raise ValueError("discovery deadline must be a finite monotonic timestamp") + try: + unique = sorted({str(ipaddress.ip_address(address)) for address in addresses}, key=_address_key) + except (ValueError, TypeError) as exc: + raise ValueError("service discovery requires literal IPv4 or IPv6 addresses") from exc + stop = cancel if cancel is not None else threading.Event() + deadline_stop = threading.Event() + + def stopped() -> bool: + if stop.is_set() or deadline_stop.is_set(): + return True + if deadline is not None and time.monotonic() >= deadline: + deadline_stop.set() + return True + return False + + if not unique or stopped(): + return {} + connect = probe if probe is not None else _connect + results: dict[str, dict[int, str]] = {} + + def inspect(address: str) -> dict[int, str]: + ports: dict[int, str] = {} + for port in _PORTS: + if stopped(): + break + connect_timeout = timeout + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + deadline_stop.set() + break + connect_timeout = min(timeout, remaining) + try: + state = connect(address, port, connect_timeout) + ports[port] = state if state in _STATES else "error" + except (TimeoutError, socket.timeout): + ports[port] = "timeout" + except ConnectionRefusedError: + ports[port] = "closed" + except OSError as exc: + if exc.errno in {errno.EMFILE, errno.ENFILE, errno.ENOMEM, errno.ENOBUFS, + getattr(errno, "WSAENOBUFS", 10055)}: + raise OSError(exc.errno, "local socket resources exhausted; reduce discovery workers") from exc + if exc.errno in (errno.ECONNREFUSED, getattr(errno, "WSAECONNREFUSED", 10061)): + ports[port] = "closed" + elif exc.errno in (errno.ETIMEDOUT, getattr(errno, "WSAETIMEDOUT", 10060)): + ports[port] = "timeout" + else: + ports[port] = "error" + except Exception: + ports[port] = "error" + return ports + + iterator = iter(unique) + executor = ThreadPoolExecutor(max_workers=min(jobs, len(unique)), thread_name_prefix="rpcscope-discovery") + pending = {} + + def submit() -> None: + if not stopped() and (address := next(iterator, None)) is not None: + pending[executor.submit(inspect, address)] = address + + try: + for _ in range(min(jobs, len(unique))): + submit() + while pending: + if stopped(): + for future in pending: + future.cancel() + wait_timeout = 0.05 + if deadline is not None and not deadline_stop.is_set(): + wait_timeout = max(0.0, min(wait_timeout, deadline - time.monotonic())) + done, _ = wait(pending, timeout=wait_timeout, return_when=FIRST_COMPLETED) + for future in done: + address = pending.pop(future) + if future.cancelled(): + continue + ports = future.result() + if ports: + results[address] = ports + if on_result: + on_result(address, dict(ports)) + submit() + if stop.is_set(): + break + except Exception: + deadline_stop.set() + raise + except BaseException: + stop.set() + raise + finally: + for future in pending: + future.cancel() + executor.shutdown(wait=not stop.is_set(), cancel_futures=True) + return {address: results[address] for address in unique if address in results} diff --git a/tests/test_advisories.py b/tests/test_advisories.py new file mode 100644 index 0000000..e648604 --- /dev/null +++ b/tests/test_advisories.py @@ -0,0 +1,147 @@ +"""Advisory refresh is metadata-only, bounded, and atomic; no network needed.""" +import copy +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from rpcscope.advisories import ADVISORIES, _download, sync_advisories +from rpcscope.catalog import CHECKS + + +MICROSOFT = "f38d906d-7342-40ea-92c1-6c4a2c6478c8" + + +def record_for(advisory): + return { + "dataType": "CVE_RECORD", + "cveMetadata": { + "cveId": advisory.key, "state": "PUBLISHED", "assignerOrgId": MICROSOFT, + "datePublished": "2026-05-12T16:58:50.091Z", "dateUpdated": "2026-08-10T15:12:59.779Z", + }, + "containers": {"cna": { + "title": "Vendor advisory title", + "providerMetadata": {"orgId": MICROSOFT, "shortName": "microsoft"}, + "references": [ + {"url": advisory.reference}, {"url": "https://example.com/poc.py"}, + {"url": "https://msrc.microsoft.com.evil.example/metadata"}, + ], + "affected": [{"vendor": "Microsoft", "product": "Windows Example", + "platforms": ["x64-based Systems"], + "versions": [{"version": "10.0.0", "lessThan": "10.0.1", + "versionType": "custom", "status": "affected"}]}], + "untrusted_command": "do not carry executable fields into cache", + }}, + } + + +class AdvisoryTests(unittest.TestCase): + def test_registry_remains_separate_from_executable_checks(self): + self.assertEqual(len(ADVISORIES), 7) + self.assertTrue(all(item.status == "review-only" for item in ADVISORIES.values())) + executable_cves = {cve for check in CHECKS.values() if check.kind == "vulnerability" for cve in check.cves} + self.assertFalse(executable_cves.intersection(ADVISORIES)) + self.assertEqual(CHECKS["longlogon"].kind, "precondition") + self.assertEqual(CHECKS["longlogon"].positive_status, "precondition-met") + self.assertIn("/2020/1xxx/", ADVISORIES["CVE-2020-1206"].cna_url) + self.assertIn("/2026/41xxx/", ADVISORIES["CVE-2026-41089"].cna_url) + + def test_sync_copies_selected_metadata_and_never_fetches_references(self): + fetched = [] + by_url = {item.cna_url: item for item in ADVISORIES.values()} + def fetch(url): + fetched.append(url) + return json.dumps(record_for(by_url[url])).encode() + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "nested" / "advisories.json" + result = sync_advisories(path, fetch=fetch) + dataset = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(fetched, list(by_url)) + self.assertEqual(dataset["advisories"], result) + self.assertFalse(dataset["scanner_coverage"]) + self.assertEqual(result[0]["title"], "Vendor advisory title") + self.assertEqual(result[0]["vendorReferences"], [result[0]["reference"]]) + self.assertEqual(result[0]["affected"][0]["versions"][0]["lessThan"], "10.0.1") + self.assertNotIn("untrusted_command", json.dumps(result)) + self.assertTrue(all(item["status"] == "review-only" for item in result)) + + def test_invalid_later_record_preserves_existing_destination(self): + advisories = list(ADVISORIES.values()) + index = 0 + def fetch(url): + nonlocal index + record = record_for(advisories[index]) + index += 1 + if index == 2: + record["cveMetadata"]["cveId"] = "CVE-2000-0001" + return json.dumps(record).encode() + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "advisories.json" + path.write_text("existing valid cache", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "CVE ID mismatch"): + sync_advisories(path, fetch=fetch) + self.assertEqual(path.read_text(encoding="utf-8"), "existing valid cache") + + def test_unpublished_untrusted_and_malformed_records_fail_closed(self): + advisory = next(iter(ADVISORIES.values())) + original = record_for(advisory) + cases = [] + for mutate in ( + lambda record: record["cveMetadata"].update(state="REJECTED"), + lambda record: record["cveMetadata"].update(assignerOrgId="other"), + lambda record: record["containers"]["cna"]["providerMetadata"].update(orgId="other"), + lambda record: record["containers"]["cna"]["providerMetadata"].update(shortName="other"), + lambda record: record["containers"]["cna"].update(title="bad\x1b[31m"), + lambda record: record["cveMetadata"].update(dateUpdated="yesterday"), + lambda record: record["containers"]["cna"].update(references=[]), + lambda record: record["containers"]["cna"].update(affected=[{}] * 513), + ): + record = copy.deepcopy(original) + mutate(record) + cases.append(json.dumps(record).encode()) + cases.extend([b"not JSON", b"{}", b"x" * (2 * 1024 * 1024 + 1)]) + with tempfile.TemporaryDirectory() as temporary: + for payload in cases: + with self.subTest(payload_size=len(payload)): + path = Path(temporary) / "advisories.json" + with self.assertRaises(ValueError): + sync_advisories(path, fetch=lambda url: payload) + self.assertFalse(path.exists()) + + def test_absent_cna_title_has_curated_fallback(self): + by_url = {item.cna_url: item for item in ADVISORIES.values()} + def fetch(url): + record = record_for(by_url[url]) + del record["containers"]["cna"]["title"] + return json.dumps(record).encode() + with tempfile.TemporaryDirectory() as temporary: + snapshots = sync_advisories(Path(temporary) / "metadata.json", fetch=fetch) + self.assertEqual(snapshots[0]["title"], next(iter(ADVISORIES.values())).title) + + def test_network_failure_preserves_existing_destination(self): + def fetch(url): + raise OSError("network unavailable") + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "metadata.json" + path.write_text("old cache", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "network unavailable"): + sync_advisories(path, fetch=fetch) + self.assertEqual(path.read_text(encoding="utf-8"), "old cache") + + def test_downloader_bounds_read_and_timeout_and_rejects_redirect(self): + url = next(iter(ADVISORIES.values())).cna_url + with patch("rpcscope.advisories.urlopen") as opening: + response = opening.return_value.__enter__.return_value + response.geturl.return_value = url + response.read.return_value = b"{}" + self.assertEqual(_download(url), b"{}") + self.assertEqual(opening.call_args.kwargs["timeout"], 15) + response.read.assert_called_once_with(2 * 1024 * 1024 + 1) + response.geturl.return_value = "https://example.com/redirect" + with self.assertRaisesRegex(ValueError, "redirected"): + _download(url) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_batch_execution.py b/tests/test_batch_execution.py new file mode 100644 index 0000000..d874e6e --- /dev/null +++ b/tests/test_batch_execution.py @@ -0,0 +1,209 @@ +"""Mocked full scans: no sockets, external scanners or target traffic.""" +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from rpcscope.catalog import EXPOSURE_CHECKS, resolve_checks +from rpcscope.checks import CheckResult +from rpcscope.netexec import NxcCapabilities +from rpcscope.reporting import coverage +from rpcscope.runner import ScanConfig, scan + + +TOOLS = {"nxc": "fake-nxc", "enum4linux-ng": "fake-enum", "rpcdump": "fake-rpc", "msfconsole": None} +CURRENT = NxcCapabilities.from_help("--shares --file-write-check --no-admin-check") + + +def targets(command): + return command[2:next(i for i, arg in enumerate(command) if arg.startswith("-u="))] + + +def banner(address): + return f"SMB {address} 445 HOST [*] Windows Server 2022 (name:HOST) (domain:LAB)" + + +def alive(addresses, on_result, **kwargs): + for address in addresses: + on_result(address, {135: "open", 445: "open"}) + + +class BatchExecutionTests(unittest.TestCase): + def test_duplicate_api_checks_are_executed_and_counted_once(self): + definition = resolve_checks(["ms17-010"])[0] + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, + use_enum4linux=False, use_rpcdump=False, checks=[definition, definition]) + def execute(command, timeout, log, password, **kwargs): + return (0, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1")) + events = [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run: + hosts = scan(config, progress=events.append) + self.assertEqual(run.call_count, 2) + self.assertEqual(len(hosts["192.0.2.1"].check_attempts), 1) + self.assertEqual(events[-1].completed, events[-1].total) + + def test_full_enumeration_does_not_delay_dedicated_checks(self): + order = [] + def execute(command, timeout, log, password, **kwargs): + if command[0] == "fake-enum": + order.append("enum") + prefix = command[command.index("-oJ") + 1] + Path(prefix + ".json").write_text('{"target":{"host":"192.0.2.1"}}', encoding="utf-8") + return 0, "" + if "-M" in command: + order.append("check") + return 0, "MS17-010 192.0.2.1 445 HOST VULNERABLE" + order.append("discovery") + return 0, banner("192.0.2.1") + def native(*args, **kwargs): + order.append("native") + return CheckResult("inconclusive", "no response") + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, + use_enum4linux=True, use_rpcdump=False, checks=resolve_checks(["ms17-010,longlogon"])) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \ + patch("rpcscope.runner.probe_longlogon", side_effect=native): + hosts = scan(config) + self.assertEqual(order, ["discovery", "check", "native", "enum"]) + self.assertEqual(hosts["192.0.2.1"].collection["enum4linux"], "success") + + def test_local_discovery_failure_is_retained_as_failure_not_dead_scan_success(self): + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), tcp_discovery=True, batch_checks=True, + checks=resolve_checks(["recommended"])) + events = [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), \ + patch("rpcscope.runner.discover_services", side_effect=OSError("local socket resources exhausted")), \ + patch("rpcscope.runner._run") as run: + hosts = scan(config, progress=events.append) + run.assert_not_called() + self.assertFalse(config.cancelled) + self.assertEqual(events[-1].status, "incomplete") + self.assertEqual(hosts["192.0.2.1"].services["445"], "unprobed") + self.assertEqual(hosts["192.0.2.1"].checks, {}) + self.assertTrue(any("failed locally" in message for message in config.errors)) + + def test_recommended_32_host_scan_uses_28_processes_with_full_attribution(self): + addresses = [f"192.0.2.{i}" for i in range(1, 33)] + commands = [] + def execute(command, timeout, log, password, **kwargs): + commands.append(command) + if "-M" not in command: + return 0, "\n".join(banner(address) for address in targets(command)) + self.assertLessEqual(len(targets(command)), 8) + self.assertLessEqual(int(command[command.index("-t") + 1]), 8) + self.assertIn("--no-admin-check", command) + module = command[command.index("-M") + 1] + lines = [] + for address in targets(command): + messages = { + "ms17-010": [("MS17-010", f"{address} does NOT appear vulnerable")], + "zerologon": [], # no recognized output remains UNKNOWN + "printnightmare": [("PRINTNIGHT...", "Not vulnerable :'(")], + "smbghost": [("SMBGHOST", "Potentially vulnerable to SMBGhost (CVE-2020-0796)")], + "coerce_plus": [("COERCE_PLUS", "VULNERABLE, " + method) for method in + ("PetitPotam", "DFSCoerce", "ShadowCoerce", "PrinterBug", "MSEven")], + "spooler": [("SPOOLER", "Spooler service enabled")], + "ioxidresolver": [("IOXIDRES...", "Address: 10.0.0.1")], + }[module] + lines.extend(f"{name} {address} 445 HOST {message}" for name, message in messages) + self.assertEqual(timeout, 600 if module == "coerce_plus" else 120) + return 0, "\n".join(lines) + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(addresses, out_dir=Path(temporary), tcp_discovery=True, batch_checks=True, + use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["recommended"])) + events, snapshots = [], [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), \ + patch("rpcscope.runner.inspect_capabilities", return_value=CURRENT), \ + patch("rpcscope.runner.discover_services", side_effect=alive), \ + patch("rpcscope.runner._run", side_effect=execute), \ + patch("rpcscope.runner.probe_longlogon", return_value=CheckResult("precondition-not-met", "not patch detection")): + hosts = scan(config, progress=events.append, checkpoint=lambda value: snapshots.append(len(value))) + check_commands = [command for command in commands if "-M" in command] + self.assertEqual(len(check_commands), 28) + self.assertEqual(len(commands), 29) # one separate inventory invocation + self.assertEqual(len(hosts), 32) + self.assertGreater(len(snapshots), 1) + for host in hosts.values(): + self.assertEqual(len(host.check_attempts), 12) + self.assertEqual(coverage(host), (12, 12, False)) + self.assertEqual(host.checks["zerologon"], "inconclusive") + self.assertEqual(host.checks["ms17-010"], "not-vulnerable") + self.assertEqual(host.checks["smbghost"], "suspected") + self.assertEqual(host.checks["oxid"], "observed") + self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS)) + self.assertEqual(config.errors, []) + self.assertEqual(events[-1].status, "complete") + self.assertEqual(events[-1].completed, events[-1].total) + + def test_zero_exit_host_exception_does_not_downgrade_other_hosts(self): + addresses = ["192.0.2.1", "192.0.2.2"] + def execute(command, timeout, log, password, **kwargs): + if "-M" not in command: + return 0, "\n".join(banner(address) for address in addresses) + return 0, ("MS17-010 192.0.2.1 445 HOST VULNERABLE\n" + "Exception while calling proto_flow() on target 192.0.2.2: failure\n" + "Traceback (most recent call last):\nValueError: malformed response\n") + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(addresses, out_dir=Path(temporary), batch_checks=True, + use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"])) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute): + hosts = scan(config) + self.assertEqual(hosts[addresses[0]].checks["ms17-010"], "vulnerable") + self.assertFalse(coverage(hosts[addresses[0]])[2]) + self.assertEqual(hosts[addresses[1]].checks["ms17-010"], "inconclusive") + self.assertTrue(coverage(hosts[addresses[1]])[2]) + self.assertTrue(config.errors) + + def test_failed_batch_is_not_retried_or_mistaken_for_negative(self): + def execute(command, timeout, log, password, **kwargs): + return (124, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1")) + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, + use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"])) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run: + hosts = scan(config) + self.assertEqual(run.call_count, 2) + self.assertEqual(hosts["192.0.2.1"].checks["ms17-010"], "inconclusive") + self.assertTrue(coverage(hosts["192.0.2.1"])[2]) + + def test_batch_cancellation_retains_finished_evidence_and_stops_remaining_work(self): + def execute(command, timeout, log, password, cancel, **kwargs): + if "-M" in command: + cancel.set() + return 130, "" + return 0, banner("192.0.2.1") + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, + checks=resolve_checks(["recommended"])) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run, \ + patch("rpcscope.runner.probe_longlogon") as native: + hosts = scan(config) + native.assert_not_called() + self.assertEqual(run.call_count, 2) + self.assertTrue(config.cancelled) + self.assertNotIn("pending", hosts["192.0.2.1"].checks.values()) + self.assertEqual(hosts["192.0.2.1"].checks["longlogon"], "skipped-cancelled") + + def test_share_inventory_uses_only_verified_readonly_options(self): + for caps, expected in ((CURRENT, ["--shares"]), + (NxcCapabilities.from_help("--shares --no-write-check"), ["--shares", "--no-write-check"]), + (NxcCapabilities(), [])): + with self.subTest(flags=caps.flags), tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), shares=True, + use_enum4linux=False, use_rpcdump=False) + with patch("rpcscope.runner.doctor", return_value=TOOLS), \ + patch("rpcscope.runner.inspect_capabilities", return_value=caps), \ + patch("rpcscope.runner._run", return_value=(0, banner("192.0.2.1"))) as run: + hosts = scan(config) + command = run.call_args.args[0] + self.assertEqual([arg for arg in command if arg in {"--shares", "--no-write-check"}], expected) + self.assertNotIn("--file-write-check", command) + self.assertEqual(bool(config.errors), not bool(expected)) + if not expected: + self.assertEqual(hosts["192.0.2.1"].collection["shares"], "skipped-unsupported") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..36abaa3 --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,111 @@ +"""Regression cases based on upstream checker output, without network access.""" + +import unittest + +from rpcscope.checks import classify_msf, classify_nxc + + +HOST = "10.20.30.41" +OTHER_HOST = "10.20.30.42" + + +def nxc_line(module, message, host=HOST): + return f"{module:16} {host} 445 HOST1 {message}\n" + + +class CheckerOutputTests(unittest.TestCase): + def test_ms17_indeterminate_message_is_never_positive(self): + result = classify_nxc( + "ms17-010", HOST, + nxc_line("MS17-010", f"[-] {HOST} Unable to detect if this host is vulnerable"), + ) + self.assertEqual(result.status, "inconclusive") + + def test_ms17_upstream_positive_and_negative_markers(self): + cases = [ + (f"[+] {HOST} is likely VULNERABLE to MS17-010! (Windows 7)", "vulnerable"), + ("[+] VULNERABLE", "vulnerable"), + (f"[-] {HOST} does NOT appear vulnerable", "not-vulnerable"), + ] + for message, expected in cases: + with self.subTest(message=message): + self.assertEqual( + classify_nxc("ms17-010", HOST, nxc_line("MS17-010", message)).status, + expected, + ) + + def test_markers_must_match_target_and_module(self): + text = ( + nxc_line("MS17-010", "[+] VULNERABLE", OTHER_HOST) + + nxc_line("ZEROLOGON", "[+] VULNERABLE") + + nxc_line("SMB", r"[+] LAB\auditor:VULNERABLE") + + "description: target may be VULNERABLE\n" + ) + self.assertEqual(classify_nxc("ms17-010", HOST, text).status, "inconclusive") + + def test_ansi_and_truncated_module_name_are_attributed(self): + text = "\x1b[32m" + nxc_line( + "PRINTNIGH...", + "[+] Vulnerable, next step https://github.com/ly4k/PrintNightmare", + ) + "\x1b[0m" + self.assertEqual(classify_nxc("printnightmare", HOST, text).status, "suspected") + + def test_smbghost_capability_indicator_is_only_suspected(self): + text = nxc_line( + "SMBGHOST", "[+] Potentially vulnerable to SMBGhost (CVE-2020-0796)", + ) + self.assertEqual(classify_nxc("smbghost", HOST, text).status, "suspected") + + def test_nonzero_exit_fails_closed_even_with_result_markers(self): + for message in ("[+] VULNERABLE", f"[-] {HOST} does NOT appear vulnerable"): + with self.subTest(message=message): + result = classify_nxc( + "ms17-010", HOST, nxc_line("MS17-010", message), returncode=124, + ) + self.assertEqual(result.status, "inconclusive") + self.assertIn("124", result.evidence) + self.assertEqual(classify_msf( + HOST, f"[+] {HOST}:445 - Host is likely VULNERABLE to MS17-010!\n", 1, + ).status, "inconclusive") + + def test_conflicting_nxc_markers_require_review(self): + text = ( + nxc_line("MS17-010", "[+] VULNERABLE") + + nxc_line("MS17-010", f"[-] {HOST} does NOT appear vulnerable") + ) + result = classify_nxc("ms17-010", HOST, text) + self.assertEqual(result.status, "inconclusive") + self.assertIn("conflicting", result.evidence) + + def test_silence_and_upstream_errors_remain_inconclusive(self): + cases = [ + ("ms17-010", "MS17-010", ""), + ("zerologon", "ZEROLOGON", "[+] Attack failed. Target is probably patched."), + ("printnightmare", "PRINTNIGHTMARE", "[-] Failed to bind: access denied"), + ("smbghost", "SMBGHOST", "[-] Error while connecting to host: timed out"), + ] + for key, module, message in cases: + with self.subTest(key=key): + text = nxc_line(module, message) if message else "" + self.assertEqual(classify_nxc(key, HOST, text).status, "inconclusive") + + def test_removed_module_is_explicitly_unsupported(self): + text = nxc_line( + "NTLM_REFLECTION", + "[-] [REMOVED] This module has been integrated into the enum_cve module.", + ) + self.assertEqual(classify_nxc("ntlm-reflection", HOST, text).status, "skipped-unsupported") + + def test_msf_results_require_target_and_conflicts_are_inconclusive(self): + positive = f"[+] {HOST}:445 - Host is likely VULNERABLE to MS17-010! (Windows 7)\n" + negative = f"[-] {HOST}:445 - Host does NOT appear vulnerable.\n" + self.assertEqual(classify_msf(HOST, positive).status, "vulnerable") + self.assertEqual(classify_msf(HOST, negative).status, "not-vulnerable") + self.assertEqual(classify_msf(HOST, positive.replace(HOST, OTHER_HOST)).status, "inconclusive") + result = classify_msf(HOST, positive + negative) + self.assertEqual(result.status, "inconclusive") + self.assertIn("conflicting", result.evidence) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5259a1c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,130 @@ +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from rpcscope.cli import main + + +TOOLS = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None} + + +class CliTests(unittest.TestCase): + def test_fast_default_deep_collection_and_isolated_fallback_plans(self): + for extra, deep, isolated in (([], False, False), (["--deep"], True, False), + (["--isolated-checks"], False, True)): + with self.subTest(extra=extra), tempfile.TemporaryDirectory() as temporary: + captured = io.StringIO() + destination = Path(temporary) / "absent" + with patch("rpcscope.runner.doctor", return_value=TOOLS), contextlib.redirect_stdout(captured): + code = main(["scan", "192.0.2.1", "--checks", "recommended", "--dry-run", "-o", str(destination), *extra]) + commands = json.loads(captured.getvalue()) + self.assertEqual(code, 0) + self.assertFalse(destination.exists()) + self.assertEqual(any(command[0] == "enum4linux-ng" for command in commands), deep) + self.assertEqual(sum("-M" in command for command in commands), 11 if isolated else 7) + self.assertTrue(any(command[0] == "" for command in commands)) + + def test_tcp_share_dry_run_shows_gating_without_guessing_live_capabilities(self): + captured = io.StringIO() + with patch("rpcscope.runner.doctor", return_value=TOOLS), \ + patch("rpcscope.runner.inspect_capabilities") as inspect, \ + contextlib.redirect_stdout(captured): + self.assertEqual(main(["scan", "192.0.2.0/24", "--shares", "--dry-run"]), 0) + inspect.assert_not_called() + commands = json.loads(captured.getvalue()) + self.assertEqual(commands[0][0], "") + self.assertEqual(commands[1][2], "") + self.assertIn("", commands[1]) + self.assertNotIn("--no-write-check", commands[1]) + + def test_invalid_limits_are_usage_errors(self): + for argument in ("--jobs", "--discovery-workers", "--timeout", "--discovery-timeout"): + with self.subTest(argument=argument), contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as raised: + main(["scan", "192.0.2.1", argument, "0", "--dry-run"]) + self.assertEqual(raised.exception.code, 2) + + def test_progress_and_final_reports_use_attributed_check_results(self): + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "scan" + captured, diagnostics = io.StringIO(), io.StringIO() + + def execute(command, timeout, log, password, **kwargs): + if "-M" not in command: + return 0, "\n".join( + f"SMB 192.0.2.{i} 445 HOST{i} [*] Windows 11 (name:HOST{i}) (domain:LAB) (signing:True) (SMBv1:False)" + for i in (1, 2) + ) + lines = [] + for host in command[2:command.index("-u=")]: + lines.append(f"MS17-010 {host} 445 HOST1 {host} is likely VULNERABLE to MS17-010! (Windows 7)" + if host.endswith(".1") else f"MS17-010 {host} 445 HOST2 {host} does NOT appear vulnerable") + return 0, "\n".join(lines) + + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \ + contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics): + code = main(["scan", "192.0.2.0/30", "--checks", "ms17-010", "--authorised", + "--discovery", "smb", "--no-enum4linux", "--no-rpcdump", "--progress", "plain", "-o", str(destination)]) + self.assertEqual(code, 0) + self.assertIn("[3/3] SUMMARY scan complete", diagnostics.getvalue()) + self.assertIn("VULN: [MS17-010]", captured.getvalue()) + self.assertIn("NOT VULNERABLE (SELECTED CHECKS)", captured.getvalue()) + report = json.loads((destination / "report.json").read_text()) + self.assertEqual(report["mode"], "live-scan") + self.assertEqual(len(report["hosts"]), 2) + self.assertEqual((destination / "report.txt").read_text(), captured.getvalue()) + self.assertIn("Host summary", (destination / "report.md").read_text()) + + def test_discovery_failure_returns_nonzero_but_retains_reports(self): + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "scan" + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(124, "")), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + code = main(["scan", "192.0.2.0/30", "--authorised", "--discovery", "smb", "--no-progress", "-o", str(destination)]) + self.assertEqual(code, 1) + report = json.loads((destination / "report.json").read_text()) + self.assertTrue(report["errors"]) + self.assertEqual(report["hosts"], []) + + def test_unknown_check_is_completed_with_zero_exit_not_a_negative(self): + with tempfile.TemporaryDirectory() as temporary: + output = io.StringIO() + diagnostics = io.StringIO() + def execute(command, timeout, log, password, **kwargs): + return (0, "") if "-M" in command else (0, "SMB 192.0.2.1 445 HOST [*] Windows 11 (name:HOST)") + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \ + contextlib.redirect_stdout(output), contextlib.redirect_stderr(diagnostics): + code = main(["scan", "192.0.2.1", "--checks", "smbghost", "--discovery", "smb", "--authorised", + "--no-enum4linux", "--no-rpcdump", "-o", str(Path(temporary) / "out")]) + self.assertEqual(code, 0) + self.assertIn("UNKNOWN:", output.getvalue()) + self.assertIn("CHECKS: 1/1", output.getvalue()) + self.assertNotIn("PARTIAL", output.getvalue()) + self.assertNotIn("NOT VULNERABLE", output.getvalue()) + self.assertIn("SUMMARY scan complete", diagnostics.getvalue()) + + def test_dry_run_has_no_files_and_keeps_json_on_stdout(self): + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "scan" + output = io.StringIO() + with patch("rpcscope.runner.doctor", return_value=TOOLS), contextlib.redirect_stdout(output): + code = main(["scan", "192.0.2.1", "--dry-run", "-o", str(destination)]) + self.assertEqual(code, 0) + self.assertFalse(destination.exists()) + self.assertIsInstance(json.loads(output.getvalue()), list) + + def test_missing_offline_input_is_reported_without_traceback(self): + with tempfile.TemporaryDirectory() as temporary: + errors = io.StringIO() + with contextlib.redirect_stderr(errors): + code = main(["analyze", "--nxc", str(Path(temporary) / "missing.txt")]) + self.assertEqual(code, 1) + self.assertIn("error:", errors.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_discovery_integration.py b/tests/test_discovery_integration.py new file mode 100644 index 0000000..9f75b01 --- /dev/null +++ b/tests/test_discovery_integration.py @@ -0,0 +1,295 @@ +"""Exercise default discovery and new adapters with no target network traffic.""" +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from rpcscope.catalog import CHECKS, EXPOSURE_CHECKS, resolve_checks +from rpcscope.checks import CheckResult +from rpcscope.cli import main +from rpcscope.reporting import coverage, minimal +from rpcscope.models import HostEvidence, Report, RpcInterface +from rpcscope.rules import evaluate +from rpcscope.runner import ScanConfig, scan + + +RPC_ONLY = "192.0.2.1" +SMB_ONLY = "192.0.2.2" +TOOLS = {"nxc": "nxc", "enum4linux-ng": "enum", "rpcdump": "rpcdump", "msfconsole": None} + + +def discovered(addresses, on_result, **kwargs): + states = {address: {135: "open" if address == RPC_ONLY else "closed", + 445: "closed" if address == RPC_ONLY else "open"} for address in addresses} + for address, ports in states.items(): + on_result(address, ports) + return states + + +class DiscoveryIntegrationTests(unittest.TestCase): + def test_rpc_only_hosts_survive_and_closed_services_are_not_scanned(self): + calls = [] + def run(command, timeout, log, password, **kwargs): + calls.append(command) + if command[0] == "rpcdump": + self.assertEqual(command[-1], RPC_ONLY) + return 0, f"UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0\nBindings:\n ncacn_ip_tcp:{RPC_ONLY}[50000]\n" + if "-M" in command: + self.assertEqual(command[2], SMB_ONLY) + return 0, f"MS17-010 {SMB_ONLY} 445 HOST {SMB_ONLY} does NOT appear vulnerable" + self.assertNotIn(RPC_ONLY, command) + return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)" + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig(["192.0.2.0/30"], out_dir=Path(temporary), tcp_discovery=True, + use_enum4linux=False, checks=[CHECKS["ms17-010"]]) + events = [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=discovered), \ + patch("rpcscope.runner._run", side_effect=run): + hosts = scan(config, progress=events.append) + self.assertEqual(set(hosts), {RPC_ONLY, SMB_ONLY}) + self.assertEqual(hosts[RPC_ONLY].collection["rpcdump"], "success") + self.assertEqual(hosts[SMB_ONLY].collection["rpcdump"], "skipped-service-unavailable") + self.assertEqual(hosts[RPC_ONLY].checks["ms17-010"], "skipped-service-unavailable") + self.assertEqual(hosts[SMB_ONLY].checks["ms17-010"], "not-vulnerable") + self.assertEqual(events[-1].completed, events[-1].total) + + def test_bind_checks_pass_method_only_and_keep_all_evidence(self): + commands = [] + def run(command, timeout, log, password, **kwargs): + commands.append(command) + if "-M" in command: + method = command[command.index("-o") + 1].partition("=")[2] + return 0, f"COERCE_PLUS {SMB_ONLY} 445 HOST VULNERABLE, {method}" + return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)" + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=resolve_checks(["exposure"]), + use_enum4linux=False, use_rpcdump=False) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run): + hosts = scan(config) + host = hosts[SMB_ONLY] + self.assertEqual(len(host.check_attempts), len(EXPOSURE_CHECKS)) + self.assertTrue(all(status == "exposed" for status in host.checks.values())) + self.assertNotIn("LISTENER", str(commands)) + self.assertNotIn("ALWAYS", str(commands)) + report = Report("now", "test", [], None, None, [host], evaluate([host])) + line = minimal(report) + self.assertIn("INVENTORY ONLY", line) + self.assertIn("EXPOSURE:", line) + self.assertNotIn("VULN:", line) + self.assertNotIn("NOT VULNERABLE", line) + self.assertTrue(all(finding.severity == "info" for finding in report.findings)) + + def test_default_cli_discovers_ports_without_expanding_oxid_scope(self): + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "out" + captured = io.StringIO() + def run(command, timeout, log, password, **kwargs): + if "-M" in command: + return 0, f"IOXIDRESOLVER {SMB_ONLY} 445 HOST Address: 10.99.0.99" + return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)" + def all_open(addresses, on_result, **kwargs): + for address in addresses: + on_result(address, {135: "open", 445: "open"}) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=all_open), \ + patch("rpcscope.runner._run", side_effect=run), contextlib.redirect_stdout(captured), contextlib.redirect_stderr(io.StringIO()): + code = main(["scan", SMB_ONLY, "--checks", "oxid", "--no-enum4linux", "--no-rpcdump", + "--authorised", "--no-progress", "-o", str(output)]) + self.assertEqual(code, 0) + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + self.assertEqual([host["address"] for host in report["hosts"]], [SMB_ONLY]) + self.assertIn("10.99.0.99", report["hosts"][0]["check_details"]["oxid"]) + self.assertIn("INFO: [OXID-INTERFACES]", captured.getvalue()) + + def test_review_catalog_is_not_an_executable_check(self): + captured = io.StringIO() + with contextlib.redirect_stdout(captured): + self.assertEqual(main(["catalog", "--advisories"]), 0) + self.assertIn("CVE-2026-41089", captured.getvalue()) + self.assertIn("review-only", captured.getvalue()) + with self.assertRaises(ValueError): + resolve_checks(["CVE-2026-41089"]) + + def test_netlogon_and_print_rpc_registrations_have_distinct_ids(self): + host = HostEvidence(RPC_ONLY, rpc_interfaces=[ + RpcInterface("12345678-1234-abcd-ef00-01234567cffb", "1.0"), + RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0"), + ]) + findings = evaluate([host]) + self.assertEqual(len({finding.rule_id for finding in findings}), 2) + line = minimal(Report("now", "test", [], None, None, [host], findings)) + self.assertIn("[MS-NRPC]", line) + self.assertIn("[MS-RPRN]", line) + self.assertNotIn(" VULN:", line) + + def test_dead_targets_stop_at_discovery_and_are_silent(self): + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "out" + diagnostics = io.StringIO() + def timeout(addresses, on_result, **kwargs): + for address in addresses: + on_result(address, {135: "timeout", 445: "timeout"}) + captured = io.StringIO() + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=timeout), \ + patch("rpcscope.runner._run") as run, contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics): + code = main(["scan", RPC_ONLY, "--checks", "recommended", "--authorised", "-o", str(output)]) + self.assertEqual(code, 0) + self.assertIn("SUMMARY scan complete", diagnostics.getvalue()) + self.assertNotIn(RPC_ONLY, diagnostics.getvalue()) + self.assertEqual(captured.getvalue(), "") + report = json.loads((output / "report.json").read_text()) + self.assertEqual(report["hosts"][0]["checks"], {}) + self.assertEqual(report["hosts"][0]["check_attempts"], []) + self.assertNotIn("enum4linux", report["hosts"][0]["collection"]) + self.assertEqual((output / "report.txt").read_text(), "") + self.assertNotIn(f"- `{RPC_ONLY}`", (output / "report.md").read_text()) + run.assert_not_called() + + def test_only_live_host_pipelines_are_scheduled_and_uncertainty_completes(self): + dead = "192.0.2.3" + def probe(addresses, on_result, **kwargs): + self.assertEqual(kwargs["jobs"], 256) + for address in addresses: + on_result(address, {135: "timeout", 445: "open" if address == SMB_ONLY else "closed"}) + def run(command, timeout, log, password, **kwargs): + self.assertNotIn(dead, command) + if "-M" in command: + return 0, f"SMBGHOST {SMB_ONLY} 445 HOST Potentially vulnerable to SMBGhost (CVE-2020-0796)" + return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (domain:LAB)" + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([SMB_ONLY, dead], out_dir=Path(temporary), tcp_discovery=True, + use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["smbghost"]]) + events = [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=probe), \ + patch("rpcscope.runner._run", side_effect=run) as execute: + hosts = scan(config, progress=events.append) + self.assertEqual(execute.call_count, 2) + self.assertEqual(hosts[dead].checks, {}) + self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False)) + self.assertEqual(events[-1].status, "complete") + self.assertEqual(events[-1].completed, events[-1].total) + self.assertFalse(any(event.host == dead for event in events)) + + def test_silent_module_is_unknown_not_execution_failure(self): + def run(command, timeout, log, password, **kwargs): + return (0, "") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11") + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["zerologon"]], + use_enum4linux=False, use_rpcdump=False) + events = [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run): + hosts = scan(config, progress=events.append) + host = hosts[SMB_ONLY] + self.assertEqual(host.checks["zerologon"], "inconclusive") + self.assertEqual(coverage(host), (1, 1, False)) + self.assertEqual(events[-1].status, "complete") + text = minimal(Report("now", "test", [], None, None, [host], evaluate([host]))) + self.assertIn("UNKNOWN:", text) + self.assertIn("CHECKS: 1/1", text) + self.assertNotIn("PARTIAL", text) + self.assertNotIn("NOT VULNERABLE", text) + + def test_checker_process_failure_remains_partial(self): + def run(command, timeout, log, password, **kwargs): + return (124, "timeout") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11") + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]], + use_enum4linux=False, use_rpcdump=False) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run): + hosts = scan(config) + self.assertTrue(coverage(hosts[SMB_ONLY])[2]) + self.assertEqual(hosts[SMB_ONLY].collection["check:ms17-010"], "failed") + self.assertTrue(config.errors) + + def test_legacy_discovery_does_not_fabricate_dead_literal_hosts(self): + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]]) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, "")) as execute: + hosts = scan(config) + self.assertEqual(hosts, {}) + self.assertEqual(execute.call_count, 1) + self.assertEqual(config.errors, []) + + def test_legacy_connection_error_line_is_not_an_alive_host(self): + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]]) + text = f"SMB {SMB_ONLY} 445 NONE [-] Error connecting to host: timed out" + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, text)) as execute: + self.assertEqual(scan(config), {}) + self.assertEqual(execute.call_count, 1) + + def test_native_longlogon_runs_without_netexec_and_keeps_precondition_separate(self): + def rpc_host(addresses, on_result, **kwargs): + for address in addresses: + on_result(address, {135: "open", 445: "closed"}) + tools = {key: None for key in TOOLS} + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True, + use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) + with patch("rpcscope.runner.doctor", return_value=tools), patch("rpcscope.runner.discover_services", side_effect=rpc_host), \ + patch("rpcscope.runner._run") as external, patch("rpcscope.runner.probe_longlogon", return_value=CheckResult( + "precondition-met", "validated minimal legacy response; empirical estimate, not patch detection", + )) as native: + hosts = scan(config) + native.assert_called_once() + self.assertEqual(native.call_args.args, (RPC_ONLY,)) + external.assert_not_called() + host = hosts[RPC_ONLY] + self.assertEqual(host.check_attempts[0].backend, "native") + self.assertEqual(host.checks["longlogon"], "precondition-met") + self.assertTrue(Path(host.check_attempts[0].source).is_file()) + self.assertEqual(coverage(host), (1, 1, False)) + line = minimal(Report("now", "test", [], None, None, [host], evaluate([host]))) + self.assertIn("PRECONDITION: [LONGLOGON:MET]", line) + self.assertNotIn("VULN:", line) + self.assertNotIn("NOT VULNERABLE", line) + self.assertTrue(all(finding.severity == "info" for finding in evaluate([host]))) + self.assertEqual(config.errors, []) + + def test_native_longlogon_is_not_attempted_on_dead_targets(self): + def dead(addresses, on_result, **kwargs): + for address in addresses: + on_result(address, {135: "closed", 445: "closed"}) + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True, + use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=dead), \ + patch("rpcscope.runner.probe_longlogon") as native: + hosts = scan(config) + native.assert_not_called() + self.assertEqual(hosts[RPC_ONLY].checks, {}) + + def test_native_dry_run_records_plan_without_sending_packets(self): + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([RPC_ONLY], out_dir=Path(temporary) / "absent", dry_run=True, + use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.probe_longlogon") as native: + self.assertEqual(scan(config), {}) + native.assert_not_called() + self.assertFalse(config.out_dir.exists()) + self.assertTrue(any(command[0] == "" for command in config.commands)) + self.assertFalse(any("-M" in command for command in config.commands)) + + def test_discovery_deadline_does_not_treat_unprobed_ports_as_completed(self): + def interrupted_scope(addresses, on_result, **kwargs): + on_result(addresses[0], {135: "closed"}) + with tempfile.TemporaryDirectory() as temporary: + config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True, + use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) + events = [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), \ + patch("rpcscope.runner.discover_services", side_effect=interrupted_scope), \ + patch("rpcscope.runner.probe_longlogon") as native: + hosts = scan(config, progress=events.append) + native.assert_not_called() + self.assertEqual(hosts[RPC_ONLY].services["445"], "unprobed") + self.assertEqual(hosts[RPC_ONLY].collection["tcp-discovery"], "incomplete") + self.assertEqual(hosts[RPC_ONLY].checks, {}) + self.assertEqual(events[-1].status, "incomplete") + self.assertTrue(config.errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_execution.py b/tests/test_execution.py new file mode 100644 index 0000000..2eb6fc2 --- /dev/null +++ b/tests/test_execution.py @@ -0,0 +1,284 @@ +"""Local subprocess and mocked orchestration tests; collectors never contact hosts.""" + +import json +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from rpcscope.catalog import CHECKS +from rpcscope.runner import ScanConfig, _run, scan + + +HOST = "10.20.30.41" +TOOLS = { + "nxc": "fake-nxc", + "enum4linux-ng": "fake-enum4linux", + "rpcdump": "fake-rpcdump", + "msfconsole": "fake-msfconsole", +} +RPC_UUID = "12345678-1234-abcd-ef00-0123456789ab" + + +def discovery_output(addresses): + return "".join( + f"SMB {address} 445 HOST{number} [*] Windows 7 " + f"(name:HOST{number}) (domain:LAB) (signing:True) (SMBv1:False)\n" + for number, address in enumerate(addresses, 1) + ) + + +class ExecutionTests(unittest.TestCase): + def test_output_limit_stops_a_running_noisy_collector(self): + with tempfile.TemporaryDirectory() as directory: + started = time.monotonic() + code, output = _run([sys.executable, "-B", "-c", + "import sys,time;sys.stdout.write('x'*(17*1024*1024));sys.stdout.flush();time.sleep(10)"], + 8, Path(directory) / "noisy.log", "") + self.assertEqual(code, 125) + self.assertIn("output limit", output) + self.assertLess(len(output), 16 * 1024 * 1024 + 200) + self.assertLess(time.monotonic() - started, 7) + + def test_timeout_retains_partial_output_and_redacts_saved_log(self): + secret = "timeout-secret-9281" + with tempfile.TemporaryDirectory() as directory: + log = Path(directory) / "collector.log" + code, text = _run([ + sys.executable, "-B", "-c", + "import time; print('partial output timeout-secret-9281', flush=True); time.sleep(10)", + ], 0.8, log, secret) + self.assertEqual(code, 124) + self.assertIn("partial output " + secret, text) + self.assertIn("RPCScope timeout", text) + saved = log.read_text(encoding="utf-8") + self.assertNotIn(secret, saved) + self.assertEqual(saved, text.replace(secret, "")) + + def test_passwords_matching_host_digits_or_markers_do_not_corrupt_scanning(self): + for password in ("1", "VULNERABLE"): + with self.subTest(password=password), tempfile.TemporaryDirectory() as directory: + def local_collector(command, timeout, log, supplied_password, **kwargs): + text = ( + f"MS17-010 {HOST} 445 HOST [+] VULNERABLE\n" + if "-M" in command else discovery_output([HOST]) + ) + return _run([ + sys.executable, "-B", "-c", f"print({text!r}, end='')", + ], timeout, log, supplied_password, **kwargs) + + config = ScanConfig( + targets=["example.test"], username="auditor", password=password, + out_dir=Path(directory), use_enum4linux=False, use_rpcdump=False, + checks=[CHECKS["ms17-010"]], + ) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch( + "rpcscope.runner._run", side_effect=local_collector, + ): + hosts = scan(config) + self.assertEqual(set(hosts), {HOST}) + self.assertEqual(hosts[HOST].checks["ms17-010"], "vulnerable") + if password == "VULNERABLE": + self.assertEqual(hosts[HOST].check_details["ms17-010"], "") + for path in (Path(directory) / "raw").iterdir(): + self.assertNotIn(password, path.read_text(encoding="utf-8")) + + def test_cancellation_stops_local_process(self): + with tempfile.TemporaryDirectory() as directory: + cancel = threading.Event() + cancel.set() + code, text = _run([ + sys.executable, "-B", "-c", "import time; time.sleep(10)", + ], 10, Path(directory) / "cancelled.log", "", cancel=cancel) + self.assertEqual(code, 130) + self.assertIn("cancelled", text) + + def test_dry_run_never_creates_output_or_launches_collectors(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "never-created" + config = ScanConfig( + targets=[HOST], username="auditor", password="private-password", + domain="LAB", dry_run=True, out_dir=output, use_msf=True, + checks=[CHECKS["ms17-010"]], + ) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run") as run: + self.assertEqual(scan(config), {}) + run.assert_not_called() + self.assertFalse(output.exists()) + self.assertEqual(list(Path(directory).iterdir()), []) + commands = json.dumps(config.commands) + self.assertNotIn(config.password, commands) + self.assertIn("fake-enum4linux", commands) + self.assertIn("fake-rpcdump", commands) + self.assertIn("fake-msfconsole", commands) + + def test_nonempty_output_is_refused_before_any_collector_runs(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + stale = output / "enum4linux-old.json" + stale.write_text('{"target": {"host": "192.0.2.5"}}', encoding="utf-8") + config = ScanConfig(targets=[HOST], out_dir=output) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run") as run: + with self.assertRaisesRegex(ValueError, "empty directory"): + scan(config) + run.assert_not_called() + self.assertTrue(stale.exists()) + + def test_host_check_concurrency_is_bounded_by_jobs(self): + addresses = ["10.20.30.41", "10.20.30.42", "10.20.30.43", "10.20.30.44"] + barrier = threading.Barrier(2, timeout=3) + lock = threading.Lock() + active = 0 + peak = 0 + checked = [] + + def fake_run(command, timeout, log, password, **kwargs): + nonlocal active, peak + if "-M" not in command: + return 0, discovery_output(addresses) + address = command[2] + with lock: + active += 1 + peak = max(peak, active) + checked.append(address) + try: + barrier.wait() + return 0, f"MS17-010 {address} 445 HOST [+] VULNERABLE\n" + finally: + with lock: + active -= 1 + + with tempfile.TemporaryDirectory() as directory: + config = ScanConfig( + targets=addresses, out_dir=Path(directory), jobs=2, + use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["ms17-010"]], + ) + events = [] + snapshots = [] + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run): + hosts = scan(config, progress=events.append, checkpoint=lambda value: snapshots.append(len(value))) + self.assertEqual(peak, 2) + self.assertCountEqual(checked, addresses) + self.assertTrue(all(host.checks["ms17-010"] == "vulnerable" for host in hosts.values())) + self.assertEqual(config.errors, []) + self.assertEqual(events[-1].kind, "summary") + finishes = [event.completed for event in events if event.kind == "finish"] + self.assertEqual(finishes, sorted(finishes)) + self.assertEqual(finishes[-1], 5) + self.assertEqual(len(snapshots), 5) + + def test_malformed_enum_export_does_not_block_rpcdump(self): + calls = [] + + def fake_run(command, timeout, log, password, **kwargs): + calls.append(command[0]) + if command[0] == TOOLS["nxc"]: + return 0, discovery_output([HOST]) + if command[0] == TOOLS["enum4linux-ng"]: + prefix = command[command.index("-oJ") + 1] + Path(prefix + ".json").write_text("{malformed", encoding="utf-8") + return 0, "" + if command[0] == TOOLS["rpcdump"]: + return 0, f"Protocol: TEST\nProvider: test.dll\nUUID: {RPC_UUID} v1.0\nBindings:\n ncacn_ip_tcp:{HOST}[49670]\n" + raise AssertionError(f"unexpected collector {command[0]}") + + with tempfile.TemporaryDirectory() as directory: + config = ScanConfig(targets=[HOST], out_dir=Path(directory)) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run): + hosts = scan(config) + host = hosts[HOST] + self.assertIn(TOOLS["rpcdump"], calls) + self.assertEqual(host.collection["enum4linux"], "failed") + self.assertEqual(host.collection["rpcdump"], "success") + self.assertEqual(host.rpc_interfaces[0].uuid, RPC_UUID) + self.assertTrue(any("enum4linux" in error for error in config.errors)) + + def test_enum_export_scrubs_escaped_password_and_passes_domain(self): + password = 'spaces "quotes" and \\slashes' + + def fake_run(command, timeout, log, supplied_password, **kwargs): + if command[0] == TOOLS["nxc"]: + return 0, discovery_output([HOST]) + self.assertEqual(command[command.index("-w") + 1], "LAB") + prefix = command[command.index("-oJ") + 1] + data = { + "target": {"host": HOST}, + "credentials": {"password": password, "user": "auditor"}, + "notes": ["echo: " + password], + } + Path(prefix + ".json").write_text(json.dumps(data), encoding="utf-8") + return 0, "" + + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + config = ScanConfig( + targets=[HOST], username="auditor", password=password, domain="LAB", + out_dir=output, use_rpcdump=False, + ) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run): + hosts = scan(config) + exported = json.loads((output / "raw" / f"enum4linux-{HOST}.json").read_text(encoding="utf-8")) + self.assertEqual(exported["credentials"]["password"], "") + self.assertEqual(exported["notes"], ["echo: "]) + self.assertEqual(hosts[HOST].collection["enum4linux"], "success") + self.assertFalse(any(path.name.startswith(".enum-") for path in (output / "raw").iterdir())) + for command in config.commands: + for argument in command: + self.assertNotIn(password, argument) + + def test_enum_password_digit_preserves_target_identity_and_raw_facts(self): + def fake_run(command, timeout, log, supplied_password, **kwargs): + if command[0] == TOOLS["nxc"]: + return 0, f"SMB {HOST} 445 HOST [*] (name:HOST) (domain:LAB)\n" + prefix = command[command.index("-oJ") + 1] + data = { + "target": {"host": HOST}, + "credentials": {"password": "1", "user": "auditor"}, + "os_info": {"Native OS": "Windows 10"}, + } + Path(prefix + ".json").write_text(json.dumps(data), encoding="utf-8") + return 0, "" + + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + config = ScanConfig( + targets=[HOST], username="auditor", password="1", out_dir=output, + use_rpcdump=False, + ) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run): + hosts = scan(config) + self.assertEqual(hosts[HOST].os, "Windows 10") + exported = json.loads((output / "raw" / f"enum4linux-{HOST}.json").read_text(encoding="utf-8")) + self.assertEqual(exported["target"]["host"], HOST) + self.assertEqual(exported["credentials"]["password"], "") + + def test_later_msf_inconclusive_or_negative_preserves_nxc_positive(self): + cases = [ + (0, "[*] scanner completed without a result\n", False), + (0, f"[-] {HOST}:445 - Host does NOT appear vulnerable.\n", True), + ] + for msf_code, msf_text, conflict in cases: + with self.subTest(conflict=conflict), tempfile.TemporaryDirectory() as directory: + def fake_run(command, timeout, log, password, **kwargs): + if command[0] == TOOLS["msfconsole"]: + return msf_code, msf_text + if "-M" in command: + return 0, f"MS17-010 {HOST} 445 HOST [+] VULNERABLE\n" + return 0, discovery_output([HOST]) + + config = ScanConfig( + targets=[HOST], out_dir=Path(directory), use_enum4linux=False, + use_rpcdump=False, use_msf=True, checks=[CHECKS["ms17-010"]], + ) + with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run): + host = scan(config)[HOST] + self.assertEqual(host.checks["ms17-010"], "vulnerable") + self.assertEqual(host.check_details["ms17-010"], "VULNERABLE") + self.assertEqual(any("Conflicting ms17-010" in note for note in host.notes), conflict) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_longlogon.py b/tests/test_longlogon.py new file mode 100644 index 0000000..8d65562 --- /dev/null +++ b/tests/test_longlogon.py @@ -0,0 +1,262 @@ +"""Synthetic protocol fixtures only: these tests never open a network socket.""" +import socket +import struct +import threading +import unittest +from unittest.mock import patch + +from rpcscope import longlogon + + +ADDRESS = "192.0.2.10" +GUID = bytes(range(16)) + + +def tlv(tag, value): + size = len(value) + length = bytes([size]) if size < 128 else b"\x81" + bytes([size]) if size < 256 else b"\x82" + size.to_bytes(2, "big") + return bytes([tag]) + length + value + + +def dns_name(name): + return b"".join(bytes([len(label.encode())]) + label.encode() for label in name.split(".") if label) + b"\0" + + +def identity(domain="lab.example.test", forest="example.test", host=None, nb_domain="LAB", nb_host="DC1", guid=GUID): + return longlogon._Identity(forest, domain, host or "dc1." + domain, nb_domain.casefold(), nb_host.casefold(), guid) + + +def ex_blob(value=None): + value = value or identity() + names = (value.forest, value.domain, value.host, value.netbios_domain, + value.netbios_host, "x", "Default-First-Site-Name", "") + return (struct.pack("alert(1)"] + report.mode = "test`\n# false heading" + text = markdown(report) + self.assertIn("Targets: `target` <script>alert(1)</script>`", text) + self.assertIn("Mode: `test` # false heading`", text) + self.assertNotIn("