Improve evidence classification and compact CLI reporting

This commit is contained in:
0x000 committed 2026-10-15 14:30:45 -04:00
1 parent 9dc7289652
commit dda4f8563f
28 files changed
+945 -87

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# Changelog
## 0.2.1
- Compact colored terminal report and phase-labelled progress bar by default;
`--verbose`, `--format full` and `--progress plain` retain detailed diagnostics.
- Native pre-login SMB2 compression-capability negotiation replaces the
login-dependent SMBGhost heuristic. No compressed transforms or exploits.
- Respect ignored/reserved SMB fields and compression-algorithm intersection;
retain strict bounds, response identity, context and absolute-timeout checks.
- Recognize Zerologon's explicit probabilistic negative; distinguish rejected
SMB authentication and denied PrintNightmare RPC binding from CVE verdicts.
- Preserve contradictory attempts, but suppress unqualified confirmed/clean
claims and use low-confidence conflict findings. Label optional signing
accurately; distinguish pending work, interruption and missing collectors.
- Detect RPC-dump protocol errors even on exit zero. Empty output cannot count
as successful enumeration. Preserve partial inventory and failure diagnostics.
- Stop queued RPC-dump copies after recognized local startup failures, without
suppressing independent remote failures or other selected checks.
- Save per-invocation timings and bounded error excerpts; retain complete,
uncolored evidence reports. Modernize wheel license metadata.
Validation uses synthetic protocol frames, mocked external tools and local
subprocess tests. No live target benchmark or patched/unpatched lab validation
has been performed. A summary showing RPC-dump exit code 1 still requires its
raw log to identify the actual root cause.
Primary behavior references:
- [MS-SMB2 negotiate response and reserved fields](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/63abf97c-0d09-47e2-88d6-6bfa552949a5)
- [MS-SMB2 negotiate-response processing](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/3b29f3af-86f9-4962-8cf3-43471cb59363)
- [MS-SMB2 compression capability format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/78e0c942-ab41-472b-b117-4a95ebe88271)
- [Impacket RPC-dump error handling](https://github.com/fortra/impacket/blob/master/examples/rpcdump.py)
+16 -12
View File
@@ -86,7 +86,9 @@ 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.
The `smbghost` selector now performs one native, unauthenticated SMB2 negotiation instead of invoking NetExec's login-dependent heuristic. It parses the actual compression context, including frame bounds, response identity, dialect and algorithm array. The result is `EXPOSED[SMB3-COMPRESSION]` or `NOT-EXPOSED[SMB3-COMPRESSION]`, never a CVE or patch verdict. Patched servers may support compression. No compressed transform, exploit, login or remote write is sent. The wire format follows Microsoft's [negotiate request](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e14db7ff-763a-4263-8b10-0c3944f52fc5) and [response](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/63abf97c-0d09-47e2-88d6-6bfa552949a5) specifications. Its absolute deadline is at most five seconds, including fragmented reads; malformed replies/timeouts are unknown.
PrintNightmare's driver-permission probe and noPac's ticket-size heuristic remain `SUSPECTED` in saved reports (`REVIEW` in the compact CLI). They do not prove code execution. An explicit RPC bind denial is `skipped-access-denied`, not vulnerable or patched. NetExec's explicit exhausted-attempts Zerologon result is now recognized as a scoped, probabilistic negative; generic connection errors are not evidence that a host is not a DC. Several NetExec adapters use `on_login`, so credentialless means no supplied credentials, not guaranteed anonymous access. An attributed failed SMB login is shown as an authentication skip. There are no automatic credential guesses, payloads or unsafe retries. The removed NTLM reflection module remains disabled. See the upstream [PrintNightmare](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/printnightmare.py), [Zerologon](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/zerologon.py), and [noPac](https://github.com/Pennyw0rth/NetExec/blob/main/nxc/modules/nopac.py) implementations.
LongLogon is included in `--checks recommended` (12 checks total). It remains a precondition-only check and can also run separately:
@@ -119,24 +121,26 @@ Outputs include a one-line-per-reachable-host `report.txt`, detailed `report.jso
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.10 (LAB) CHECKED EXPOSURE: [SMB3-COMPRESSION] [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.
The default CLI uses one updating progress bar on stderr and a compact final report on stdout. It does not print every start/finish event. Color is automatic on terminals; `--color never` or `NO_COLOR` disables it, and `--color always` forces it. Redirected compact progress emits only phase/final lines, never animation escapes. Discovery and audit have separately labelled phase percentages: finishing hundreds of dead targets does not imply the checks are almost finished. The audit denominator is established after live-host discovery; the bar is not an ETA. Dead addresses advance counts silently and never enter a host-check pipeline.
```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)
[########--------] 50% audit [25/50] batch:ms17-010 | 12s
192.0.2.10 (LAB-DC) VULN[MS17-010] EXPOSED[SMB3-COMPRESSION]
192.0.2.20 (WS01) NOT-VULN[MS17-010] SKIP[access-denied:1] ERROR[rpcdump]
```
`VULN` means a dedicated checker signature, not proof of remote code execution. `NOT-VULN` is negative for the named probe and identity only. `REVIEW` needs validation. `UNKNOWN` includes a short reason/count instead of repeating every CVE; `SKIP` shows unmet prerequisites; `ERROR` names the failing collection stage. A failed RPC dump no longer hides a separately successful check behind an unexplained per-host `PARTIAL` tag. Full coverage and all check evidence remain in `report.json`, `report.md`, and the uncolored `report.txt`. `--verbose` includes per-check evidence/collection notes; `--format full` restores the longer terminal host lines. `--progress auto` or `--progress plain` restores per-task progress.
Each external invocation and native check records duration in JSON; Markdown groups summed task time by stage (parallel task time is not wall-clock duration). Errors now retain a bounded diagnostic excerpt. A recognized local RPC-dump dependency/argument failure disables further queued copies of that collector; already running tasks finish and the stage remains explicitly skipped/failed. It does not disable checks or hide remote failures. This is not a general circuit breaker for access denial.
RPC dump must return parsed endpoints or an explicit empty inventory without a protocol error; a zero exit code alone is insufficient. A missing requested collector makes coverage partial. Conflicting check attempts remain in JSON, but show `CONFLICT` instead of a positive/clean verdict in reports and are excluded from the compact confirmed count. Optional signing is labelled `SIGNING-OPTIONAL`, not disabled signing.
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.
@@ -145,9 +149,9 @@ Use `--progress plain` for log files or `--no-progress` for quiet operation. Lig
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.
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 upstream MS17-010 adapter. The native SMB negotiation supports IPv4 and IPv6. 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).
The offline 32-host `recommended` test uses 24 NetExec **check** processes (six module groups, four batches each), plus native SMB negotiation and LongLogon checks, retaining twelve per-host check records. The prior all-NetExec orchestrator needed 352 check processes; the first batching version needed 28. 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.
+2
View File
@@ -46,6 +46,8 @@ Rules consume only this model. They do not parse terminal output and do not invo
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.
`smbghost` now uses `smb_negotiate.py`: one ordinary, unauthenticated SMB2 negotiate with bounded framing/context parsing and an absolute deadline. Compression is a capability exposure, not a CVE finding. `console.py` renders compact, optional-color host results separately from the complete saved evidence. The CLI's compact progress bar suppresses individual task lines; plain/auto modes retain diagnostic events. Per-invocation timings are retained, and recognized local RPC-dump startup failures stop further queued copies without suppressing remote errors or other checks.
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.
+5 -1
View File
@@ -4,6 +4,8 @@
Every implemented check must return an explicit state:
Compression capability checks additionally use `not-exposed` for a valid negotiation that did not select compression. This is not a `not-vulnerable` patch verdict.
- `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;
@@ -20,7 +22,9 @@ An exposed RPC UUID, OS banner, open port, or legacy protocol is an `EXPOSURE`,
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.
PrintNightmare's driver-permission probe and noPac's PAC-size heuristic produce `suspected` results. SMB compression is now measured through a bounded native, pre-login SMB2 negotiation: `exposed` / `not-exposed` describe only capability negotiation, never SMBGhost patch status. Both complete execution; neither is a vulnerability verdict. NetExec's legacy compression positive is also normalized to exposure, not critical CVE suspicion. The removed NetExec NTLM reflection module is disabled pending an adapter for its replacement.
An explicit failed SMB login without module output is `skipped-auth-denied`; RPC bind access denial is `skipped-access-denied`. Neither proves patched. Zerologon's exact exhausted-attempts message yields a probabilistic `not-vulnerable` for that checker run. Its generic DCERPC exception does not prove the host is not a DC. Failed processes still yield unknown, even if partial output contains a result marker. Duration and bounded failure diagnostics are retained for troubleshooting.
## Catalog synchronization
+5 -3
View File
@@ -17,7 +17,7 @@ RPCScope continues to invoke the operator's installed executable.
| 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. |
| Reviewed MS17-010 and SMBGhost modules create IPv4 sockets. | MS17-010 stays IPv4-gated. SMBGhost has been replaced by a native IPv4/IPv6 negotiation capability probe; its old positive is no longer a CVE suspicion. |
| 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
@@ -43,8 +43,10 @@ compatibility. CLI defaults select batching and leave full enum to `--deep`.
## 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
The mocked 32-live-host recommended scan now executes 24 NetExec check invocations
instead of the old 352 (six module groups, four eight-host batches), plus native
compression capability and LongLogon precondition checks. The earlier batching
version used 28 processes. 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.
+4 -6
View File
@@ -1,23 +1,21 @@
[build-system]
requires = ["setuptools>=68"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "rpcscope"
version = "0.1.0"
version = "0.2.1"
description = "Evidence-driven SMB and Windows RPC exposure auditing"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
license = "MIT"
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
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*"]
+1 -1
View File
@@ -1,4 +1,4 @@
"""RPCScope: normalize and correlate SMB/RPC enumeration evidence."""
__version__ = "0.1.0"
__version__ = "0.2.1"
+6 -5
View File
@@ -65,16 +65,17 @@ CHECKS: dict[str, CheckDefinition] = {
),
"smbghost": CheckDefinition(
key="smbghost",
label="CVE-2020-0796/SMBGHOST",
label="SMB3-COMPRESSION",
cves=("CVE-2020-0796",),
surface="SMBv3 compression",
nxc_module="smbghost",
ip_versions=(4,),
backend="native",
requires_credentials=False,
severity="critical",
severity="info",
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.",
positive_status="exposed",
kind="capability",
note="Unauthenticated SMB2 negotiate only: reports compression capability, never SMBGhost patch state. No compressed transform or exploit is sent.",
),
"nopac": CheckDefinition(
key="nopac",
+23 -5
View File
@@ -32,8 +32,12 @@ def index_messages(text: str) -> MessageIndex:
address = str(ipaddress.ip_address(parts[1].strip("[]")))
except ValueError:
continue
message = re.sub(r"^\[(?:\+|-|\*)\]\s*", "", parts[4]).strip()
payload = parts[4]
message = re.sub(r"^\[(?:\+|-|\*)\]\s*", "", payload).strip()
result.setdefault(address, {}).setdefault((token, int(parts[2])), []).append(message)
if token == "smb" and payload.startswith("[-]") and re.search(
r"\bSTATUS_(?:LOGON_FAILURE|ACCOUNT_DISABLED|ACCOUNT_LOCKED_OUT|PASSWORD_EXPIRED)\b", message):
result[address].setdefault(("smb-auth-failure", int(parts[2])), []).append("SMB authentication rejected")
return result
@@ -59,6 +63,15 @@ def classify_nxc(key: str, host: str, text: str, returncode: int = 0, *,
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 not messages and _messages(text, "smb-auth-failure", host, (445,), index):
return CheckResult("skipped-auth-denied", "SMB authentication rejected; NetExec on_login module did not return a result")
bind_denied = key == "printnightmare" and any(
message.startswith("Failed to bind:") and re.search(r"STATUS_ACCESS_DENIED|access denied|rpc_s_access_denied", message, re.I)
for message in messages)
if key == "smbghost":
if "Potentially vulnerable to SMBGhost (CVE-2020-0796)" in messages:
return CheckResult("exposed", "SMB 3.1.1 compression indicator observed; patch state and CVE-2020-0796 exploitability not established")
return CheckResult("inconclusive", messages[-1] if messages else "no SMB compression result; silence is not a negative")
if definition.kind == "exposure":
method = definition.nxc_options[0].partition("=")[2]
expected = f"VULNERABLE, {method}".casefold()
@@ -93,17 +106,22 @@ def classify_nxc(key: str, host: str, text: str, returncode: int = 0, *,
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 == "zerologon":
if message.upper() == "VULNERABLE":
positives.append(message)
elif message == "Attack failed. Target is probably patched.":
negatives.append("NetExec exhausted its 2000 authentication attempts without success (probabilistic negative; not patch inventory)")
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 bind_denied:
if positives or negatives:
return CheckResult("inconclusive", "conflicting RPC bind denial and driver-permission result; inspect the log")
return CheckResult("skipped-access-denied", "RPC bind access denied for this identity; vulnerability probe could not run")
if positives and negatives:
return CheckResult("inconclusive", "conflicting positive and negative checker messages")
if positives:
+13 -8
View File
@@ -16,6 +16,7 @@ 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 .console import compact, use_color
from .storage import prepare_output
from .advisories import ADVISORIES, sync_advisories
@@ -79,8 +80,11 @@ def _parser() -> argparse.ArgumentParser:
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("--progress", choices=("compact", "auto", "plain", "quiet"), default="compact",
help="compact progress bar; plain/auto retain per-task diagnostics")
scan_p.add_argument("--color", choices=("auto", "always", "never"), default="auto")
scan_p.add_argument("--verbose", action="store_true", help="include per-check evidence and collection errors")
scan_p.add_argument("--format", choices=("compact", "full"), default="compact", help="terminal report style (default: compact)")
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",
@@ -103,13 +107,13 @@ def _parser() -> argparse.ArgumentParser:
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:
hosts, errors: list[str], commands: list[list[str]], timings: list[dict] | None = None) -> 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),
commands=list(commands), timings=list(timings or []),
)
write_json(report, output / "report.json")
write_markdown(report, output / "report.md")
@@ -201,15 +205,15 @@ def main(argv: list[str] | None = None) -> int:
# 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)
args.targets, args.lhost, principal, hosts, config.errors, config.commands, config.timings)
last_checkpoint = now
try:
with ProgressReporter(mode=args.progress) as progress:
with ProgressReporter(mode=args.progress, color=args.color) 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)
args.targets, args.lhost, principal, hosts, config.errors, config.commands, config.timings)
except KeyboardInterrupt:
print("Interrupted; existing report checkpoints were retained.", file=sys.stderr)
return 130
@@ -220,7 +224,8 @@ def main(argv: list[str] | None = None) -> int:
if args.dry_run:
print(json.dumps(config.commands, indent=2))
return 0
print(minimal(report), end="")
print(minimal(report) if args.format == "full" else compact(
report, color=use_color(sys.stdout, args.color), verbose=args.verbose), end="")
print(f"Reports: {args.output.resolve()}", file=sys.stderr)
if config.cancelled:
return 130
+147
View File
@@ -0,0 +1,147 @@
"""Compact terminal presentation; saved evidence remains complete and uncolored."""
from __future__ import annotations
import os
from collections import Counter
from typing import TextIO
from .models import Report
from .reporting import COMPLETED_CHECK_STATUSES, address_key, display, reachable
def use_color(stream: TextIO, mode: str = "auto") -> bool:
if mode == "never":
return False
if mode == "always":
return True
try:
return bool("NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
and stream.isatty())
except (AttributeError, OSError, ValueError):
return False
def paint(text: str, code: str, enabled: bool) -> str:
return f"\x1b[{code}m{text}\x1b[0m" if enabled else text
def short_label(key: str) -> str:
return {"smbghost": "SMB3-COMPRESSION", "ms17-010": "MS17-010"}.get(key, key.upper())
def reason(status: str, detail: str = "") -> str:
value = detail.casefold()
if "auth" in status or "logon_failure" in value or "authentication rejected" in value:
return "auth-denied"
if "access" in status or "access_denied" in value or "access denied" in value:
return "access-denied"
if "timeout" in status or "timed out" in value or "status 124" in value:
return "timeout"
if "probably not a dc" in value or "no route to host" in value:
return "rpc-unreachable"
if status.startswith("skipped-"):
return status.removeprefix("skipped-")
if "status" in value or status == "failed":
return "tool-error"
return "no-verdict"
def compact(report: Report, *, color: bool = False, verbose: bool = False) -> str:
"""One host/line. No port inventory, CVE duplication or repeated unknown lists."""
lines = []
findings = {}
names = {"SMB-001": "SMBv1", "SMB-002": "SIGNING-OPTIONAL", "SMB-003": "NULL-AUTH",
"SMB-004": "GUEST", "SMB-005": "SHARE-WRITE", "SMB-006": "SHARE-READ",
"SMB-007": "LEGACY-OS"}
for finding in report.findings:
if finding.rule_id in names:
findings.setdefault(finding.host, set()).add(names[finding.rule_id])
totals = Counter()
for host in sorted(report.hosts, key=lambda item: address_key(item.address)):
if not reachable(host):
continue
totals["alive"] += 1
identity = display(host.hostname or host.domain)[:40]
prefix = display(host.address) + (f" ({identity})" if identity else "")
groups = []
conflicts = host.conflicting_checks()
categories = (
("VULN", {"vulnerable"}, "31;1"),
("REVIEW", {"suspected"}, "33"),
("NOT-VULN", {"not-vulnerable"}, "32"),
("EXPOSED", {"exposed"}, "33"),
("NOT-EXPOSED", {"not-exposed"}, "32"),
)
for title, statuses, code in categories:
keys = sorted(key for key, status in host.checks.items() if status in statuses and key not in conflicts)
if keys:
totals[title] += len(keys)
groups.append(paint(f"{title}[{','.join(short_label(key) for key in keys)}]", code, color))
exposure = sorted(findings.get(host.address, ()))
if exposure:
groups.append(paint("SMB[" + ",".join(exposure) + "]", "33", color))
observed = sorted(short_label(key) for key, status in host.checks.items() if status == "observed")
if observed:
groups.append(paint("INFO[" + ",".join(observed) + "]", "36", color))
for key, status in host.checks.items():
if status.startswith("precondition-") and key not in conflicts:
groups.append(paint(f"{short_label(key)}[{status.removeprefix('precondition-')}]", "36", color))
unknown = {key: status for key, status in host.checks.items()
if status == "inconclusive" or (status not in COMPLETED_CHECK_STATUSES and
(not status.startswith("skipped-") or status == "skipped-cancelled"))}
skipped = {key: status for key, status in host.checks.items()
if status.startswith("skipped-") and status != "skipped-cancelled"}
if unknown:
reasons = Counter(reason(status, host.check_details.get(key, "")) for key, status in unknown.items())
hint = ",".join(f"{key}:{value}" for key, value in sorted(reasons.items()))
groups.append(paint(f"UNKNOWN:{len(unknown)}({hint})", "33", color))
totals["unknown"] += len(unknown)
if skipped:
blocked = Counter(reason(status, host.check_details.get(key, "")) for key, status in skipped.items())
groups.append("SKIP[" + ",".join(f"{key}:{value}" for key, value in sorted(blocked.items())) + "]")
totals["skipped"] += len(skipped)
if conflicts:
groups.append(paint("CONFLICT[" + ",".join(short_label(key) for key in sorted(conflicts)) + "]", "31;1", color))
totals["conflicts"] += len(conflicts)
elif any(note.startswith("Conflicting ") for note in host.notes):
groups.append(paint("CONFLICT", "31;1", color))
pending = [key for key, status in host.collection.items() if status == "pending"]
if pending:
groups.append("PENDING[" + ",".join(display(key) for key in sorted(pending)) + "]")
failed = [key for key, status in host.collection.items()
if status not in {"success", "pending"} and not status.startswith("skipped-")]
if failed:
# Show the failing stage, not a mysterious PARTIAL after a 4/4 count.
groups.append(paint("ERROR[" + ",".join(display(key) for key in sorted(failed)) + "]", "31", color))
local_skips = sorted(key for key, status in host.collection.items() if status == "skipped-local-error")
if local_skips:
groups.append(paint("COLLECTOR-SKIP[" + ",".join(display(key) for key in local_skips) + "]", "33", color))
if not groups:
groups.append("INVENTORY")
lines.append(prefix + " " + " ".join(groups))
if verbose:
for key, status in sorted(host.checks.items()):
detail = display(host.check_details.get(key, "no diagnostic supplied"))
lines.append(f" {short_label(key)}: {status} — {detail}")
for stage, status in sorted(host.collection.items()):
if stage in failed or status == "missing":
lines.append(f" collector {display(stage)}: {display(status)}")
lines.extend(" note: " + display(note) for note in host.notes)
if not totals["alive"]:
lines.append("No reachable SMB/RPC hosts.")
else:
lines.append(f"{totals['alive']} hosts | {totals['VULN']} confirmed | {totals['REVIEW']} review | "
f"{totals['unknown']} unknown | {totals['skipped']} skipped")
if totals["conflicts"]:
lines.append(f"{totals['conflicts']} conflicting check result(s); inspect attempts before assigning a verdict.")
if report.mode == "interrupted":
lines.append(paint("INTERRUPTED — completed evidence retained; unfinished checks are unresolved.", "33", color))
if totals["NOT-VULN"]:
lines.append("NOT-VULN = negative for the named probe/identity only; not a blanket patch guarantee.")
if totals["REVIEW"]:
lines.append("REVIEW = permission/heuristic indicator, not confirmed code execution.")
if verbose and report.errors:
lines.extend(" " + display(error) for error in report.errors)
elif report.errors:
lines.append(f"{len(report.errors)} collection notes; see report.md (or --verbose).")
return "\n".join(lines) + "\n"
+10 -1
View File
@@ -62,6 +62,10 @@ class HostEvidence:
services: dict[str, str] = field(default_factory=dict)
check_attempts: list[CheckAttempt] = field(default_factory=list)
def conflicting_checks(self) -> set[str]:
return {key for key in self.checks if any(
note.startswith(f"Conflicting {key} checker results") for note in self.notes)}
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"}:
@@ -119,7 +123,7 @@ class HostEvidence:
# 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,
"not-vulnerable": 3, "not-exposed": 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():
@@ -132,6 +136,10 @@ class HostEvidence:
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, status} in ({"suspected", "not-vulnerable"}, {"exposed", "not-exposed"}):
note = f"Conflicting {key} checker results ({previous} and {status}); 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)
@@ -168,6 +176,7 @@ class Report:
findings: list[Finding]
errors: list[str] = field(default_factory=list)
commands: list[list[str]] = field(default_factory=list)
timings: list[dict] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
+36 -6
View File
@@ -11,6 +11,8 @@ import unicodedata
from dataclasses import dataclass
from typing import Callable, TextIO
from .console import paint, use_color
@dataclass(frozen=True)
class ProgressEvent:
@@ -63,9 +65,10 @@ class ProgressReporter:
mode: str = "auto",
clock: Callable[[], float] = time.monotonic,
interval: float = 10.0,
color: str = "auto",
) -> None:
if mode not in {"auto", "plain", "quiet"}:
raise ValueError("progress mode must be auto, plain, or quiet")
if mode not in {"auto", "compact", "plain", "quiet"}:
raise ValueError("progress mode must be auto, compact, plain, or quiet")
if interval <= 0:
raise ValueError("progress interval must be positive")
self.stream = stream if stream is not None else sys.stderr
@@ -73,8 +76,8 @@ class ProgressReporter:
self.clock = clock
self.interval = interval
try:
self._tty = mode == "auto" and self.stream.isatty()
except (AttributeError, OSError):
self._tty = mode in {"auto", "compact"} and self.stream.isatty()
except (AttributeError, OSError, ValueError):
self._tty = False
self._lock = threading.RLock()
self._stop = threading.Event()
@@ -84,17 +87,20 @@ class ProgressReporter:
self._active: dict[tuple[str, str], float] = {}
self._completed = 0
self._total = 0
self._phase_base = 0
self._phase = "discovery"
self._last_event: ProgressEvent | None = None
self._dashboard_visible = False
self._finished = False
self._closed = False
self._color = use_color(self.stream, color) if mode != "plain" else 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":
if self.mode != "quiet" and (self.mode != "compact" or self._tty):
self._thread = threading.Thread(
target=self._heartbeat_loop, name="rpcscope-progress", daemon=True
)
@@ -122,6 +128,9 @@ class ProgressReporter:
now = self.clock()
self._completed = max(self._completed, event.completed)
self._total = max(self._total, event.total, self._completed)
if event.kind == "finish" and event.stage == "discovery" and self._total > self._completed:
self._phase_base = self._completed
self._phase = "audit"
key = (event.stage, event.host)
if event.kind == "advance":
# Discovery can finish thousands of unreachable targets. Count
@@ -141,6 +150,17 @@ class ProgressReporter:
self._active.clear()
self._finished = True
self._last_event = event
if self.mode == "compact":
if event.kind == "summary" and event.stage == "scan":
prefix = "\r\x1b[2K" if self._tty else ""
status = paint(_clean(event.status).upper(), "32" if event.status == "complete" else "33", self._color)
self._write(f"{prefix}{status} | {_clean(event.detail)} | {_elapsed(now - self._started_at)}\n")
self._dashboard_visible = False
elif self._tty and (now - self._last_output_at >= 0.1 or not self._dashboard_visible):
self._render_locked(now)
elif not self._tty and event.kind == "start" and not event.host and event.stage in {"tcp-discovery", "discovery"}:
self._write(f"{_clean(event.stage)} | {_clean(event.detail)}\n")
return
if self._tty:
if event.kind in {"finish", "summary"}:
self._write("\r\x1b[2K" + self._event_line(event, duration, now) + "\n")
@@ -171,6 +191,14 @@ class ProgressReporter:
return line
def _dashboard(self, now: float) -> str:
if self.mode == "compact":
total = self._total - self._phase_base
completed = max(0, self._completed - self._phase_base)
fraction = min(1.0, completed / total) if total else 0.0
filled = int(fraction * 16)
bar = "#" * filled + "-" * (16 - filled)
stage = next(iter(self._active))[0] if self._active else (self._last_event.stage if self._last_event else "scan")
return f"[{bar}] {fraction:3.0%} {self._phase} [{completed}/{total}] {_clean(stage, 35)} | {_elapsed(now - self._started_at)}"
parts = [self._counts(), "RUNNING" if self._active else "WAITING"]
if self._active:
stage, host = next(iter(self._active))
@@ -185,6 +213,8 @@ class ProgressReporter:
def _render(self, heartbeat: bool = True) -> None:
"""Render a heartbeat; factored out so timing is testable without sleeps."""
with self._lock:
if self.mode == "compact" and not self._tty:
return
if self.mode == "quiet" or self._closed or self._finished or not self._active:
return
now = self.clock()
@@ -196,7 +226,7 @@ class ProgressReporter:
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._write("\r\x1b[2K" + paint(line[:width], "36", self._color))
self._dashboard_visible = True
else:
self._write(line + "\n")
+26 -10
View File
@@ -31,7 +31,7 @@ def _label(key: str) -> str:
COMPLETED_CHECK_STATUSES = frozenset({
"vulnerable", "not-vulnerable", "suspected", "exposed", "observed", "inconclusive",
"precondition-met", "precondition-not-met", "precondition-borderline",
"precondition-met", "precondition-not-met", "precondition-borderline", "not-exposed",
})
@@ -55,7 +55,7 @@ def coverage(host) -> tuple[int, int, bool]:
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}
collection_failed = any(status not in {"success", *COMPLETED_CHECK_STATUSES}
and not _skipped(status) for status in host.collection.values())
return completed, selected, check_failed or collection_failed
@@ -123,6 +123,8 @@ def markdown(report: Report, include_unreachable: bool = False) -> str:
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 stage, status in sorted(host.collection.items()):
lines.append(f" - Collection: {_code(stage)}: {_code(status)}")
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:
@@ -132,6 +134,15 @@ def markdown(report: Report, include_unreachable: bool = False) -> str:
if report.errors:
lines.extend(["", "## Collection notes", ""])
lines.extend(f"- {_code(error)}" for error in report.errors)
if report.timings:
lines.extend(["", "## Collector timings", "", "Summed task time; parallel tasks overlap in wall-clock time.", ""])
seconds = Counter()
calls = Counter()
for item in report.timings:
seconds[item["stage"]] += item["seconds"]
calls[item["stage"]] += 1
for stage in sorted(seconds, key=seconds.get, reverse=True):
lines.append(f"- {_code(stage)}: {seconds[stage]:.1f}s across {calls[stage]} invocation(s)")
return "\n".join(lines) + "\n"
@@ -166,14 +177,15 @@ def minimal(report: Report, include_unreachable: bool = False) -> str:
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"]
conflicts = host.conflicting_checks()
positives = [_label(key) for key, status in sorted(host.checks.items()) if status == "vulnerable" and key not in conflicts]
suspected = [_label(key) for key, status in sorted(host.checks.items()) if status == "suspected" and key not in conflicts]
exposed = [_label(key) for key, status in sorted(host.checks.items()) if status == "exposed" and key not in conflicts]
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-")]
for key, status in sorted(host.checks.items()) if status.startswith("precondition-") and key not in conflicts]
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"}
@@ -187,7 +199,7 @@ def minimal(report: Report, include_unreachable: bool = False) -> str:
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):
and not partial and not skipped and not unknown and not preconditions and not conflicts):
verdict = "NOT VULNERABLE (SELECTED CHECKS)"
elif pending:
verdict = "IN PROGRESS"
@@ -207,6 +219,12 @@ def minimal(report: Report, include_unreachable: bool = False) -> str:
verdict += " SUSPECTED: " + " ".join(f"[{item}]" for item in suspected)
if unknown:
verdict += " UNKNOWN: " + " ".join(f"[{item}]" for item in unknown)
negatives = [_label(key) for key, status in sorted(host.checks.items()) if status == "not-vulnerable" and key not in conflicts]
if negatives and not verdict.startswith("NOT VULNERABLE"):
verdict += " NEGATIVE: " + " ".join(f"[{item}]" for item in negatives)
not_exposed = [_label(key) for key, status in sorted(host.checks.items()) if status == "not-exposed" and key not in conflicts]
if not_exposed:
verdict += " NOT-EXPOSED: " + " ".join(f"[{item}]" for item in not_exposed)
if preconditions:
verdict += " PRECONDITION: " + " ".join(f"[{label}:{status}]" for label, status in preconditions)
exposures = sorted(set(exposed) | {
@@ -221,10 +239,8 @@ def minimal(report: Report, include_unreachable: bool = False) -> str:
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)
verdict += " CONFLICT: " + " ".join(f"[{_label(key)}]" for key in sorted(conflicts))
if host.services:
verdict += " PORTS: " + ",".join(f"{port}/{status}" for port, status in sorted(host.services.items()))
if selected:
+12 -2
View File
@@ -186,14 +186,24 @@ def evaluate_host(host: HostEvidence) -> list[Finding]:
recommendation, [reference], confidence="medium",
))
conflicting_checks = host.conflicting_checks()
for check_key, status in host.checks.items():
status = status.casefold()
if status not in {"vulnerable", "suspected", "exposed", "observed", "precondition-met", "precondition-borderline"}:
if status not in {"vulnerable", "suspected", "exposed", "observed", "precondition-met", "precondition-borderline"} and check_key not in conflicting_checks:
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"
if check_key in conflicting_checks:
findings.append(_finding(
host, f"CHECK-{check_key.upper()}", f"{label} checker results conflict", "info",
"Conflicting checker attempts retained in host inventory.",
"The available attempts disagree; neither a confirmed vulnerability nor a clean verdict is established.",
"Review each backend's evidence, identity and execution outcome before assigning a verdict.",
reference, confidence="low",
))
continue
suspected = status == "suspected"
if status.startswith("precondition-"):
findings.append(_finding(
@@ -206,7 +216,7 @@ def evaluate_host(host: HostEvidence) -> list[Finding]:
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, f"CHECK-{check_key.upper()}", f"{label} {'capability observed' if definition and definition.kind == 'capability' else '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.",
+56 -6
View File
@@ -17,8 +17,9 @@ from pathlib import Path
from typing import Callable
from .catalog import CheckDefinition
from .checks import CheckResult, classify_msf, classify_nxc, index_messages
from .checks import ANSI, CheckResult, classify_msf, classify_nxc, index_messages
from .longlogon import probe_longlogon
from .smb_negotiate import probe_smb_compression
from .models import CheckAttempt, HostEvidence
from .netexec import (NxcCapabilities, host_failures, inspect_capabilities, invocation_failed,
plan_batches, skip_reason, validate_literal_credentials)
@@ -53,6 +54,7 @@ class ScanConfig:
out_dir: Path = Path("rpcscope-output")
commands: list[list[str]] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
timings: list[dict] = field(default_factory=list)
cancelled: bool = False
@@ -204,6 +206,14 @@ def _record_check(host: HostEvidence, key: str, outcome: CheckResult, source: Pa
check_attempts=[CheckAttempt(key, backend, outcome.status, detail, saved)]))
def _failure_hint(text: str) -> str:
"""Short tool diagnostic, without dumping its whole output into progress."""
lines = [line.strip() for line in text.splitlines() if line.strip()]
diagnostic = [line for line in lines if re.search(
r"error|exception|timed?\s*out|denied|unrecognized arguments|no module named|RPCScope timeout", line, re.I)]
return (diagnostic[-1] if diagnostic else "no diagnostic returned")[:240]
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):
@@ -245,7 +255,8 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
*(["-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"])
config.commands.append(["<native:smb-compression>", address, "TCP/445", "SMB2-NEGOTIATE-only"] if definition.key == "smbghost" else
["<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,
@@ -272,6 +283,7 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
raw.mkdir(mode=0o700)
cancel = threading.Event()
lock = threading.Lock()
disabled_collectors: dict[str, str] = {}
total = len(scope) + 1 if config.tcp_discovery else 1
completed = 0
@@ -310,9 +322,12 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
with lock:
if command != discovery:
config.commands.append(_redacted(command, config.password))
started = time.monotonic()
code, text = _run(command, timeout, path, config.password, cancel=cancel)
with lock:
config.timings.append({"stage": stage, "host": address, "seconds": round(time.monotonic() - started, 3), "exit_code": code})
if code:
error(f"{stage}{' for ' + address if address else ''} exited with status {code}; see {path.name}")
error(f"{stage}{' for ' + address if address else ''} exited with status {code}: {_failure_hint(text)}; see {path.name}")
return code, text
discovery_start = time.monotonic()
@@ -346,6 +361,7 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
and not cancel.is_set() and not discovery_failed):
error("TCP discovery deadline reached; unprobed targets remain incomplete")
rc = 124
config.timings.append({"stage": "tcp-discovery", "host": "", "seconds": round(time.monotonic() - discovery_start, 3), "exit_code": rc})
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)")
@@ -366,7 +382,9 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
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))
started = time.monotonic()
code, output = _run(command, remaining, path, config.password, cancel=cancel)
config.timings.append({"stage": "smb-inventory", "host": "", "seconds": round(time.monotonic() - started, 3), "exit_code": code})
if not code and invocation_failed(output, tuple(batch)):
code = 1
error(f"SMB discovery invocation reported an error; see {path.name}")
@@ -514,10 +532,15 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
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:
with lock:
disabled = disabled_collectors.get(stage)
if cancel.is_set():
status = "skipped-cancelled"
elif unavailable(ports):
status = "skipped-service-unavailable"
elif disabled:
status = "skipped-local-error"
result.notes.append(f"{stage} disabled after local tool failure: {disabled}")
elif not tools[tool]:
status = "missing"
else:
@@ -559,7 +582,29 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
# 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)))
evidence = parse_rpcdump(text, address, str(path))
result.merge(evidence)
cleaned = ANSI.sub("", text)
# Impacket catches some protocol exceptions and returns exit zero,
# even printing 'No endpoints found.' after that failure.
protocol_error = re.search(
r"(?mi)^\s*(?:\[[-!*]\]\s*|(?:CRITICAL|ERROR):(?:root:)?)?"
r"(?:Protocol failed:|Traceback \(most recent call last\):|ModuleNotFoundError:|ImportError:)", cleaned)
empty_inventory = re.search(r"(?mi)^\s*(?:\[\*\]\s*)?No endpoints found\.\s*$", cleaned)
if not code and (protocol_error or (not evidence.rpc_interfaces and not empty_inventory)):
code = 1
error(f"rpcdump for {address} returned no reliable inventory: {_failure_hint(text)}; see {path.name}")
if code:
hint = redact_text(_failure_hint(text), config.password)
result.notes.append(f"rpcdump: {hint}")
# A missing Python dependency or invalid CLI is local, not 28
# independent remote failures. Stop scheduling further copies.
local_error = re.search(r"(?m)^\s*(?:ModuleNotFoundError|ImportError):", cleaned)
cli_error = re.search(r"(?m)^\s*" + re.escape(Path(str(tools["rpcdump"])).name) +
r": error:.*(?:unrecognized arguments:|invalid choice:)", cleaned)
if local_error or cli_error:
with lock:
disabled_collectors["rpcdump"] = hint
return "success" if code == 0 else "failed"
def store_check(key: str, outcome: CheckResult, source: Path, backend: str = "netexec") -> None:
@@ -579,8 +624,13 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
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)
config.commands.append(["<native:smb-compression>", address, "TCP/445", "SMB2-NEGOTIATE-only"] if definition.key == "smbghost" else
["<native:longlogon>", address, "UDP/389", "EX-control+guarded-minimal-legacy"])
outcome = (probe_smb_compression(address, timeout=min(5.0, config.timeout), cancel=cancel)
if definition.key == "smbghost" else
probe_longlogon(address, timeout=min(3.0, config.timeout / 2), cancel=cancel))
with lock:
config.timings.append({"stage": definition.key, "host": address, "seconds": round(time.monotonic() - started, 3), "status": outcome.status})
atomic_text(path, redact_text(f"{outcome.status}\n{outcome.evidence}\n", config.password))
else:
command = [*nxc_command([address], 1), "-M", definition.nxc_module]
+133
View File
@@ -0,0 +1,133 @@
"""One ordinary SMB2 NEGOTIATE, before login. No exploit/transform packets.
Wire layouts: MS-SMB2 2.2.1, 2.2.3, 2.2.4 and negotiate contexts.
https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e14db7ff-763a-4263-8b10-0c3944f52fc5
https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/63abf97c-0d09-47e2-88d6-6bfa552949a5
"""
from __future__ import annotations
import ipaddress
import os
import socket
import struct
import threading
import time
from .checks import CheckResult
DIALECTS = (0x202, 0x210, 0x300, 0x302, 0x311)
ALGORITHMS = {1: "LZNT1", 2: "LZ77", 3: "LZ77+Huffman"}
def negotiate_request() -> bytes:
header = bytearray(64)
header[:4] = b"\xfeSMB"
struct.pack_into("<H", header, 4, 64)
struct.pack_into("<H", header, 14, 1) # credits requested
body = struct.pack("<HHHHI16sIHH", 36, len(DIALECTS), 1, 0, 0,
os.urandom(16), 112, 2, 0)
preauth = struct.pack("<HHH", 1, 32, 1) + os.urandom(32) # SHA-512
compression = struct.pack("<HHIHHH", 3, 0, 0, 1, 2, 3)
def context(kind, data):
value = struct.pack("<HHI", kind, len(data), 0) + data
return value + b"\x00" * (-len(value) % 8)
payload = bytes(header) + body + struct.pack("<5H", *DIALECTS) + b"\x00\x00"
payload += context(1, preauth) + context(3, compression)
return len(payload).to_bytes(4, "big") + payload
def parse_negotiate(data: bytes) -> CheckResult:
if len(data) < 128 or data[:4] != b"\xfeSMB" or struct.unpack_from("<H", data, 4)[0] != 64:
raise ValueError("invalid/truncated SMB2 negotiate response")
status, command = struct.unpack_from("<IH", data, 8)
flags, next_command = struct.unpack_from("<II", data, 16)
if status or command or not flags & 1 or flags & 2 or next_command or any(data[24:32]):
raise ValueError(f"unexpected SMB2 negotiate header/status 0x{status:08x}")
structure, security, dialect, count = struct.unpack_from("<HHHH", data, 64)
if structure != 65 or dialect not in DIALECTS or security & ~3:
raise ValueError("invalid SMB2 negotiate fields")
# On older dialects count/offset are reserved. MS-SMB2 requires clients
# to ignore them; Windows may leave nonzero values in the count field.
security_offset, security_length, context_offset = struct.unpack_from("<HHI", data, 120)
if security_length and (security_offset < 128 or security_offset + security_length > len(data)):
raise ValueError("invalid security buffer bounds")
description = f"SMB dialect 0x{dialect:04x}"
algorithms = set()
if dialect == 0x311:
security_end = security_offset + security_length if security_length else 128
if not 1 <= count <= 32 or context_offset < max(128, security_end) or context_offset % 8:
raise ValueError("invalid negotiate context count/offset")
offset, kinds = context_offset, set()
for _ in range(count):
if offset + 8 > len(data):
raise ValueError("truncated negotiate context header")
kind, length, _reserved = struct.unpack_from("<HHI", data, offset)
start, end = offset + 8, offset + 8 + length
if (kind in {1, 2, 3, 6, 7, 8} and kind in kinds) or end > len(data):
raise ValueError("invalid/duplicate negotiate context")
kinds.add(kind)
value = data[start:end]
if kind == 1:
if len(value) < 6:
raise ValueError("truncated preauthentication context")
hashes, salt, algorithm = struct.unpack_from("<HHH", value)
if hashes != 1 or algorithm != 1 or len(value) != 6 + salt:
raise ValueError("invalid preauthentication context")
if kind == 3:
if length < 10:
raise ValueError("truncated compression context")
number, _padding, compression_flags = struct.unpack_from("<HHI", value)
# Ignore padding; only enable algorithms actually offered. The
# server can advertise other IDs, per MS-SMB2 3.2.5.2.
if not 1 <= number <= 32 or length < 8 + number * 2 or compression_flags & ~1:
raise ValueError("invalid compression algorithm array")
algorithms = set(struct.unpack_from(f"<{number}H", value, 8))
if any(algorithm >= 32 for algorithm in algorithms):
raise ValueError("invalid compression algorithm ID")
algorithms.intersection_update(ALGORITHMS)
offset = (end + 7) & ~7
if 1 not in kinds:
raise ValueError("missing mandatory preauthentication context")
if algorithms - {0}:
chosen = ", ".join(ALGORITHMS[item] for item in sorted(algorithms - {0}))
return CheckResult("exposed", f"{description}; compression negotiated: {chosen}. Capability only; SMBGhost patch state unknown.")
return CheckResult("not-exposed", f"{description}; offered compression was not negotiated. Capability result only; not a patch/CVE verdict.")
def probe_smb_compression(address: str, timeout: float = 5.0,
cancel: threading.Event | None = None) -> CheckResult:
address = str(ipaddress.ip_address(address)) # never follow advertised endpoints
if cancel and cancel.is_set():
return CheckResult("skipped-cancelled", "scan interrupted")
deadline = time.monotonic() + timeout
def remaining():
if cancel and cancel.is_set():
raise InterruptedError("scan interrupted")
value = deadline - time.monotonic()
if value <= 0:
raise TimeoutError("SMB negotiation timeout")
return value
try:
# Numeric target, one connection and one request; no session setup/login.
with socket.create_connection((address, 445), timeout=remaining()) as connection:
connection.settimeout(remaining())
connection.sendall(negotiate_request())
def receive(size):
data = bytearray()
while len(data) < size:
connection.settimeout(remaining())
block = connection.recv(size - len(data))
if not block:
raise ValueError("connection closed before complete SMB response")
data.extend(block)
return bytes(data)
netbios = receive(4)
length = int.from_bytes(netbios, "big")
if netbios[0] != 0 or not 128 <= length <= 65536:
raise ValueError("invalid/oversized SMB response frame")
return parse_negotiate(receive(length))
except InterruptedError:
return CheckResult("skipped-cancelled", "scan interrupted")
except (OSError, ValueError) as exc:
return CheckResult("inconclusive", f"SMB negotiate failed: {exc}")
+64 -5
View File
@@ -29,6 +29,64 @@ def alive(addresses, on_result, **kwargs):
class BatchExecutionTests(unittest.TestCase):
def test_rpcdump_exit_zero_requires_reliable_inventory_and_no_protocol_error(self):
endpoint = "UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0\nBindings:\n ncacn_ip_tcp:192.0.2.1[50000]\n"
cases = [
("[-] Protocol failed: access denied\n[*] No endpoints found.\n", "failed", True),
("CRITICAL:root:Protocol failed: refused\n", "failed", True),
("", "failed", True),
("[*] Received one endpoint.\n", "failed", True),
(endpoint + "[-] Protocol failed: incomplete lookup\n", "failed", True),
("[*] No endpoints found.\n", "success", False),
(endpoint + "Provider: ModuleNotFoundError: remote annotation\n", "success", False),
]
for text, status, has_errors in cases:
with self.subTest(text=text), tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), use_enum4linux=False, use_rpcdump=True)
def execute(command, timeout, log, password, **kwargs):
return (0, text) if command[0] == "fake-rpc" else (0, banner("192.0.2.1"))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
host = scan(config)["192.0.2.1"]
self.assertEqual(host.collection["rpcdump"], status)
self.assertEqual(bool(config.errors), has_errors)
self.assertEqual(coverage(host)[2], has_errors)
if endpoint in text:
self.assertTrue(host.rpc_interfaces) # keep partial evidence
def test_local_rpcdump_dependency_error_stops_queued_copies_not_other_checks(self):
addresses = ["192.0.2.1", "192.0.2.2", "192.0.2.3"]
calls = []
def execute(command, timeout, log, password, **kwargs):
calls.append(command)
if command[0] == "fake-rpc":
return 1, "Traceback (most recent call last):\nModuleNotFoundError: No module named 'impacket'"
if "-M" in command:
return 0, f"MS17-010 {command[2]} 445 HOST {command[2]} does NOT appear vulnerable"
return 0, "\n".join(banner(address) for address in addresses)
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), jobs=1, use_enum4linux=False,
use_rpcdump=True, 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(sum(command[0] == "fake-rpc" for command in calls), 1)
self.assertEqual(sum("-M" in command for command in calls), 3)
self.assertEqual(hosts[addresses[0]].collection["rpcdump"], "failed")
self.assertEqual(hosts[addresses[1]].collection["rpcdump"], "skipped-local-error")
self.assertTrue(all(host.checks["ms17-010"] == "not-vulnerable" for host in hosts.values()))
self.assertIn("ModuleNotFoundError", config.errors[0])
self.assertTrue(any(item["stage"] == "rpcdump" and item["exit_code"] == 1 for item in config.timings))
def test_remote_rpcdump_failure_does_not_disable_other_targets(self):
addresses = ["192.0.2.1", "192.0.2.2"]
def execute(command, timeout, log, password, **kwargs):
return (1, "RPC access denied") if command[0] == "fake-rpc" else (0, "\n".join(banner(address) for address in addresses))
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), jobs=1, use_enum4linux=False, use_rpcdump=True)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
hosts = scan(config)
self.assertTrue(all(host.collection["rpcdump"] == "failed" for host in hosts.values()))
self.assertEqual(len(config.errors), 2)
def test_duplicate_api_checks_are_executed_and_counted_once(self):
definition = resolve_checks(["ms17-010"])[0]
with tempfile.TemporaryDirectory() as temporary:
@@ -84,7 +142,7 @@ class BatchExecutionTests(unittest.TestCase):
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):
def test_recommended_32_host_scan_uses_24_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):
@@ -118,11 +176,12 @@ class BatchExecutionTests(unittest.TestCase):
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")):
patch("rpcscope.runner.probe_longlogon", return_value=CheckResult("precondition-not-met", "not patch detection")), \
patch("rpcscope.runner.probe_smb_compression", return_value=CheckResult("exposed", "compression capability only")):
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(check_commands), 24)
self.assertEqual(len(commands), 25) # one separate inventory invocation
self.assertEqual(len(hosts), 32)
self.assertGreater(len(snapshots), 1)
for host in hosts.values():
@@ -130,7 +189,7 @@ class BatchExecutionTests(unittest.TestCase):
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["smbghost"], "exposed")
self.assertEqual(host.checks["oxid"], "observed")
self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS))
self.assertEqual(config.errors, [])
+25 -4
View File
@@ -50,11 +50,33 @@ class CheckerOutputTests(unittest.TestCase):
) + "\x1b[0m"
self.assertEqual(classify_nxc("printnightmare", HOST, text).status, "suspected")
def test_smbghost_capability_indicator_is_only_suspected(self):
def test_smbghost_capability_indicator_is_not_a_cve_finding(self):
text = nxc_line(
"SMBGHOST", "[+] Potentially vulnerable to SMBGhost (CVE-2020-0796)",
)
self.assertEqual(classify_nxc("smbghost", HOST, text).status, "suspected")
self.assertEqual(classify_nxc("smbghost", HOST, text).status, "exposed")
def test_zerologon_exhausted_attempts_are_a_scoped_negative(self):
result = classify_nxc("zerologon", HOST, nxc_line("ZEROLOGON", "[+] Attack failed. Target is probably patched."))
self.assertEqual(result.status, "not-vulnerable")
self.assertIn("probabilistic", result.evidence)
def test_denial_blocks_probe_instead_of_implying_patched(self):
result = classify_nxc("printnightmare", HOST, nxc_line("PRINTNIGHTMARE", "[-] Failed to bind: STATUS_ACCESS_DENIED"))
self.assertEqual(result.status, "skipped-access-denied")
self.assertEqual(classify_nxc("ms17-010", HOST, nxc_line("SMB", "[-] LAB\\: STATUS_LOGON_FAILURE")).status, "skipped-auth-denied")
self.assertEqual(classify_nxc("ms17-010", HOST, nxc_line("SMB", "[+] LAB\\user:STATUS_LOGON_FAILURE")).status, "inconclusive")
def test_mixed_rpc_methods_do_not_hide_positive_binds_behind_another_denial(self):
text = nxc_line("COERCE_PLUS", "[-] Failed to bind: STATUS_ACCESS_DENIED") + nxc_line("COERCE_PLUS", "[+] VULNERABLE, PetitPotam")
self.assertEqual(classify_nxc("petitpotam", HOST, text).status, "exposed")
text = nxc_line("PRINTNIGHTMARE", "[-] Failed to bind: STATUS_ACCESS_DENIED") + nxc_line(
"PRINTNIGHTMARE", "[+] Vulnerable, next step https://github.com/ly4k/PrintNightmare")
self.assertEqual(classify_nxc("printnightmare", HOST, text).status, "inconclusive")
def test_zerologon_mixed_positive_negative_is_unknown_not_patched(self):
text = nxc_line("ZEROLOGON", "[+] VULNERABLE") + nxc_line("ZEROLOGON", "[+] Attack failed. Target is probably patched.")
self.assertEqual(classify_nxc("zerologon", HOST, text).status, "inconclusive")
def test_nonzero_exit_fails_closed_even_with_result_markers(self):
for message in ("[+] VULNERABLE", f"[-] {HOST} does NOT appear vulnerable"):
@@ -80,8 +102,7 @@ class CheckerOutputTests(unittest.TestCase):
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"),
("zerologon", "ZEROLOGON", "[-] Error while connecting to host: DCERPCException, which means this is probably not a DC!"),
("smbghost", "SMBGHOST", "[-] Error while connecting to host: timed out"),
]
for key, module, message in cases:
+7 -4
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from unittest.mock import patch
from rpcscope.cli import main
from rpcscope.checks import CheckResult
TOOLS = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None}
@@ -25,7 +26,8 @@ class CliTests(unittest.TestCase):
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.assertEqual(sum("-M" in command for command in commands), 10 if isolated else 6)
self.assertTrue(any(command[0] == "<native:smb-compression>" for command in commands))
self.assertTrue(any(command[0] == "<native:longlogon>" for command in commands))
def test_tcp_share_dry_run_shows_gating_without_guessing_live_capabilities(self):
@@ -68,7 +70,7 @@ class CliTests(unittest.TestCase):
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)])
"--discovery", "smb", "--no-enum4linux", "--no-rpcdump", "--progress", "plain", "--format", "full", "-o", str(destination)])
self.assertEqual(code, 0)
self.assertIn("[3/3] SUMMARY scan complete", diagnostics.getvalue())
self.assertIn("VULN: [MS17-010]", captured.getvalue())
@@ -97,15 +99,16 @@ class CliTests(unittest.TestCase):
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), \
patch("rpcscope.runner.probe_smb_compression", return_value=CheckResult("inconclusive", "no response")), \
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.assertIn("1 hosts | 0 confirmed", output.getvalue())
self.assertNotIn("PARTIAL", output.getvalue())
self.assertNotIn("NOT VULNERABLE", output.getvalue())
self.assertIn("SUMMARY scan complete", diagnostics.getvalue())
self.assertIn("COMPLETE |", diagnostics.getvalue())
def test_dry_run_has_no_files_and_keeps_json_on_stdout(self):
with tempfile.TemporaryDirectory() as temporary:
+133
View File
@@ -0,0 +1,133 @@
import io
import unittest
from unittest.mock import patch
from rpcscope.console import compact, use_color
from rpcscope.models import HostEvidence, Report
from rpcscope.progress import ProgressEvent, ProgressReporter
from rpcscope.rules import evaluate
class Terminal(io.StringIO):
def isatty(self):
return True
def report(hosts, errors=None):
return Report("now", "test", [], None, None, hosts, evaluate(hosts), errors or [])
class ConsoleTests(unittest.TestCase):
def test_conflicting_results_are_not_counted_as_confirmed(self):
host = HostEvidence("192.0.2.1", checks={"ms17-010": "vulnerable"})
host.merge(HostEvidence(host.address, checks={"ms17-010": "not-vulnerable"}))
text = compact(report([host]))
self.assertIn("CONFLICT[MS17-010]", text)
self.assertNotIn("VULN[MS17-010]", text)
self.assertIn("0 confirmed", text)
self.assertIn("1 conflicting check", text)
finding = report([host]).findings[0]
self.assertEqual(finding.confidence, "low")
self.assertIn("conflict", finding.title)
def test_optional_signing_is_not_labelled_disabled(self):
host = HostEvidence("192.0.2.1", signing=False)
text = compact(report([host]))
self.assertIn("SIGNING-OPTIONAL", text)
self.assertNotIn("SIGNING-OFF", text)
def test_pending_and_interrupted_output_are_explicit(self):
host = HostEvidence("192.0.2.1", checks={"ms17-010": "pending"}, collection={"rpcdump": "pending"})
current = report([host])
current.mode = "interrupted"
text = compact(current)
self.assertIn("PENDING[rpcdump]", text)
self.assertNotIn("ERROR[rpcdump]", text)
self.assertIn("INTERRUPTED", text)
self.assertIn("UNKNOWN:1", text)
def test_compact_percentage_resets_explicitly_for_audit_phase(self):
output = Terminal()
reporter = ProgressReporter(output, mode="compact", color="never")
reporter(ProgressEvent("start", "tcp-discovery", total=509))
reporter(ProgressEvent("advance", "tcp-discovery", completed=508, total=509))
reporter(ProgressEvent("finish", "discovery", completed=509, total=649))
reporter(ProgressEvent("start", "batch:ms17-010", completed=509, total=649))
self.assertIn("0% audit [0/140]", reporter._dashboard(reporter.clock()))
reporter(ProgressEvent("finish", "ms17-010", "192.0.2.1", completed=579, total=649))
self.assertIn("50% audit [70/140]", reporter._dashboard(reporter.clock()))
def test_compact_separates_probe_results_and_collection_failures(self):
host = HostEvidence("192.0.2.1", hostname="HOST", domain="DOMAIN", checks={
"ms17-010": "not-vulnerable", "smbghost": "exposed",
"printnightmare": "skipped-access-denied", "zerologon": "inconclusive",
}, check_details={"zerologon": "checker exited with status 124"}, collection={"rpcdump": "failed"})
text = compact(report([host]))
self.assertIn("192.0.2.1 (HOST)", text)
self.assertIn("NOT-VULN[MS17-010]", text)
self.assertIn("EXPOSED[SMB3-COMPRESSION]", text)
self.assertIn("SKIP[access-denied:1]", text)
self.assertIn("UNKNOWN:1(timeout:1)", text)
self.assertIn("ERROR[rpcdump]", text)
self.assertNotIn("PARTIAL", text)
self.assertNotIn("PORTS:", text)
self.assertNotIn("4/4", text)
self.assertNotIn("\x1b", text)
def test_dead_hosts_are_hidden_and_uncertainty_is_not_a_clean_verdict(self):
text = compact(report([HostEvidence("192.0.2.2", services={"445": "timeout"}),
HostEvidence("192.0.2.1", checks={"ms17-010": "inconclusive"})]))
self.assertNotIn("192.0.2.2", text)
self.assertNotIn("NOT-VULN", text)
self.assertIn("UNKNOWN:1", text)
def test_verbose_contains_evidence_and_local_error_notes(self):
host = HostEvidence("192.0.2.1", checks={"ms17-010": "inconclusive"},
check_details={"ms17-010": "no SMB1 response"})
text = compact(report([host], ["rpcdump: ModuleNotFoundError"]), verbose=True)
self.assertIn("MS17-010: inconclusive", text)
self.assertIn("no SMB1 response", text)
self.assertIn("ModuleNotFoundError", text)
def test_color_detection_respects_redirects_and_no_color(self):
with patch.dict("os.environ", {}, clear=True):
self.assertTrue(use_color(Terminal()))
self.assertFalse(use_color(io.StringIO()))
self.assertFalse(use_color(Terminal(), "never"))
with patch.dict("os.environ", {"NO_COLOR": "1"}):
self.assertFalse(use_color(Terminal()))
self.assertTrue(use_color(io.StringIO(), "always"))
def test_terminal_control_characters_in_host_and_evidence_are_removed(self):
host = HostEvidence("192.0.2.1", hostname="FAKE\x1b[2K\nNAME", checks={"ms17-010": "vulnerable"})
text = compact(report([host]))
self.assertIn("(FAKE NAME)", text)
self.assertNotIn("\x1b", text)
self.assertEqual(len(text.splitlines()), 2)
self.assertIn("\x1b[31;1mVULN[MS17-010]", compact(report([host]), color=True))
def test_compact_tty_uses_a_bar_without_completed_task_lines(self):
output = Terminal()
reporter = ProgressReporter(output, mode="compact", color="never")
reporter(ProgressEvent("start", "discovery", total=100))
reporter(ProgressEvent("finish", "discovery", completed=1, total=100))
reporter(ProgressEvent("start", "batch:ms17-010", total=100))
reporter(ProgressEvent("finish", "ms17-010", "192.0.2.1", "vulnerable", 99, 100))
reporter._render()
reporter(ProgressEvent("summary", "scan", status="complete", completed=100, total=100))
self.assertIn("[###############-]", output.getvalue())
self.assertIn("99%", output.getvalue())
self.assertNotIn("DONE", output.getvalue())
self.assertNotIn("192.0.2.1", output.getvalue())
self.assertIn("COMPLETE |", output.getvalue())
self.assertTrue(output.getvalue().endswith("\n"))
def test_compact_redirected_output_has_only_phase_and_final_lines(self):
output = io.StringIO()
reporter = ProgressReporter(output, mode="compact")
reporter(ProgressEvent("start", "discovery", total=4))
for i in range(4):
reporter(ProgressEvent("finish", "check", f"192.0.2.{i}", "unknown", i + 1, 4))
reporter(ProgressEvent("summary", "scan", status="incomplete", completed=4, total=4))
self.assertEqual(len(output.getvalue().splitlines()), 2)
self.assertNotIn("\x1b", output.getvalue())
+7 -4
View File
@@ -101,7 +101,7 @@ class DiscoveryIntegrationTests(unittest.TestCase):
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())
self.assertIn("INFO[OXID]", captured.getvalue())
def test_review_catalog_is_not_an_executable_check(self):
captured = io.StringIO()
@@ -136,9 +136,9 @@ class DiscoveryIntegrationTests(unittest.TestCase):
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.assertIn("COMPLETE |", diagnostics.getvalue())
self.assertNotIn(RPC_ONLY, diagnostics.getvalue())
self.assertEqual(captured.getvalue(), "")
self.assertEqual(captured.getvalue(), "No reachable SMB/RPC hosts.\n")
report = json.loads((output / "report.json").read_text())
self.assertEqual(report["hosts"][0]["checks"], {})
self.assertEqual(report["hosts"][0]["check_attempts"], [])
@@ -163,9 +163,12 @@ class DiscoveryIntegrationTests(unittest.TestCase):
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.probe_smb_compression", return_value=CheckResult("exposed", "compression only")) as native, \
patch("rpcscope.runner._run", side_effect=run) as execute:
hosts = scan(config, progress=events.append)
self.assertEqual(execute.call_count, 2)
self.assertEqual(execute.call_count, 1)
native.assert_called_once()
self.assertEqual(native.call_args.args, (SMB_ONLY,))
self.assertEqual(hosts[dead].checks, {})
self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False))
self.assertEqual(events[-1].status, "complete")
+7
View File
@@ -59,6 +59,13 @@ class ModelTests(unittest.TestCase):
collection={"rpcdump": "success"}))
self.assertEqual(host.checks["check"], "suspected")
self.assertEqual(host.collection["rpcdump"], "success")
self.assertEqual(host.conflicting_checks(), {"check"})
def test_capability_conflict_is_retained_in_both_merge_orders(self):
for first, second in (("exposed", "not-exposed"), ("not-exposed", "exposed")):
host = HostEvidence("192.0.2.1", checks={"smbghost": first})
host.merge(HostEvidence("192.0.2.1", checks={"smbghost": second}))
self.assertEqual(host.conflicting_checks(), {"smbghost"})
if __name__ == "__main__":
+1 -1
View File
@@ -59,7 +59,7 @@ class NetExecTests(unittest.TestCase):
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(len(batches), 24)
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"]
+1 -1
View File
@@ -124,7 +124,7 @@ class ProgressTests(unittest.TestCase):
def test_tty_dashboard_retains_completed_result(self):
output = Terminal()
clock = Clock()
reporter = ProgressReporter(output, clock=clock)
reporter = ProgressReporter(output, clock=clock, color="never")
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))
+14 -1
View File
@@ -8,6 +8,19 @@ class MinimalReportTests(unittest.TestCase):
def report(self, host, findings=None, principal=None):
return Report("now", "test", [], None, principal, [host], findings or [])
def test_missing_requested_collector_is_not_complete_coverage(self):
host = HostEvidence("192.0.2.1", checks={"ms17-010": "not-vulnerable"}, collection={"rpcdump": "missing"})
self.assertTrue(coverage(host)[2])
self.assertNotIn("NOT VULNERABLE", minimal(self.report(host)))
def test_conflict_does_not_print_positive_or_clean_verdict(self):
host = HostEvidence("192.0.2.1", checks={"ms17-010": "vulnerable"})
host.merge(HostEvidence(host.address, checks={"ms17-010": "not-vulnerable"}))
text = minimal(self.report(host))
self.assertIn("CONFLICT: [MS17-010]", text)
self.assertNotIn(" VULN:", text)
self.assertNotIn("NOT VULNERABLE", text)
def test_positive_vulnerability_line(self):
host = HostEvidence("10.10.10.25", checks={"zerologon": "vulnerable"})
line = minimal(self.report(host, principal="LAB\\auditor"))
@@ -36,7 +49,7 @@ class MinimalReportTests(unittest.TestCase):
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("SUSPECTED: [SMB3-COMPRESSION]", line)
self.assertIn("CHECKS: 1/2 (IN PROGRESS)", line)
self.assertNotIn("NO VULNERABILITIES CONFIRMED", line)
self.assertNotIn(" VULN:", line)
+9 -1
View File
@@ -5,6 +5,14 @@ from rpcscope.rules import evaluate_host
class RuleTests(unittest.TestCase):
def test_negative_capability_with_retained_conflict_still_gets_review_finding(self):
host = HostEvidence("192.0.2.1", checks={"smbghost": "exposed"})
host.merge(HostEvidence(host.address, checks={"smbghost": "not-exposed"}))
findings = evaluate_host(host)
self.assertEqual(len(findings), 1)
self.assertIn("conflict", findings[0].title)
self.assertEqual(findings[0].confidence, "low")
def test_rules_separate_exposure_from_confirmed_vulnerability(self):
host = HostEvidence(
address="10.10.10.25",
@@ -78,7 +86,7 @@ class RuleTests(unittest.TestCase):
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)
self.assertIn("never SMBGhost patch state", finding.impact)
def test_checker_positive_includes_actual_evidence(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
+149
View File
@@ -0,0 +1,149 @@
"""Synthetic protocol frames only: never connect to remote scan targets."""
import socket
import struct
import threading
import unittest
from unittest.mock import MagicMock, patch
from rpcscope.smb_negotiate import negotiate_request, parse_negotiate, probe_smb_compression
def context(kind, data):
value = struct.pack("<HHI", kind, len(data), 0) + data
return value + b"\x00" * (-len(value) % 8)
def response(dialect=0x311, algorithms=(1,), compression=True):
data = bytearray(128)
data[:4] = b"\xfeSMB"
struct.pack_into("<H", data, 4, 64)
struct.pack_into("<I", data, 16, 1)
struct.pack_into("<HHHH", data, 64, 65, 1, dialect, 1 + compression if dialect == 0x311 else 0)
if dialect == 0x311:
struct.pack_into("<I", data, 124, 128)
data += context(1, struct.pack("<HHH", 1, 0, 1))
if compression:
data += context(3, struct.pack("<HHI", len(algorithms), 0, 0) + struct.pack(f"<{len(algorithms)}H", *algorithms))
return bytes(data)
class SmbNegotiateTests(unittest.TestCase):
def test_request_is_small_normal_negotiate_with_correct_context_offsets(self):
frame = negotiate_request()
size = int.from_bytes(frame[:4], "big")
self.assertEqual(size, len(frame) - 4)
self.assertLess(size, 256)
data = frame[4:]
self.assertEqual(data[:4], b"\xfeSMB")
self.assertEqual(struct.unpack_from("<H", data, 12)[0], 0)
self.assertEqual(struct.unpack_from("<H", data, 64)[0], 36)
offset, count = struct.unpack_from("<IH", data, 92)
self.assertEqual((offset, count), (112, 2))
self.assertEqual(struct.unpack_from("<HHI", data, offset), (1, 38, 0))
self.assertEqual(struct.unpack_from("<HHI", data, 160), (3, 14, 0))
def test_valid_capability_is_exposure_not_cve(self):
for algorithms in ((1,), (1, 2, 3), (0, 1)):
with self.subTest(algorithms=algorithms):
outcome = parse_negotiate(response(algorithms=algorithms))
self.assertEqual(outcome.status, "exposed")
self.assertIn("patch state unknown", outcome.evidence)
def test_absent_capability_is_not_a_patch_verdict(self):
for frame in (response(0x202), response(0x210), response(0x300), response(0x302),
response(compression=False), response(algorithms=(0,))):
outcome = parse_negotiate(frame)
self.assertEqual(outcome.status, "not-exposed")
self.assertIn("not a patch/CVE verdict", outcome.evidence)
def test_malformed_offsets_lengths_and_wrong_responses_never_become_negative(self):
modifications = [(0, b"\xffSMB"), (4, b"\x20\x00"), (8, b"\x22\x00\x00\xc0"),
(12, b"\x01\x00"), (16, b"\x00\x00\x00\x00"),
(16, b"\x03\x00\x00\x00"), (20, b"\x80\x00\x00\x00"),
(24, b"\x01"), (64, b"\x40\x00"), (68, b"\xff\xff"),
(70, b"\xff\xff"), (124, b"\x01\x00\x00\x00"),
(124, b"\x80\xff\x00\x00"), (130, b"\xff\xff"),
(136, b"\x02"), (140, b"\x02"),
(144, b"\x01\x00"), (152, b"\xff\xff")]
for offset, value in modifications:
with self.subTest(offset=offset, value=value):
data = bytearray(response())
data[offset:offset + len(value)] = value
with self.assertRaises(ValueError):
parse_negotiate(bytes(data))
for end in (0, 4, 64, 127, 140, 155):
with self.subTest(end=end), self.assertRaises(ValueError):
parse_negotiate(response()[:end])
def test_older_dialect_reserved_fields_are_ignored(self):
for dialect in (0x202, 0x210, 0x300, 0x302):
with self.subTest(dialect=dialect):
data = bytearray(response(dialect))
struct.pack_into("<H", data, 70, 65535)
struct.pack_into("<I", data, 124, 0xffffffff)
self.assertEqual(parse_negotiate(bytes(data)).status, "not-exposed")
def test_padding_reserved_bytes_and_chained_support_do_not_hide_capability(self):
data = bytearray(response())
struct.pack_into("<I", data, 132, 0xffffffff)
struct.pack_into("<H", data, 154, 0xffff)
struct.pack_into("<I", data, 156, 1)
self.assertEqual(parse_negotiate(bytes(data)).status, "exposed")
def test_server_algorithm_list_uses_intersection_with_our_offer(self):
self.assertEqual(parse_negotiate(response(algorithms=(1, 4, 5))).status, "exposed")
self.assertEqual(parse_negotiate(response(algorithms=(4, 5))).status, "not-exposed")
with self.assertRaises(ValueError):
parse_negotiate(response(algorithms=(32,)))
def test_duplicate_unknown_contexts_are_ignored_but_known_duplicates_are_invalid(self):
data = bytearray(response())
struct.pack_into("<H", data, 70, 4)
data += context(0x100, b"") + context(0x100, b"")
self.assertEqual(parse_negotiate(bytes(data)).status, "exposed")
data = bytearray(response())
struct.pack_into("<H", data, 70, 3)
data += context(3, struct.pack("<HHIH", 1, 0, 0, 1))
with self.assertRaises(ValueError):
parse_negotiate(bytes(data))
def test_fragmented_tcp_response_is_read_completely_without_login(self):
payload = response()
frame = len(payload).to_bytes(4, "big") + payload
connection = MagicMock()
connection.recv.side_effect = [bytes([byte]) for byte in frame]
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect:
connect.return_value.__enter__.return_value = connection
outcome = probe_smb_compression("192.0.2.1")
self.assertEqual(outcome.status, "exposed")
self.assertEqual(connection.sendall.call_count, 1)
self.assertEqual(connection.sendall.call_args.args[0][4:8], b"\xfeSMB")
self.assertEqual(connect.call_args.args, (("192.0.2.1", 445),))
def test_cancellation_does_not_connect(self):
cancel = threading.Event()
cancel.set()
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect:
self.assertEqual(probe_smb_compression("192.0.2.1", cancel=cancel).status, "skipped-cancelled")
connect.assert_not_called()
def test_timeout_eof_or_large_frame_remain_inconclusive(self):
with patch("rpcscope.smb_negotiate.socket.create_connection", side_effect=socket.timeout("timed out")):
self.assertEqual(probe_smb_compression("192.0.2.1").status, "inconclusive")
for value in (b"", b"\x01\x00\x00\x00", b"\x00\x01\x00\x01"):
connection = MagicMock()
connection.recv.return_value = value
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect:
connect.return_value.__enter__.return_value = connection
self.assertEqual(probe_smb_compression("192.0.2.1").status, "inconclusive")
self.assertEqual(connection.recv.call_count, 1)
def test_total_deadline_not_reset_per_fragment(self):
connection = MagicMock()
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect, \
patch("rpcscope.smb_negotiate.time.monotonic", side_effect=[0, 0, 0, 10]):
connect.return_value.__enter__.return_value = connection
outcome = probe_smb_compression("192.0.2.1", timeout=5)
self.assertEqual(outcome.status, "inconclusive")
self.assertIn("timeout", outcome.evidence)
connection.recv.assert_not_called()