Initial RPCScope release

This commit is contained in:
0x000 committed 2026-10-15 12:34:26 -04:00
commit 9dc7289652
40 files changed
+6688

No files matched your search

+22
View File
@@ -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
+21
View File
@@ -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.
+187
View File
@@ -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.
+36
View File
@@ -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.
```
+77
View File
@@ -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.
+55
View File
@@ -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.
+73
View File
@@ -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)
+26
View File
@@ -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"]
+4
View File
@@ -0,0 +1,4 @@
"""RPCScope: normalize and correlate SMB/RPC enumeration evidence."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
raise SystemExit(main())
+234
View File
@@ -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
+260
View File
@@ -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)]
+132
View File
@@ -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")
+229
View File
@@ -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, "<redacted>") 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
+350
View File
@@ -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("<I", 6 if extended else 2))
search = (
_tlv(4, b"") + _tlv(10, b"\0") + _tlv(10, b"\0")
+ _tlv(2, b"\0") + _tlv(2, b"\0") + _tlv(1, b"\0")
+ _tlv(0xA0, terms) + _tlv(0x30, _tlv(4, b"Netlogon"))
)
return _tlv(0x30, _tlv(2, bytes([message_id])) + _tlv(0x63, search))
def _ldap_netlogon(data: bytes, message_id: int) -> 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("<HH", data) not in ((23, 0), (25, 0)):
raise _Invalid("unsupported Netlogon EX response")
if data[-8:] != struct.pack("<IHH", 5, 0xFFFF, 0xFFFF):
raise _Invalid("unsupported Netlogon EX version or trailer")
offset = 24
names = []
for _ in range(8):
name, offset = _name(data, offset, len(data) - 8)
names.append(name)
if offset != len(data) - 8 or names[5] != "x":
raise _Invalid("unexpected EX fields or echoed user")
return _identity(names[:5], data[8:24])
def _unicode_name(data: bytes, offset: int, limit: int) -> 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("<H", data)[0] not in (19, 21):
raise _Invalid("unsupported Netlogon legacy response")
if data[-8:] != struct.pack("<IHH", 3, 0xFFFF, 0xFFFF):
raise _Invalid("unsupported Netlogon legacy version or trailer")
offset = 2
unicode_names = []
for _ in range(3):
name, offset = _unicode_name(data, offset, len(data) - 48)
unicode_names.append(name)
if unicode_names[1] != "x" or offset + 32 >= 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}")
+174
View File
@@ -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)
+169
View File
@@ -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"(?<![\w-])--[a-z][a-z0-9-]*", text)))
def performance_options(self) -> 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)
+263
View File
@@ -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"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?![\d.])")
UUID_RE = re.compile(
r"(?i)\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b"
)
NXC_LINE_RE = re.compile(r"(?i)^\s*SMB\s+(?P<address>\S+)\s+(?P<port>\d+)\s+\S+(?:\s+(?P<payload>.*))?$")
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))
+216
View File
@@ -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()
+246
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("`", "&#96;") + "`"
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))
+242
View File
@@ -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),
)
+719
View File
@@ -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] = "<redacted Metasploit resource script>"
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", "<version-specific-read-only-options>"])
if config.dry_run:
if config.tcp_discovery:
config.commands.append(_redacted(["<native:tcp-discovery>", *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", "<TCP/445-listeners>", *_nxc_auth(config), "-t", str(config.discovery_workers), "--no-progress",
*(["--shares", "<version-specific-read-only-options>"] if config.shares else [])]
config.commands.append(_redacted(discovery, config.password))
if config.dry_run:
address = "<discovered-host>"
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", "<live-host-batch>", *_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(["<native:longlogon>", 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",
"<MS17-010 scanner resource script; credentials redacted>"])
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", "<private-temp-prefix>", 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(["<native:longlogon>", 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"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?![\d.])", path.stem)
if not match:
notes.append(f"{path.name}: no target IP in filename")
continue
try:
address = str(ipaddress.ip_address(match.group(1)))
evidence = parse_rpcdump(path.read_text(encoding="utf-8", errors="replace"), address, str(path))
hosts.setdefault(address, HostEvidence(address)).merge(evidence)
except (OSError, ValueError) as exc:
notes.append(f"{path.name}: {exc}")
return hosts
+55
View File
@@ -0,0 +1,55 @@
"""Private output files with atomic replacement for live report readers."""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def redact_text(value: str, password: str) -> 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, "<redacted>")
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: "<redacted>" 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, "<redacted>")
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")
+264
View File
@@ -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}(?<!-)", re.IGNORECASE)
_STATES = frozenset({"open", "closed", "timeout", "error"})
_PORTS = (135, 445)
def _address_key(address: str) -> 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}
+147
View File
@@ -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()
+209
View File
@@ -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()
+111
View File
@@ -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()
+130
View File
@@ -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] == "<native:longlogon>" 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], "<native:tcp-discovery>")
self.assertEqual(commands[1][2], "<TCP/445-listeners>")
self.assertIn("<version-specific-read-only-options>", 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()
+295
View File
@@ -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] == "<native:longlogon>" 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()
+284
View File
@@ -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, "<redacted>"))
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"], "<redacted>")
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"], "<redacted>")
self.assertEqual(exported["notes"], ["echo: <redacted>"])
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"], "<redacted>")
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()
+262
View File
@@ -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("<HHI", 25, 0, 0x18) + value.guid
+ b"".join(dns_name(name) for name in names)
+ struct.pack("<IHH", 5, 0xFFFF, 0xFFFF))
def legacy_blob(value=None):
value = value or identity()
strings = (value.netbios_host, "x", value.netbios_domain)
return (struct.pack("<H", 21)
+ b"".join(name.encode("utf-16-le") + b"\0\0" for name in strings)
+ value.guid + bytes(16)
+ b"".join(dns_name(name) for name in (value.forest, value.domain, value.host))
+ socket.inet_aton(ADDRESS) + struct.pack("<IIHH", 0x10, 3, 0xFFFF, 0xFFFF))
def ldap(blob, message_id, *, done=True):
attribute = tlv(0x30, tlv(4, b"Netlogon") + tlv(0x31, tlv(4, blob)))
entry = tlv(0x30, tlv(2, bytes([message_id])) + tlv(0x64, tlv(4, b"") + tlv(0x30, attribute)))
if done:
entry += tlv(0x30, tlv(2, bytes([message_id])) + tlv(0x65, tlv(10, b"\0") + tlv(4, b"") + tlv(4, b"")))
return entry
def request_id(request):
outer = longlogon._Reader(request)
inner = longlogon._Reader(outer.take(0x30))
return int.from_bytes(inner.take(2), "big")
class FakeSocket:
def __init__(self, responses):
self.responses = list(responses)
self.sent = []
self.peer = None
self.closed = False
def __enter__(self):
return self
def __exit__(self, *args):
self.closed = True
def connect(self, peer):
self.peer = peer
def settimeout(self, timeout):
self.timeout = timeout
def send(self, request):
self.sent.append(request)
return len(request)
def recv(self, maximum):
if not self.responses:
raise OSError("no UDP reply")
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
if callable(response):
return response(self.sent[-1])
return response
def response(blob):
return lambda request: ldap(blob, request_id(request))
class LongLogonTests(unittest.TestCase):
def probe(self, responses, **kwargs):
fake = FakeSocket(responses)
with patch.object(longlogon.socket, "socket", return_value=fake) as factory:
result = longlogon.probe_longlogon(ADDRESS, **kwargs)
self.assertTrue(fake.closed)
self.assertEqual(fake.peer, (ADDRESS, 389))
self.assertEqual(factory.call_args.args, (socket.AF_INET, socket.SOCK_DGRAM))
return result, fake
def test_valid_pair_is_precondition_only_and_requests_are_fixed_small(self):
result, fake = self.probe([response(ex_blob()), response(legacy_blob())])
self.assertEqual(result.status, "precondition-not-met")
self.assertIn("patch state is not determined", result.evidence)
self.assertIn("projected full-user length=", result.evidence)
self.assertEqual(len(fake.sent), 2)
self.assertNotEqual(request_id(fake.sent[0]), request_id(fake.sent[1]))
self.assertLess(max(map(len, fake.sent)), 256)
self.assertNotIn(b"DnsDomain", fake.sent[0])
self.assertIn(b"DnsDomain", fake.sent[1])
self.assertIn(b"lab.example.test", fake.sent[1])
for request, version in zip(fake.sent, (6, 2)):
self.assertIn(tlv(4, b"User") + tlv(4, b"x"), request)
self.assertIn(tlv(4, b"NtVer") + tlv(4, struct.pack("<I", version)), request)
self.assertNotIn(b"x" * 2, request)
def test_long_identity_can_produce_positive_precondition_without_long_user(self):
value = identity(domain="a" * 60 + ".example.test")
result, fake = self.probe([response(ex_blob(value)), response(legacy_blob(value))])
self.assertEqual(result.status, "precondition-met")
self.assertIn("empirical", result.evidence)
self.assertTrue(all(b"x" * 2 not in request for request in fake.sent))
def test_exact_empirical_thresholds(self):
for estimate, expected in ((527, "precondition-not-met"), (528, "precondition-borderline"),
(535, "precondition-borderline"), (536, "precondition-met")):
with self.subTest(estimate=estimate):
result = longlogon._result("lab.test", 180, estimate - 258, 400)
self.assertEqual(result.status, expected)
self.assertIn(f"length={estimate} B", result.evidence)
def test_known_domain_is_normalized_and_must_match_response(self):
result, fake = self.probe([response(ex_blob()), response(legacy_blob())], domain="LAB.EXAMPLE.TEST.")
self.assertEqual(result.status, "precondition-not-met")
self.assertIn(b"lab.example.test", fake.sent[0])
result, fake = self.probe([response(ex_blob())], domain="other.example.test")
self.assertEqual(result.status, "inconclusive")
self.assertIn("does not match", result.evidence)
self.assertEqual(len(fake.sent), 1)
def test_unsafe_baseline_withholds_legacy_query(self):
domain = ".".join(("a" * 60, "b" * 60, "c" * 60, "test"))
value = identity(domain=domain, forest=domain)
result, fake = self.probe([response(ex_blob(value))])
self.assertEqual(result.status, "inconclusive")
self.assertIn("legacy query withheld", result.evidence)
self.assertIn("upper bound=", result.evidence)
self.assertEqual(len(fake.sent), 1)
def test_no_control_response_and_ex_only_are_unknown_not_negative(self):
result, fake = self.probe([OSError("connection refused")])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
result, fake = self.probe([response(ex_blob()), OSError("no UDP reply")])
self.assertEqual(result.status, "inconclusive")
self.assertIn("EX=", result.evidence)
self.assertIn("legacy=no valid response", result.evidence)
self.assertEqual(len(fake.sent), 2)
def test_socket_timeouts_are_bounded_and_unknown(self):
fake = FakeSocket([socket.timeout()])
with patch.object(longlogon.socket, "socket", return_value=fake), \
patch.object(longlogon.time, "monotonic", side_effect=(0.0, 0.01, 1.0)):
result = longlogon.probe_longlogon(ADDRESS, timeout=0.5)
self.assertEqual(result.status, "inconclusive")
self.assertIn("timed out", result.evidence)
self.assertEqual(len(fake.sent), 1)
def test_invalid_control_packets_never_send_legacy(self):
cases = [b"", b"x" * 8193, b"\x30\x80", b"\x30\x82\xff\xff", b"\x30\x04\x02\x01\x01\x64"]
for raw in cases:
with self.subTest(packet=raw[:8]):
result, fake = self.probe([raw])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
def test_wrong_transaction_id_and_error_only_response_are_unknown(self):
bad_id = lambda request: ldap(ex_blob(), request_id(request) % 127 + 1)
error = lambda request: tlv(0x30, tlv(2, bytes([request_id(request)])) + tlv(0x65, tlv(10, b"\x20") + tlv(4, b"") + tlv(4, b"no object")))
for response_value in (bad_id, error):
result, fake = self.probe([response_value])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
def test_unsupported_control_version_and_opcode_are_unknown(self):
original = ex_blob()
cases = [struct.pack("<H", 24) + original[2:], original[:-8] + struct.pack("<IHH", 3, 0xFFFF, 0xFFFF),
original[:2] + b"\x01\0" + original[4:], original[:-1] + b"\0"]
for blob in cases:
result, fake = self.probe([response(blob)])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
def test_legacy_identity_and_variant_must_match_control(self):
other = identity(guid=b"1" * 16)
old = legacy_blob()
for blob in (legacy_blob(other), old[:-8] + struct.pack("<IHH", 1, 0xFFFF, 0xFFFF), ex_blob()):
result, fake = self.probe([response(ex_blob()), response(blob)])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 2)
def test_ldap_requires_single_netlogon_value_and_success_when_done_present(self):
original = ldap(ex_blob(), 1)
for data in (original.replace(b"Netlogon", b"Anything"), original + original,
original[:-7] + b"\x01" + original[-6:]):
with self.assertRaises(longlogon._Invalid):
longlogon._ldap_netlogon(data, 1)
self.assertEqual(longlogon._ldap_netlogon(ldap(ex_blob(), 1, done=False), 1), ex_blob())
def test_compressed_names_and_cycle_detection(self):
data = b"\x03lab\x04test\0\x02dc\xc0\0"
self.assertEqual(longlogon._name(data, 10, len(data)), ("dc.lab.test", len(data)))
for data, offset in ((b"\xc0\0", 0), (b"\xc0\x02\0", 0), (b"\x40x\0", 0), (b"\x03ab", 0)):
with self.assertRaises(longlogon._Invalid):
longlogon._name(data, offset, len(data))
def test_invalid_target_domain_or_timeout_opens_no_socket(self):
for address, kwargs in (("dc.example.test", {}), (ADDRESS, {"domain": "bad)(*"}),
(ADDRESS, {"timeout": float("inf")}), (ADDRESS, {"timeout": 0})):
with patch.object(longlogon.socket, "socket") as factory:
self.assertEqual(longlogon.probe_longlogon(address, **kwargs).status, "inconclusive")
factory.assert_not_called()
def test_cancel_before_start_sends_nothing(self):
cancel = threading.Event()
cancel.set()
with patch.object(longlogon.socket, "socket") as factory:
result = longlogon.probe_longlogon(ADDRESS, cancel=cancel)
self.assertEqual(result.status, "skipped-cancelled")
factory.assert_not_called()
def test_cancel_after_control_withholds_legacy(self):
cancel = threading.Event()
def control(request):
cancel.set()
return ldap(ex_blob(), request_id(request))
result, fake = self.probe([control], cancel=cancel)
self.assertEqual(result.status, "skipped-cancelled")
self.assertEqual(len(fake.sent), 1)
def test_ipv6_connects_only_to_given_literal_target(self):
fake = FakeSocket([response(ex_blob()), response(legacy_blob())])
with patch.object(longlogon.socket, "socket", return_value=fake) as factory:
result = longlogon.probe_longlogon("2001:db8::10")
self.assertEqual(result.status, "precondition-not-met")
self.assertEqual(factory.call_args.args[0], socket.AF_INET6)
self.assertEqual(fake.peer, ("2001:db8::10", 389, 0, 0))
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import unittest
from rpcscope.models import HostEvidence, RpcInterface, Share
class ModelTests(unittest.TestCase):
def test_share_merge_retains_richer_permissions_and_deduplicates(self):
host = HostEvidence("10.10.10.25", shares=[Share("Data", ["READ"])])
other = HostEvidence("10.10.10.25", shares=[Share("data", ["WRITE"]),
Share("Public", ["READ"]),
Share("PUBLIC", ["WRITE"])])
host.merge(other)
self.assertEqual(len(host.shares), 2)
self.assertEqual(host.shares[0].access, ["READ", "WRITE"])
self.assertEqual(host.shares[1].access, ["READ", "WRITE"])
self.assertEqual(other.shares[0].access, ["WRITE"])
def test_rpc_merge_retains_bindings_and_metadata(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-ABCD-EF00-0123456789AB", "1.0",
endpoint="ncalrpc:[spooler]")
])
other = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncacn_ip_tcp:10.10.10.25[49665]", provider="spoolsv.exe"),
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncacn_ip_tcp:10.10.10.25[49665]"),
])
host.merge(other)
self.assertEqual(len(host.rpc_interfaces), 1)
self.assertEqual(len(host.rpc_interfaces[0].bindings), 2)
self.assertEqual(host.rpc_interfaces[0].provider, "spoolsv.exe")
def test_conflicting_boolean_stays_unknown_across_later_merges(self):
host = HostEvidence("10.10.10.25", signing=False)
host.merge(HostEvidence("10.10.10.25", signing=True))
host.merge(HostEvidence("10.10.10.25", signing=False))
self.assertIsNone(host.signing)
self.assertEqual(len(host.notes), 1)
copied = HostEvidence("10.10.10.25", signing=True)
copied.merge(host)
self.assertIsNone(copied.signing)
def test_confirmed_positive_survives_weaker_results(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
check_details={"ms17-010": "positive evidence"})
host.merge(HostEvidence("10.10.10.25", checks={"ms17-010": "inconclusive"},
check_details={"ms17-010": "timeout"}))
self.assertEqual(host.checks["ms17-010"], "vulnerable")
self.assertEqual(host.check_details["ms17-010"], "positive evidence")
host.merge(HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"}))
self.assertEqual(host.checks["ms17-010"], "vulnerable")
self.assertIn("Conflicting", host.notes[0])
def test_suspected_survives_negative_and_collection_updates(self):
host = HostEvidence("10.10.10.25", checks={"check": "suspected"},
collection={"rpcdump": "pending"})
host.merge(HostEvidence("10.10.10.25", checks={"check": "not-vulnerable"},
collection={"rpcdump": "success"}))
self.assertEqual(host.checks["check"], "suspected")
self.assertEqual(host.collection["rpcdump"], "success")
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""Offline tests for CLI compatibility, bounded planning and result isolation."""
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from rpcscope.catalog import CHECKS, EXPOSURE_CHECKS, resolve_checks
from rpcscope.checks import classify_nxc, index_messages
from rpcscope.models import HostEvidence
from rpcscope.netexec import (NxcCapabilities, _inspect, host_failures, inspect_capabilities,
invocation_failed, plan_batches, validate_literal_credentials)
class NetExecTests(unittest.TestCase):
def test_readonly_share_modes_are_version_aware(self):
legacy = NxcCapabilities.from_help("--shares --no-write-check --no-admin-check")
current = NxcCapabilities.from_help("--shares --file-write-check --no-admin-check")
self.assertEqual(legacy.readonly_shares(), ["--shares", "--no-write-check"])
self.assertEqual(current.readonly_shares(), ["--shares"])
self.assertEqual(current.performance_options(), ["--no-admin-check"])
self.assertIsNone(NxcCapabilities.from_help("--shares").readonly_shares())
self.assertEqual(NxcCapabilities().performance_options(), [])
def test_help_is_cached_and_does_not_send_targets_or_credentials(self):
_inspect.cache_clear()
with tempfile.TemporaryDirectory() as temporary:
tool = Path(temporary) / "nxc"
tool.write_text("placeholder", encoding="utf-8")
completed = SimpleNamespace(returncode=0, stdout=b"--shares --file-write-check")
with patch("rpcscope.netexec.subprocess.run", return_value=completed) as run:
first = inspect_capabilities(str(tool))
self.assertEqual(first, inspect_capabilities(str(tool)))
self.assertEqual(run.call_count, 1)
self.assertEqual(run.call_args.args[0], [str(tool.resolve()), "smb", "--help"])
self.assertEqual(run.call_args.kwargs["timeout"], 10)
self.assertEqual(first.readonly_shares(), ["--shares"])
_inspect.cache_clear()
def test_missing_or_failing_help_has_no_assumed_write_safety(self):
self.assertIsNone(inspect_capabilities("/missing/nxc").readonly_shares())
_inspect.cache_clear()
with patch("rpcscope.netexec.subprocess.run", side_effect=OSError("unavailable")):
self.assertIsNone(_inspect("/missing/nxc", 1, 1).readonly_shares())
_inspect.cache_clear()
def test_credentials_are_never_silently_expanded_as_files(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "literal-secret"
path.write_text("not a credential list", encoding="utf-8")
for user, password in ((str(path), ""), ("auditor", str(path))):
with self.assertRaisesRegex(ValueError, "credential list"):
validate_literal_credentials(user, password)
validate_literal_credentials("", "")
validate_literal_credentials("auditor", "ordinary-literal")
def test_recommended_batches_are_bounded_and_combine_only_selected_binds(self):
hosts = {f"192.0.2.{i}": HostEvidence(f"192.0.2.{i}", services={"135": "open", "445": "open"})
for i in range(1, 33)}
batches, skipped = plan_batches(resolve_checks(["recommended"]), hosts, jobs=8,
tcp_discovery=True, has_nxc=True, username="")
self.assertEqual(len(batches), 28)
self.assertEqual(skipped, [])
self.assertTrue(all(len(batch.targets) == 8 for batch in batches))
bind_batches = [batch for batch in batches if batch.module == "coerce_plus"]
self.assertEqual(len(bind_batches), 4)
self.assertTrue(all(batch.options == ("METHOD=All",) for batch in bind_batches))
self.assertTrue(all({check.key for check in batch.checks} == set(EXPOSURE_CHECKS) for batch in bind_batches))
limited, _ = plan_batches(resolve_checks(["printerbug,petitpotam"]), hosts, jobs=8,
tcp_discovery=True, has_nxc=True, username="")
self.assertTrue(all(batch.options != ("METHOD=All",) for batch in limited))
self.assertEqual({check.key for batch in limited for check in batch.checks}, {"printerbug", "petitpotam"})
def test_service_credentials_and_ip_version_gate_before_batch_creation(self):
hosts = {"192.0.2.1": HostEvidence("192.0.2.1", services={"135": "open", "445": "closed"}),
"2001:db8::1": HostEvidence("2001:db8::1", services={"135": "open", "445": "open"})}
batches, skipped = plan_batches(resolve_checks(["ms17-010,nopac"]), hosts, jobs=8,
tcp_discovery=True, has_nxc=True, username="")
self.assertEqual(batches, [])
reasons = {(host, definition.key): result.status for host, definition, result in skipped}
self.assertEqual(reasons[("192.0.2.1", "ms17-010")], "skipped-service-unavailable")
self.assertEqual(reasons[("2001:db8::1", "ms17-010")], "skipped-unsupported")
self.assertEqual(reasons[("2001:db8::1", "nopac")], "skipped-no-credentials")
def test_indexed_results_remain_attributed_by_host_module_and_port(self):
text = "\n".join([
"MS17-010 192.0.2.1 445 HOST VULNERABLE",
"MS17-010 192.0.2.2 445 HOST 192.0.2.2 does NOT appear vulnerable",
"MS17-010 192.0.2.3 80 HOST VULNERABLE",
"SMB 192.0.2.4 445 HOST [+] LAB\\auditor:VULNERABLE",
])
indexed = index_messages(text)
for address, expected in (("192.0.2.1", "vulnerable"), ("192.0.2.2", "not-vulnerable"),
("192.0.2.3", "inconclusive"), ("192.0.2.4", "inconclusive")):
self.assertEqual(classify_nxc("ms17-010", address, "", index=indexed).status, expected)
def test_printnightmare_negative_must_be_a_known_full_marker(self):
for message, status in (("Not vulnerable :'(", "not-vulnerable"),
("Not vulnerable? Unable to determine", "inconclusive")):
text = f"PRINTNIGHT… 192.0.2.1 445 HOST {message}"
self.assertEqual(classify_nxc("printnightmare", "192.0.2.1", text).status, status)
def test_zero_exit_host_errors_are_scoped_and_not_credential_echoes(self):
text = ("Exception while calling proto_flow() on target 192.0.2.2: error\n"
"Traceback (most recent call last):\nValueError: bad response\n"
"SMB 192.0.2.1 445 HOST [+] LAB\\auditor:Exception for target 192.0.2.1\n")
targets = ("192.0.2.1", "192.0.2.2")
self.assertEqual(host_failures(text, targets), {"192.0.2.2"})
self.assertFalse(invocation_failed(text, targets))
self.assertTrue(invocation_failed("Traceback (most recent call last):\nImportError: missing dependency"))
self.assertTrue(invocation_failed("nxc: error: unrecognized arguments: --no-write-check"))
self.assertFalse(invocation_failed("SMB 192.0.2.1 445 HOST [+] LAB\\user:Module not found:"))
def test_ipv6_root_errors_preserve_compressed_literal(self):
text = "Exception for target 2001:db8::: failure\nException for target 2001:db8::1: failure"
self.assertEqual(host_failures(text, ("2001:db8::", "2001:db8::1")), {"2001:db8::", "2001:db8::1"})
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""RPC exposure and inventory outputs never imply successful exploitation."""
import unittest
from rpcscope.catalog import (
CHECKS, CREDENTIALLESS_CHECKS, EXPOSURE_CHECKS, INFO_CHECKS,
RECOMMENDED_CHECKS, resolve_checks,
)
from rpcscope.checks import classify_nxc
HOST = "192.0.2.40"
def nxc_line(module, message, host=HOST, port=445):
return f"{module:16} {host} {port} HOST1 [+] {message}\n"
class RpcExposureTests(unittest.TestCase):
def test_presets_are_explicit_and_keep_existing_credentialless_selection(self):
self.assertEqual(tuple(check.key for check in resolve_checks(["credentialless"])), CREDENTIALLESS_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["safe"])), CREDENTIALLESS_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["exposure"])), EXPOSURE_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["info"])), INFO_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["recommended"])), RECOMMENDED_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["info,oxid"])), INFO_CHECKS)
def test_bind_checks_supply_only_a_method_and_no_cve_claim(self):
for key in EXPOSURE_CHECKS:
with self.subTest(key=key):
definition = CHECKS[key]
self.assertEqual(definition.nxc_module, "coerce_plus")
self.assertEqual(len(definition.nxc_options), 1)
self.assertTrue(definition.nxc_options[0].startswith("METHOD="))
self.assertEqual(definition.cves, ())
self.assertFalse(definition.requires_credentials)
self.assertEqual(definition.positive_status, "exposed")
self.assertNotIn("LISTENER", repr(definition.nxc_options))
self.assertNotIn("ALWAYS", repr(definition.nxc_options))
def test_recommended_includes_longlogon_once_as_precondition_only(self):
checks = resolve_checks(["recommended,longlogon"])
self.assertEqual(len(checks), 12)
self.assertEqual(sum(check.key == "longlogon" for check in checks), 1)
self.assertIn("longlogon", [check.key for check in resolve_checks(["recommended"])])
self.assertEqual(CHECKS["longlogon"].kind, "precondition")
self.assertEqual(CHECKS["longlogon"].positive_status, "precondition-met")
self.assertEqual(resolve_checks([]), [])
def test_each_bind_marker_is_only_exposure_and_matches_selected_method(self):
for key in EXPOSURE_CHECKS:
with self.subTest(key=key):
method = CHECKS[key].nxc_options[0].partition("=")[2]
text = nxc_line("COERCE_PLUS", f"VULNERABLE, {method}")
result = classify_nxc(key, HOST, text)
self.assertEqual(result.status, "exposed")
self.assertIn("bind accepted", result.evidence)
for other in set(EXPOSURE_CHECKS) - {key}:
self.assertEqual(classify_nxc(other, HOST, text).status, "inconclusive")
def test_coercion_success_words_alone_are_not_bind_confirmation(self):
for message in ("VULNERABLE", "Exploit Success, lsarpc\\EfsRpcOpenFileRaw", "Target is not vulnerable to PetitPotam"):
with self.subTest(message=message):
self.assertEqual(classify_nxc("petitpotam", HOST, nxc_line("COERCE_PLUS", message)).status, "inconclusive")
def test_bind_requires_exact_target_module_port_and_successful_tool_exit(self):
for text in (
nxc_line("COERCE_PLUS", "VULNERABLE, PetitPotam", host="192.0.2.41"),
nxc_line("SMB", "VULNERABLE, PetitPotam"),
nxc_line("COERCE_PLUS", "VULNERABLE, PetitPotam", port=80),
"COERCE_PLUS VULNERABLE, PetitPotam\n",
):
with self.subTest(text=text):
self.assertEqual(classify_nxc("petitpotam", HOST, text).status, "inconclusive")
self.assertEqual(classify_nxc("petitpotam", HOST, nxc_line("COERCE_PLUS", "VULNERABLE, PetitPotam"), 124).status, "inconclusive")
def test_spooler_marker_is_inventory_only(self):
for port in (135, 445):
result = classify_nxc("spooler", HOST, nxc_line("SPOOLER", "Spooler service enabled", port=port))
self.assertEqual(result.status, "observed")
self.assertIn("patch status was not established", result.evidence)
for message in ("No endpoints found", "Spooler service disabled", "Protocol failed: access denied"):
self.assertEqual(classify_nxc("spooler", HOST, nxc_line("SPOOLER", message)).status, "inconclusive")
def test_oxid_only_keeps_valid_attributed_ip_addresses(self):
text = (
nxc_line("IOXIDRESOLVER", "Address: 10.2.3.4\x00", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: 2001:db8::12\x00", port=445)
+ nxc_line("IOXIDRESOLVER", "Address: 10.2.3.4", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: 999.2.3.4", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: another-machine", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: 10.2.3.9", host="192.0.2.41")
+ nxc_line("SMB", "Address: 10.2.3.10")
)
result = classify_nxc("oxid", HOST, text)
self.assertEqual(result.status, "observed")
self.assertEqual(result.evidence, "Advertised interface IPs: 10.2.3.4, 2001:db8::12")
def test_oxid_malformed_or_unattributed_output_remains_inconclusive(self):
for text in ("", "Address: 10.2.3.4\n", nxc_line("IOXIDRESOLVER", "Address: not-an-ip")):
with self.subTest(text=text):
self.assertEqual(classify_nxc("oxid", HOST, text).status, "inconclusive")
if __name__ == "__main__":
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
import unittest
from rpcscope.parsers import parse_enum4linux, parse_nxc, parse_rpcdump
class ParserTests(unittest.TestCase):
def test_nxc_alive_evidence_requires_a_banner_or_positive_response(self):
hosts = parse_nxc("\n".join([
"SMB 192.0.2.1 445 NONE [-] Error connecting to host: timed out",
"SMB 192.0.2.2 445 HOST [*] Windows 11 (name:HOST)",
"SMB 192.0.2.3 445 HOST [+] LAB\\auditor:password",
]))
self.assertEqual(hosts["192.0.2.1"].services, {})
self.assertEqual(hosts["192.0.2.2"].services, {"445": "open"})
self.assertEqual(hosts["192.0.2.3"].services, {"445": "open"})
def test_parse_nxc_host_and_share(self):
text = """
SMB 10.10.10.25 445 FILE01 [*] Windows 7 Build 7601 (name:FILE01) (domain:LAB) (signing:False) (SMBv1:True)
SMB 10.10.10.25 445 FILE01 [+] LAB\\guest
SMB 10.10.10.25 445 FILE01 Public READ,WRITE Team share
"""
host = parse_nxc(text)["10.10.10.25"]
self.assertEqual(host.hostname, "FILE01")
self.assertIs(host.smb1, True)
self.assertIs(host.signing, False)
self.assertIs(host.guest_session, True)
self.assertEqual(host.shares[0].name, "Public")
self.assertEqual(host.shares[0].access, ["READ", "WRITE"])
def test_parse_rpcdump_block(self):
text = """
Protocol: [MS-RPRN]: Print System Remote Protocol
Provider: spoolsv.exe
UUID : 12345678-1234-ABCD-EF00-0123456789AB v1.0
Bindings:
ncacn_np:10.10.10.25[\\pipe\\spoolss]
"""
host = parse_rpcdump(text, "10.10.10.25")
self.assertEqual(host.rpc_interfaces[0].uuid, "12345678-1234-abcd-ef00-0123456789ab")
self.assertIn("spoolss", host.rpc_interfaces[0].endpoint)
def test_parse_enum4linux_core_fields(self):
data = {
"target": {"host": "10.10.10.25"},
"sessions": {"sessions_possible": True, "null": True, "guest": False},
"smb": {"smb1_supported": True, "signing_required": False},
"shares": {"Public": {"access": {"mapping": "OK", "listing": "OK"}}},
}
host = parse_enum4linux(data, "fixture.json")
self.assertEqual(host.address, "10.10.10.25")
self.assertIs(host.null_session, True)
self.assertIs(host.smb1, True)
self.assertIs(host.signing, False)
self.assertEqual(host.shares[0].access, ["READ"])
def test_enum4linux_current_schema_and_server_facts(self):
data = {
"target": {"host": "10.10.10.25"},
"credentials": {"domain": "LOGIN-DOMAIN", "password": "WRITE"},
"smb_dialects": {
"Supported dialects": {"SMB 1.0": False, "SMB 3.1.1": True},
"SMB1 only": False,
"SMB signing required": True,
},
"smb_domain_info": {"NetBIOS computer name": "FILE01",
"NetBIOS domain name": "LAB"},
"rpc_domain_info": {"Domain": "RPC-LAB"},
"os_info": {"OS": "Windows Server 2019", "Native OS": "Windows 10.0"},
}
host = parse_enum4linux(data, "fixture.json")
self.assertIs(host.smb1, False)
self.assertIs(host.signing, True)
self.assertEqual(host.hostname, "FILE01")
self.assertEqual(host.domain, "RPC-LAB")
self.assertEqual(host.os, "Windows Server 2019")
def test_enum_share_requires_successful_listing(self):
data = {
"target": {"host": "10.10.10.25"},
"shares": {
"Denied": {"access": {"mapping": "ok", "listing": "denied"}},
"WrongPassword": {"access": {"mapping": "ok", "listing": "wrong password"}},
"IPC$": {"access": {"mapping": "ok", "listing": "not supported"}},
"NoProbe": {"comment": "READ WRITE backup share"},
"FalseFlags": {"access": {"read": False, "write": False}},
"Readable": {"access": {"mapping": "ok", "listing": "ok"}},
},
}
host = parse_enum4linux(data, "fixture.json")
permissions = {share.name: share.access for share in host.shares}
self.assertEqual(permissions.pop("Readable"), ["READ"])
self.assertTrue(all(access == [] for access in permissions.values()))
def test_enum_ignores_client_settings_and_credentials(self):
host = parse_enum4linux({"target": {"host": "10.10.10.25"},
"credentials": {"domain": "ATTACKER"},
"smb": {"ipc_signing": False},
"errors": {"os": "failed"}}, "fixture.json")
self.assertIsNone(host.signing)
self.assertEqual(host.domain, "")
self.assertEqual(host.os, "")
def test_nxc_does_not_infer_null_or_guest_from_partial_names(self):
text = """
SMB 10.10.10.25 445 FILE01 [+] LAB\\annulled:anonymous
SMB 10.10.10.25 445 FILE01 [+] LAB\\guestadmin:password
SMB 10.10.10.25 445 FILE01 [-] guest session failed
"""
host = parse_nxc(text)["10.10.10.25"]
self.assertIsNone(host.null_session)
self.assertIsNone(host.guest_session)
def test_nxc_explicit_null_and_guest_flags(self):
text = """
SMB 10.10.10.25 445 FILE01 [*] Windows Server 2019 (name:FILE01) (domain:LAB) (Null Auth:True) (Guest Auth:True)
"""
host = parse_nxc(text)["10.10.10.25"]
self.assertIs(host.null_session, True)
self.assertIs(host.guest_session, True)
def test_nxc_handles_ipv6_and_repeated_share_permissions(self):
text = """
SMB 2001:db8::25 445 FILE01 [*] Windows Server 2019 (name:FILE01) (domain:LAB)
SMB 2001:db8::25 445 FILE01 Data READ first pass
SMB 2001:db8::25 445 FILE01 data READ,WRITE second pass
SMB 2001:db8::25 445 FILE01 Team Files READ Shared files
"""
host = parse_nxc(text)["2001:db8::25"]
self.assertEqual(len(host.shares), 2)
self.assertEqual(host.shares[0].access, ["READ", "WRITE"])
self.assertEqual(host.shares[1].name, "Team Files")
def test_nxc_does_not_parse_credential_or_comment_as_share(self):
text = """
SMB 10.10.10.25 445 FILE01 [+] LAB\\user:READ WRITE
SMB 10.10.10.25 445 FILE01 Public READONLY This is a comment
"""
self.assertEqual(parse_nxc(text)["10.10.10.25"].shares, [])
def test_nxc_share_table_keeps_acl_labels_and_ignores_denied_remarks(self):
prefix = "SMB 10.10.10.25 445 FILE01 "
rows = [f"{'Share':<15} {'Permissions':<22} Remark",
f"{'Denied':<15} {'':<22} READ WRITE access is denied",
f"{'AclOnly':<15} {'READ,WRITE (ACL)':<22} Permission changes allowed",
f"{'SubdirOnly':<15} {'WRITE (SUBDIR)':<22} Directory creation allowed",
f"{'A very long share name':<15} {'READ':<22} Long name"]
host = parse_nxc("\n".join(prefix + row for row in rows))["10.10.10.25"]
self.assertEqual([share.name for share in host.shares],
["AclOnly", "SubdirOnly", "A very long share name"])
self.assertEqual(host.shares[0].access, ["READ", "WRITE (ACL)"])
self.assertEqual(host.shares[1].access, ["WRITE (SUBDIR)"])
def test_rpcdump_preserves_all_bindings_and_inline_annotation(self):
text = """
Protocol: [MS-RPRN]: Print System Remote Protocol
Provider: spoolsv.exe
UUID : 12345678-1234-ABCD-EF00-0123456789AB v1.0 Print annotation
Bindings:
ncalrpc:[local-spooler]
ncacn_ip_tcp:10.10.10.25[49665]
ncacn_np:10.10.10.25[\\pipe\\spoolss]
ncacn_np:10.10.10.25[\\pipe\\spoolss]
"""
interface = parse_rpcdump(text, "10.10.10.25").rpc_interfaces[0]
self.assertEqual(len(interface.bindings), 3)
self.assertEqual(interface.endpoint, "ncalrpc:[local-spooler]")
self.assertEqual(interface.annotation, "Print annotation")
def test_rpcdump_unlabelled_uuid_in_mixed_output(self):
text = """
UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0
Bindings: ncacn_ip_tcp:10.10.10.25[49665]
367abb81-9844-35f1-ad32-98f038001003 2.0 Service manager
Bindings:
ncacn_np:10.10.10.25[\\pipe\\svcctl]
"""
interfaces = parse_rpcdump(text, "10.10.10.25").rpc_interfaces
self.assertEqual(len(interfaces), 2)
self.assertEqual(interfaces[1].version, "2.0")
self.assertEqual(interfaces[1].annotation, "Service manager")
if __name__ == "__main__":
unittest.main()
+165
View File
@@ -0,0 +1,165 @@
import io
import unittest
from unittest.mock import patch
from rpcscope.progress import ProgressEvent, ProgressReporter
class Clock:
def __init__(self):
self.now = 0.0
def __call__(self):
return self.now
class Terminal(io.StringIO):
def isatty(self):
return True
class ProgressTests(unittest.TestCase):
def test_redirected_output_has_results_and_no_terminal_codes(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock)
reporter(ProgressEvent("start", "ms17-010", "10.0.0.1", total=2))
clock.now = 1.25
reporter(ProgressEvent("finish", "ms17-010", "10.0.0.1", "vulnerable", 1, 2))
reporter(ProgressEvent("summary", "scan", status="complete", completed=2, total=2))
self.assertIn("[0/2] START ms17-010 10.0.0.1", output.getvalue())
self.assertIn("[1/2] DONE ms17-010 10.0.0.1 vulnerable (1.2s)", output.getvalue())
self.assertIn("[2/2] SUMMARY scan complete", output.getvalue())
self.assertNotIn("\x1b", output.getvalue())
self.assertNotIn("\r", output.getvalue())
def test_heartbeat_requires_inactivity_and_an_active_task(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock, interval=10)
reporter._render()
self.assertEqual(output.getvalue(), "")
reporter(ProgressEvent("start", "discovery", total=3))
initial = output.getvalue()
clock.now = 9
reporter._render()
self.assertEqual(output.getvalue(), initial)
clock.now = 10
reporter._render()
self.assertIn("[0/3] RUNNING discovery (elapsed 10s)", output.getvalue())
first_heartbeat = output.getvalue()
clock.now = 19
reporter._render()
self.assertEqual(output.getvalue(), first_heartbeat)
reporter(ProgressEvent("summary", "scan", status="complete", completed=3, total=3))
final = output.getvalue()
clock.now = 99
reporter._render()
self.assertEqual(output.getvalue(), final)
def test_parallel_tasks_and_out_of_order_counts(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock)
reporter(ProgressEvent("start", "rpcdump", "10.0.0.1", total=4))
reporter(ProgressEvent("start", "rpcdump", "10.0.0.2", total=4))
clock.now = 10
reporter._render()
self.assertIn("10.0.0.1 (+1 active)", output.getvalue())
reporter(ProgressEvent("finish", "rpcdump", "10.0.0.2", "failed", 2, 4))
reporter(ProgressEvent("finish", "rpcdump", "10.0.0.1", "ok", 1, 4))
self.assertIn("[2/4] DONE rpcdump 10.0.0.1 ok", output.getvalue())
def test_silent_discovery_advance_keeps_counts_without_printing_dead_hosts(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock, interval=10)
reporter(ProgressEvent("start", "tcp-discovery", total=254))
initial = output.getvalue()
clock.now = 9
reporter(ProgressEvent("advance", "tcp-discovery", "192.0.2.9", "closed", 9, 254))
self.assertEqual(output.getvalue(), initial)
clock.now = 10
reporter._render()
self.assertIn("[9/254] RUNNING tcp-discovery (elapsed 10s)", output.getvalue())
self.assertNotIn("192.0.2.9", output.getvalue())
reporter(ProgressEvent("finish", "tcp-discovery", "192.0.2.10", "alive", 10, 254))
self.assertIn("[10/254] DONE tcp-discovery 192.0.2.10 alive", output.getvalue())
def test_tty_advance_does_not_draw_dead_target_or_keep_it_active(self):
output = Terminal()
reporter = ProgressReporter(output)
reporter(ProgressEvent("start", "tcp-discovery", total=254))
initial = output.getvalue()
reporter(ProgressEvent("advance", "tcp-discovery", "192.0.2.9", "timeout", 1, 254))
self.assertEqual(output.getvalue(), initial)
self.assertNotIn("192.0.2.9", reporter._dashboard(reporter.clock()))
self.assertIn("[1/254]", reporter._dashboard(reporter.clock()))
def test_hostless_discovery_advance_preserves_aggregate_heartbeat(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock, interval=10)
reporter(ProgressEvent("start", "tcp-discovery", total=254))
initial = output.getvalue()
reporter(ProgressEvent("advance", "tcp-discovery", completed=1, total=254))
self.assertEqual(output.getvalue(), initial)
clock.now = 10
reporter._render()
self.assertIn("[1/254] RUNNING tcp-discovery (elapsed 10s)", output.getvalue())
def test_untrusted_text_cannot_inject_control_sequences_or_lines(self):
output = io.StringIO()
reporter = ProgressReporter(output, mode="plain")
reporter(ProgressEvent(
"finish", "check", "10.0.0.1\r\nFAKE", "\x1b[31mvulnerable\x1b[0m",
detail="one\tline\x00\u202e\nonly",
))
self.assertEqual(len(output.getvalue().splitlines()), 1)
self.assertIn("10.0.0.1 FAKE vulnerable", output.getvalue())
self.assertIn("one line only", output.getvalue())
self.assertNotIn("\x1b", output.getvalue())
self.assertNotIn("\u202e", output.getvalue())
def test_tty_dashboard_retains_completed_result(self):
output = Terminal()
clock = Clock()
reporter = ProgressReporter(output, clock=clock)
reporter(ProgressEvent("start", "check", "10.0.0.1", total=1))
self.assertIn("\r\x1b[2K[0/1] RUNNING check 10.0.0.1", output.getvalue())
reporter(ProgressEvent("finish", "check", "10.0.0.1", "inconclusive", 1, 1))
self.assertIn("DONE check 10.0.0.1 inconclusive (0.0s)\n", output.getvalue())
reporter.__exit__(None, None, None)
self.assertTrue(output.getvalue().endswith("\n"))
def test_plain_mode_never_uses_ansi_even_on_a_terminal(self):
output = Terminal()
reporter = ProgressReporter(output, mode="plain")
reporter(ProgressEvent("start", "discovery"))
self.assertNotIn("\x1b", output.getvalue())
def test_context_starts_and_joins_heartbeat_thread(self):
output = io.StringIO()
with patch("rpcscope.progress.threading.Thread") as thread:
with ProgressReporter(output) as reporter:
reporter(ProgressEvent("start", "discovery"))
thread.return_value.start.assert_called_once()
thread.return_value.join.assert_called_once()
self.assertTrue(reporter._stop.is_set())
final = output.getvalue()
reporter(ProgressEvent("start", "ignored"))
reporter._render()
self.assertEqual(output.getvalue(), final)
def test_quiet_does_not_create_a_thread_or_output(self):
output = io.StringIO()
with patch("rpcscope.progress.threading.Thread") as thread:
with ProgressReporter(output, mode="quiet") as reporter:
reporter(ProgressEvent("start", "discovery"))
reporter._render()
thread.assert_not_called()
self.assertEqual(output.getvalue(), "")
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
import unittest
from rpcscope.models import Finding, HostEvidence, Report
from rpcscope.reporting import coverage, markdown, minimal
class MinimalReportTests(unittest.TestCase):
def report(self, host, findings=None, principal=None):
return Report("now", "test", [], None, principal, [host], findings or [])
def test_positive_vulnerability_line(self):
host = HostEvidence("10.10.10.25", checks={"zerologon": "vulnerable"})
line = minimal(self.report(host, principal="LAB\\auditor"))
self.assertIn("10.10.10.25 (LAB\\auditor) VULN: [CVE-2020-1472/ZEROLOGON]", line)
self.assertNotIn("CHECKED:", line)
self.assertNotIn("password", line)
def test_explicit_negative_can_say_not_vulnerable(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"})
self.assertIn("NOT VULNERABLE", minimal(self.report(host)))
def test_no_checks_is_inventory_not_a_vulnerability_verdict(self):
host = HostEvidence("10.10.10.25")
line = minimal(self.report(host))
self.assertIn("INVENTORY ONLY", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("10.10.10.25 NOT VULNERABLE", line)
def test_exposure_is_not_a_vulnerability(self):
host = HostEvidence("10.10.10.25")
finding = Finding("SMB-001", host.address, "SMBv1", "high", "high", "x", "x", "x")
line = minimal(self.report(host, [finding]))
self.assertIn("EXPOSURE: [SMBv1]", line)
self.assertNotIn("VULN: [SMBv1]", line)
def test_suspected_finishes_a_check_while_pending_remains_in_progress(self):
host = HostEvidence("10.10.10.25", checks={"smbghost": "suspected", "ms17-010": "pending"})
line = minimal(self.report(host))
self.assertIn("SUSPECTED: [CVE-2020-0796/SMBGHOST]", line)
self.assertIn("CHECKS: 1/2 (IN PROGRESS)", line)
self.assertNotIn("NO VULNERABILITIES CONFIRMED", line)
self.assertNotIn(" VULN:", line)
def test_failed_collection_prevents_unqualified_negative(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"},
collection={"rpcdump": "failed"})
line = minimal(self.report(host))
self.assertIn("PARTIAL", line)
self.assertNotIn(" NOT VULNERABLE", line)
def test_heuristic_and_unknown_results_complete_execution_without_clean_verdict(self):
host = HostEvidence("10.10.10.25", checks={
"smbghost": "suspected", "ms17-010": "inconclusive", "printerbug": "exposed",
"oxid": "observed", "zerologon": "not-vulnerable",
})
self.assertEqual(coverage(host), (5, 5, False))
line = minimal(self.report(host))
self.assertIn("CHECKS: 5/5", line)
self.assertIn("UNKNOWN: [MS17-010]", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("PARTIAL", line)
self.assertNotIn("NOT VULNERABLE", line)
def test_prerequisite_skips_have_separate_counts_and_never_imply_patched(self):
host = HostEvidence("10.10.10.25", checks={
"ms17-010": "not-vulnerable", "nopac": "skipped-no-credentials",
"ntlm-reflection": "skipped-unsupported",
})
self.assertEqual(coverage(host), (1, 1, False))
line = minimal(self.report(host))
self.assertIn("CHECKS: 1/1 SKIPPED: 2", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("NOT VULNERABLE", line)
host.checks = {"nopac": "skipped-no-credentials"}
self.assertIn("NO CHECKS RUN SKIPPED: 1", minimal(self.report(host)))
def test_interruption_is_partial_rather_than_an_ordinary_prerequisite_skip(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "skipped-cancelled"})
self.assertEqual(coverage(host), (0, 1, True))
line = minimal(self.report(host))
self.assertIn("CHECKS: 0/1 (PARTIAL)", line)
self.assertNotIn("SKIPPED:", line)
def test_precondition_outcomes_are_completed_and_do_not_imply_a_cve_verdict(self):
for status, label in (("precondition-met", "MET"), ("precondition-not-met", "NOT-MET"),
("precondition-borderline", "BORDERLINE")):
with self.subTest(status=status):
host = HostEvidence("10.10.10.25", checks={"longlogon": status})
self.assertEqual(coverage(host), (1, 1, False))
line = minimal(self.report(host))
self.assertIn(f"PRECONDITION: [LONGLOGON:{label}]", line)
self.assertIn("CHECKS: 1/1", line)
self.assertNotIn("NOT VULNERABLE", line)
self.assertNotIn(" VULN:", line)
def test_informational_notes_do_not_turn_a_negative_into_partial_collection(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"},
notes=["Anonymous inventory cannot read the OS build."],
collection={"rpcdump": "success", "enum4linux": "skipped-disabled"})
self.assertEqual(coverage(host), (1, 1, False))
self.assertIn("NOT VULNERABLE (SELECTED CHECKS)", minimal(self.report(host)))
def test_unreachable_hosts_are_hidden_but_remain_available_as_diagnostics(self):
live = HostEvidence("10.10.10.25", services={"135": "open", "445": "closed"})
dead = HostEvidence("10.10.10.26", services={"135": "timeout", "445": "closed"})
legacy = HostEvidence("10.10.10.27")
report = self.report(live)
report.hosts.extend([dead, legacy])
line = minimal(report)
self.assertIn(live.address, line)
self.assertIn(legacy.address, line)
self.assertNotIn(dead.address, line)
self.assertIn(f"{dead.address} NO REACHABLE SMB/RPC SERVICES", minimal(report, include_unreachable=True))
text = markdown(report)
self.assertNotIn(dead.address, text)
self.assertIn("Unreachable targets omitted: **1**", text)
self.assertIn(dead.address, markdown(report, include_unreachable=True))
self.assertEqual(len(report.as_dict()["hosts"]), 3)
def test_exposure_only_checks_complete_inventory(self):
host = HostEvidence("10.10.10.25", checks={"printerbug": "exposed", "oxid": "observed"})
line = minimal(self.report(host))
self.assertIn("INVENTORY ONLY EXPOSURE:", line)
self.assertIn("CHECKS: 2/2", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("NOT VULNERABLE", line)
def test_markdown_metadata_cannot_escape_its_code_span_or_inject_html(self):
report = self.report(HostEvidence("10.10.10.25"))
report.targets = ["target`\n<script>alert(1)</script>"]
report.mode = "test`\n# false heading"
text = markdown(report)
self.assertIn("Targets: `target&#96; &lt;script&gt;alert(1)&lt;/script&gt;`", text)
self.assertIn("Mode: `test&#96; # false heading`", text)
self.assertNotIn("<script>", text)
self.assertNotIn("\n# false heading", text)
def test_hosts_sort_numerically_and_names_cannot_inject_lines(self):
report = self.report(HostEvidence("10.0.0.10", domain="LAB\nFAKE\x1b[2K"))
report.hosts.append(HostEvidence("10.0.0.2"))
lines = minimal(report).splitlines()
self.assertEqual(len(lines), 2)
self.assertTrue(lines[0].startswith("10.0.0.2 "))
self.assertNotIn("\x1b", lines[1])
if __name__ == "__main__":
unittest.main()
+104
View File
@@ -0,0 +1,104 @@
import unittest
from rpcscope.models import HostEvidence, RpcInterface, Share
from rpcscope.rules import evaluate_host
class RuleTests(unittest.TestCase):
def test_rules_separate_exposure_from_confirmed_vulnerability(self):
host = HostEvidence(
address="10.10.10.25",
smb1=True,
signing=False,
null_session=True,
shares=[Share("Public", ["READ", "WRITE"])],
rpc_interfaces=[RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0")],
)
findings = evaluate_host(host)
ids = {finding.rule_id for finding in findings}
self.assertTrue({"SMB-001", "SMB-002", "SMB-003", "SMB-005"} <= ids)
rpc = next(finding for finding in findings if finding.rule_id.startswith("RPC-"))
self.assertIn("does not prove", rpc.impact)
self.assertNotIn("MSF-MS17-010", ids)
def test_msf_result_is_explicit(self):
host = HostEvidence(address="10.10.10.25", checks={"ms17-010": "vulnerable"})
self.assertTrue(any(f.rule_id == "CHECK-MS17-010" for f in evaluate_host(host)))
def test_catalog_check_becomes_detailed_finding(self):
host = HostEvidence(address="10.10.10.25", checks={"zerologon": "vulnerable"})
finding = next(f for f in evaluate_host(host) if f.rule_id == "CHECK-ZEROLOGON")
self.assertEqual(finding.severity, "critical")
self.assertIn("CVE-2020-1472", finding.title)
def test_rpc_registration_does_not_claim_remote_reachability(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncalrpc:[local-spooler]")
])
finding = evaluate_host(host)[0]
self.assertEqual(finding.severity, "info")
self.assertIn("local bindings only", finding.title)
self.assertIn("reachability untested", finding.evidence)
def test_rpc_report_preserves_remote_binding_after_local_binding(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
bindings=["ncalrpc:[local-spooler]", "ncacn_ip_tcp:10.10.10.25[49665]"])
])
finding = evaluate_host(host)[0]
self.assertEqual(finding.severity, "medium")
self.assertIn("ncacn_ip_tcp", finding.evidence)
self.assertNotIn("local bindings only", finding.title)
def test_legacy_fingerprint_uses_version_boundaries(self):
for os in ("Windows 11", "Windows Server 2019", "Windows 80", "Samba 4.20.1"):
with self.subTest(os=os):
self.assertFalse(any(f.rule_id == "SMB-007" for f in evaluate_host(
HostEvidence("10.10.10.25", os=os)
)))
for os in ("Windows 7 Build 7601", "Windows 8.1", "Windows Server 2008 R2", "Samba 3.6.1"):
with self.subTest(os=os):
self.assertTrue(any(f.rule_id == "SMB-007" for f in evaluate_host(
HostEvidence("10.10.10.25", os=os)
)))
def test_acl_write_capability_does_not_claim_direct_write(self):
host = HostEvidence("10.10.10.25", shares=[Share("Data", ["READ", "WRITE (ACL)"])])
finding = evaluate_host(host)[0]
self.assertEqual(finding.rule_id, "SMB-005")
self.assertIn("permission-control", finding.title)
self.assertIn("WRITE (ACL)", finding.evidence)
def test_suspected_checker_indicator_is_preserved_with_low_confidence(self):
host = HostEvidence("10.10.10.25", checks={"smbghost": "suspected"},
check_details={"smbghost": "SMB 3.1.1 compression capability observed"})
finding = evaluate_host(host)[0]
self.assertEqual(finding.confidence, "low")
self.assertIn("requires validation", finding.title)
self.assertEqual(finding.evidence, host.check_details["smbghost"])
self.assertIn("does not distinguish patched", finding.impact)
def test_checker_positive_includes_actual_evidence(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
check_details={"ms17-010": "positive checker marker from raw artifact"})
finding = evaluate_host(host)[0]
self.assertEqual(finding.confidence, "high")
self.assertEqual(finding.evidence, host.check_details["ms17-010"])
def test_rpc_evidence_preserves_version_to_binding_mapping(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncalrpc:[local-spooler]"),
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "2.0",
endpoint="ncacn_ip_tcp:10.10.10.25[49665]"),
])
finding = evaluate_host(host)[0]
self.assertIn("v1.0 advertised via ncalrpc:[local-spooler]", finding.evidence)
self.assertIn("v2.0 advertised via ncacn_ip_tcp:10.10.10.25[49665]", finding.evidence)
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.catalog import CHECKS
from rpcscope.runner import ScanConfig, _msf_command, _redacted, scan
class RunnerTests(unittest.TestCase):
def test_nonfinite_and_invalid_api_limits_are_rejected_before_collection(self):
for attribute, value in (("timeout", float("nan")), ("timeout", float("inf")),
("jobs", True), ("discovery_workers", 1.5),
("connect_timeout", float("nan")), ("connect_timeout", True)):
with self.subTest(attribute=attribute, value=value), patch("rpcscope.runner.doctor") as inspect:
config = ScanConfig(["192.0.2.1"])
setattr(config, attribute, value)
with self.assertRaises(ValueError):
scan(config)
inspect.assert_not_called()
def test_redacts_embedded_password(self):
command = ["rpcdump", "user:[email protected]"]
self.assertEqual(
_redacted(command, "supersecret"),
["rpcdump", "user:<redacted>@10.10.10.25"],
)
def test_dry_run_does_not_require_installed_collectors(self):
with tempfile.TemporaryDirectory() as directory:
config = ScanConfig(
targets=["10.10.10.0/24"],
username="",
password="",
shares=True,
dry_run=True,
out_dir=Path(directory),
)
self.assertEqual(scan(config), {})
self.assertEqual(config.commands[0][0:3], ["nxc", "smb", "10.10.10.0/24"])
def test_msf_escaped_credentials_are_never_recorded(self):
config = ScanConfig(targets=["192.0.2.1"], username="auditor", password='a\\b"c')
command = _msf_command("msfconsole", "192.0.2.1", config)
redacted = _redacted(command, config.password)
self.assertEqual(redacted[-1], "<redacted Metasploit resource script>")
self.assertNotIn(config.password, " ".join(redacted))
def test_discovery_interruption_retains_partial_hosts_and_skips_checks(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.0/24"], out_dir=Path(temporary) / "out",
checks=[CHECKS["ms17-010"]])
tools = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None}
def interrupted(command, timeout, log, password, cancel):
cancel.set()
return 130, "SMB 192.0.2.1 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
with patch("rpcscope.runner.doctor", return_value=tools), patch("rpcscope.runner._run", side_effect=interrupted) as run:
hosts = scan(config)
self.assertTrue(config.cancelled)
self.assertIn("192.0.2.1", hosts)
self.assertEqual(hosts["192.0.2.1"].checks["ms17-010"], "skipped-cancelled")
self.assertEqual(run.call_count, 1)
if __name__ == "__main__":
unittest.main()
+230
View File
@@ -0,0 +1,230 @@
import errno
import socket
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from rpcscope.targets import _connect, discover_services, expand_targets
class TargetExpansionTests(unittest.TestCase):
def test_local_socket_exhaustion_is_not_a_dead_target_or_user_cancellation(self):
cancel = threading.Event()
def exhausted(address, port, timeout):
raise OSError(errno.EMFILE, "too many open files")
with self.assertRaisesRegex(OSError, "local socket resources exhausted"):
discover_services(["192.0.2.1"], jobs=1, cancel=cancel, probe=exhausted)
self.assertFalse(cancel.is_set())
def test_canonical_unique_sorted_addresses_and_host_semantics(self):
targets = ["2001:db8::/126", "192.0.2.0/30", "192.0.2.2",
"192.0.2.4/31", "192.0.2.7/32", "2001:db8::4/127", "::1/128"]
self.assertEqual(expand_targets(targets), ["192.0.2.1", "192.0.2.2", "192.0.2.4",
"192.0.2.5", "192.0.2.7", "::1",
"2001:db8::1", "2001:db8::2", "2001:db8::3",
"2001:db8::4", "2001:db8::5"])
def test_non_strict_network_and_overlapping_limit(self):
self.assertEqual(expand_targets(["192.0.2.2/30", "192.0.2.0/30"], max_hosts=2),
["192.0.2.1", "192.0.2.2"])
with self.assertRaisesRegex(ValueError, "limit"):
expand_targets(["192.0.2.1", "192.0.2.2"], max_hosts=1)
def test_huge_network_is_rejected_before_iteration(self):
for cidr in ("0.0.0.0/0", "::/0"):
with self.subTest(cidr=cidr), self.assertRaisesRegex(ValueError, "contains.*limit"):
expand_targets([cidr])
def test_file_comments_bom_hostname_and_ipv6_resolution(self):
resolver = Mock(return_value=[
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:0db8::9", 0, 0, 0)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.9", 0)),
])
with tempfile.TemporaryDirectory() as temporary:
target_file = Path(temporary) / "targets.txt"
target_file.write_text("\ufeff# lab\n192.0.2.1 # first\n\nserver.example\n", encoding="utf-8")
self.assertEqual(expand_targets([str(target_file), "192.0.2.9"], resolver=resolver),
["192.0.2.1", "192.0.2.9", "2001:db8::9"])
resolver.assert_called_once_with("server.example", None, socket.AF_UNSPEC, socket.SOCK_STREAM)
def test_nested_files_rejected_with_line_number(self):
with tempfile.TemporaryDirectory() as temporary:
parent = Path(temporary) / "parent.txt"
child = Path(temporary) / "child.txt"
child.write_text("192.0.2.1\n", encoding="utf-8")
parent.write_text("# comment\nchild.txt\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, r"parent\.txt:2: nested target files"):
expand_targets([str(parent)])
def test_invalid_inputs_do_not_call_dns(self):
resolver = Mock()
for target in ("-M", "", "https://example.org", "999.2.3.4", "192.0.2.1/99", "bad name", "::x"):
with self.subTest(target=target), self.assertRaises(ValueError):
expand_targets([target], resolver=resolver)
resolver.assert_not_called()
def test_dns_errors_and_empty_file_are_useful(self):
with self.assertRaisesRegex(ValueError, "could not resolve.*example"):
expand_targets(["example"], resolver=Mock(side_effect=socket.gaierror("missing")))
with self.assertRaisesRegex(ValueError, "did not resolve"):
expand_targets(["example"], resolver=Mock(return_value=[]))
with tempfile.TemporaryDirectory() as temporary:
target_file = Path(temporary) / "empty.txt"
target_file.write_text("# none\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "did not contain"):
expand_targets([str(target_file)])
class ServiceDiscoveryTests(unittest.TestCase):
def test_probe_states_are_independent_and_callbacks_use_caller_thread(self):
callback = Mock()
caller_thread = threading.get_ident()
def completed(address, ports):
self.assertEqual(threading.get_ident(), caller_thread)
callback(address, ports)
def probe(address, port, timeout):
self.assertEqual(timeout, 0.25)
if address == "192.0.2.1" and port == 135:
return "open"
if address == "192.0.2.1":
raise ConnectionRefusedError()
if port == 135:
raise TimeoutError()
raise OSError(errno.ENETUNREACH, "unreachable")
result = discover_services(["192.0.2.2", "192.0.2.1", "192.0.2.1"], jobs=2,
timeout=0.25, probe=probe, on_result=completed)
self.assertEqual(result, {"192.0.2.1": {135: "open", 445: "closed"},
"192.0.2.2": {135: "timeout", 445: "error"}})
self.assertEqual(callback.call_count, 2)
def test_host_concurrency_is_bounded(self):
barrier = threading.Barrier(2)
lock = threading.Lock()
active = 0
peak = 0
order = {}
def probe(address, port, timeout):
nonlocal active, peak
with lock:
order.setdefault(address, []).append(port)
if port == 135:
active += 1
peak = max(peak, active)
if port == 135:
barrier.wait(timeout=2)
else:
with lock:
active -= 1
return "open"
results = discover_services([f"192.0.2.{index}" for index in range(1, 7)], jobs=2, probe=probe)
self.assertEqual(len(results), 6)
self.assertEqual(peak, 2)
self.assertTrue(all(ports == [135, 445] for ports in order.values()))
def test_cancel_stops_new_hosts_and_second_port(self):
cancel = threading.Event()
probe = Mock(side_effect=lambda *_: (cancel.set() or "open"))
result = discover_services(["192.0.2.1", "192.0.2.2"], jobs=1, cancel=cancel, probe=probe)
self.assertEqual(result, {"192.0.2.1": {135: "open"}})
probe.assert_called_once()
self.assertEqual(discover_services(["192.0.2.1"], cancel=cancel, probe=probe), {})
def test_interrupt_sets_cancel_event_and_propagates(self):
cancel = threading.Event()
with self.assertRaises(KeyboardInterrupt):
discover_services(["192.0.2.1"], cancel=cancel, probe=Mock(side_effect=KeyboardInterrupt))
self.assertTrue(cancel.is_set())
def test_expired_deadline_makes_no_probe_and_does_not_cancel_caller(self):
cancel = threading.Event()
probe = Mock()
with patch("rpcscope.targets.time.monotonic", return_value=10.0):
self.assertEqual(discover_services(["192.0.2.1"], cancel=cancel, probe=probe, deadline=9.0), {})
probe.assert_not_called()
self.assertFalse(cancel.is_set())
def test_deadline_stops_next_port_and_host_but_retains_completed_evidence(self):
cancel = threading.Event()
now = [10.0]
callback = Mock()
def probe(*_):
now[0] = 20.0
return "open"
with patch("rpcscope.targets.time.monotonic", side_effect=lambda: now[0]):
result = discover_services(["192.0.2.1", "192.0.2.2"], jobs=1, cancel=cancel,
probe=probe, deadline=20.0, on_result=callback)
self.assertEqual(result, {"192.0.2.1": {135: "open"}})
callback.assert_called_once_with("192.0.2.1", {135: "open"})
self.assertFalse(cancel.is_set())
def test_each_port_timeout_is_capped_to_remaining_deadline_budget(self):
now = [10.0]
calls = []
def probe(address, port, timeout):
calls.append((address, port, timeout))
now[0] += 0.25
return "closed"
with patch("rpcscope.targets.time.monotonic", side_effect=lambda: now[0]):
result = discover_services(["192.0.2.1", "192.0.2.2"], jobs=1, timeout=2.0,
deadline=10.5, probe=probe)
self.assertEqual(result, {"192.0.2.1": {135: "closed", 445: "closed"}})
self.assertEqual(calls, [("192.0.2.1", 135, 0.5), ("192.0.2.1", 445, 0.25)])
def test_worker_slot_is_reused_without_waiting_for_slower_host(self):
slow_started = threading.Event()
next_host_started = threading.Event()
callback = Mock()
def probe(address, port, timeout):
if address == "192.0.2.1" and port == 135:
slow_started.set()
if not next_host_started.wait(timeout=2):
raise AssertionError("completed worker did not take the next host")
if address == "192.0.2.2" and port == 135:
if not slow_started.wait(timeout=2):
raise AssertionError("first worker did not start")
if address == "192.0.2.3":
next_host_started.set()
return "closed"
result = discover_services(["192.0.2.1", "192.0.2.2", "192.0.2.3"], jobs=2,
probe=probe, on_result=callback)
self.assertTrue(next_host_started.is_set())
self.assertEqual(result, {address: {135: "closed", 445: "closed"}
for address in ("192.0.2.1", "192.0.2.2", "192.0.2.3")})
self.assertEqual(callback.call_count, 3)
self.assertEqual({call.args[0] for call in callback.call_args_list}, set(result))
def test_default_probe_connects_and_closes_without_sending(self):
with patch("rpcscope.targets.socket.create_connection") as connection:
self.assertEqual(_connect("192.0.2.1", 135, 0.2), "open")
connection.assert_called_once_with(("192.0.2.1", 135), timeout=0.2)
connection.return_value.__exit__.assert_called_once()
connection.return_value.__enter__.return_value.send.assert_not_called()
connection.return_value.__enter__.return_value.sendall.assert_not_called()
def test_invalid_configuration_and_unexpected_probe_result(self):
for kwargs in ({"jobs": 0}, {"jobs": True}, {"timeout": 0}, {"timeout": float("inf")},
{"timeout": float("nan")}, {"deadline": float("nan")},
{"deadline": float("inf")}, {"deadline": True}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
discover_services(["192.0.2.1"], **kwargs)
with self.assertRaises(ValueError):
discover_services(["server.example"])
self.assertEqual(discover_services(["192.0.2.1"], probe=Mock(return_value="unexpected")),
{"192.0.2.1": {135: "error", 445: "error"}})
if __name__ == "__main__":
unittest.main()