diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1a6d6f0 --- /dev/null +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index eeb0328..82f946f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 476dfc4..15177bf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/CHECKER_POLICY.md b/docs/CHECKER_POLICY.md index 999e61a..bdd417e 100644 --- a/docs/CHECKER_POLICY.md +++ b/docs/CHECKER_POLICY.md @@ -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 diff --git a/docs/NETEXEC_REVIEW.md b/docs/NETEXEC_REVIEW.md index 5a25328..4bd063f 100644 --- a/docs/NETEXEC_REVIEW.md +++ b/docs/NETEXEC_REVIEW.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index a9507f1..36c2399 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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*"] diff --git a/rpcscope/__init__.py b/rpcscope/__init__.py index 24dbcde..9d0c3c8 100644 --- a/rpcscope/__init__.py +++ b/rpcscope/__init__.py @@ -1,4 +1,4 @@ """RPCScope: normalize and correlate SMB/RPC enumeration evidence.""" -__version__ = "0.1.0" +__version__ = "0.2.1" diff --git a/rpcscope/catalog.py b/rpcscope/catalog.py index 50c68a2..e0556d6 100644 --- a/rpcscope/catalog.py +++ b/rpcscope/catalog.py @@ -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", diff --git a/rpcscope/checks.py b/rpcscope/checks.py index d34c517..c49ba14 100644 --- a/rpcscope/checks.py +++ b/rpcscope/checks.py @@ -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: diff --git a/rpcscope/cli.py b/rpcscope/cli.py index 2d780cc..4f9a275 100644 --- a/rpcscope/cli.py +++ b/rpcscope/cli.py @@ -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 diff --git a/rpcscope/console.py b/rpcscope/console.py new file mode 100644 index 0000000..aa72ae8 --- /dev/null +++ b/rpcscope/console.py @@ -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" diff --git a/rpcscope/models.py b/rpcscope/models.py index b9c9394..cef785a 100644 --- a/rpcscope/models.py +++ b/rpcscope/models.py @@ -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) diff --git a/rpcscope/progress.py b/rpcscope/progress.py index 4c446aa..81e2989 100644 --- a/rpcscope/progress.py +++ b/rpcscope/progress.py @@ -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") diff --git a/rpcscope/reporting.py b/rpcscope/reporting.py index 908a7d4..ac43305 100644 --- a/rpcscope/reporting.py +++ b/rpcscope/reporting.py @@ -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: diff --git a/rpcscope/rules.py b/rpcscope/rules.py index 4da494f..f58fda8 100644 --- a/rpcscope/rules.py +++ b/rpcscope/rules.py @@ -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.", diff --git a/rpcscope/runner.py b/rpcscope/runner.py index cfaf075..488a766 100644 --- a/rpcscope/runner.py +++ b/rpcscope/runner.py @@ -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(["", address, "UDP/389", "EX-control+guarded-minimal-legacy"]) + config.commands.append(["", address, "TCP/445", "SMB2-NEGOTIATE-only"] if definition.key == "smbghost" else + ["", 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(["", address, "UDP/389", "EX-control+guarded-minimal-legacy"]) - outcome = probe_longlogon(address, timeout=min(3.0, config.timeout / 2), cancel=cancel) + config.commands.append(["", address, "TCP/445", "SMB2-NEGOTIATE-only"] if definition.key == "smbghost" else + ["", 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] diff --git a/rpcscope/smb_negotiate.py b/rpcscope/smb_negotiate.py new file mode 100644 index 0000000..9895a24 --- /dev/null +++ b/rpcscope/smb_negotiate.py @@ -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(" CheckResult: + if len(data) < 128 or data[:4] != b"\xfeSMB" or struct.unpack_from(" 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(" 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("= 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}") diff --git a/tests/test_batch_execution.py b/tests/test_batch_execution.py index d874e6e..334d019 100644 --- a/tests/test_batch_execution.py +++ b/tests/test_batch_execution.py @@ -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, []) diff --git a/tests/test_checks.py b/tests/test_checks.py index 36abaa3..adf76b0 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index 5259a1c..97f13cb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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] == "" for command in commands)) self.assertTrue(any(command[0] == "" 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: diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..b941cf3 --- /dev/null +++ b/tests/test_console.py @@ -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()) diff --git a/tests/test_discovery_integration.py b/tests/test_discovery_integration.py index 9f75b01..54cbfc8 100644 --- a/tests/test_discovery_integration.py +++ b/tests/test_discovery_integration.py @@ -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") diff --git a/tests/test_models.py b/tests/test_models.py index 3a9fc0e..6491d0f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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__": diff --git a/tests/test_netexec.py b/tests/test_netexec.py index e59c577..74a4573 100644 --- a/tests/test_netexec.py +++ b/tests/test_netexec.py @@ -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"] diff --git a/tests/test_progress.py b/tests/test_progress.py index edfa91c..8554489 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -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)) diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 227dc5a..38bfe88 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -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) diff --git a/tests/test_rules.py b/tests/test_rules.py index e9e46ad..8112fbe 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -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"}, diff --git a/tests/test_smb_negotiate.py b/tests/test_smb_negotiate.py new file mode 100644 index 0000000..1bd7a86 --- /dev/null +++ b/tests/test_smb_negotiate.py @@ -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("