Improve evidence classification and compact CLI reporting
This commit is contained in:
1 parent
9dc7289652
commit
dda4f8563f
28 files changed
+945
-87
No files matched your search
@@ -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)
|
||||||
@@ -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.
|
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:
|
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.
|
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
|
```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
|
192.0.2.20 CHECKED UNKNOWN: [MS17-010] PORTS: 135/open,445/open CHECKS: 1/1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Progress and performance
|
## 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
|
```text
|
||||||
[0/3] START discovery (elapsed 0s)
|
[########--------] 50% audit [25/50] batch:ms17-010 | 12s
|
||||||
[1/3] DONE discovery success (0.3s) | 2 target(s)
|
192.0.2.10 (LAB-DC) VULN[MS17-010] EXPOSED[SMB3-COMPRESSION]
|
||||||
[1/3] START batch:ms17-010 (elapsed 0s)
|
192.0.2.20 (WS01) NOT-VULN[MS17-010] SKIP[access-denied:1] ERROR[rpcdump]
|
||||||
[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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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.
|
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.
|
`--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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
`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.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
Every implemented check must return an explicit state:
|
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;
|
- `vulnerable`: the dedicated checker returned its documented positive marker;
|
||||||
- `not-vulnerable`: the checker completed and returned its documented negative marker;
|
- `not-vulnerable`: the checker completed and returned its documented negative marker;
|
||||||
- `suspected`: the checker found a capability or permission indicator requiring independent validation;
|
- `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.
|
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
|
## Catalog synchronization
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## Execution and budgets
|
||||||
@@ -43,8 +43,10 @@ compatibility. CLI defaults select batching and leave full enum to `--deep`.
|
|||||||
|
|
||||||
## Verification and limits
|
## Verification and limits
|
||||||
|
|
||||||
The mocked 32-live-host recommended scan executes 28 NetExec check invocations
|
The mocked 32-live-host recommended scan now executes 24 NetExec check invocations
|
||||||
instead of the old 352 (seven module groups, four eight-host batches). All twelve
|
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
|
selected results per host remain represented; inventory and capability-help
|
||||||
invocations are separate from this count. This is not a wall-clock benchmark.
|
invocations are separate from this count. This is not a wall-clock benchmark.
|
||||||
|
|
||||||
|
|||||||
+4
-6
@@ -1,23 +1,21 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68"]
|
requires = ["setuptools>=77"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rpcscope"
|
name = "rpcscope"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
description = "Evidence-driven SMB and Windows RPC exposure auditing"
|
description = "Evidence-driven SMB and Windows RPC exposure auditing"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = { text = "MIT" }
|
license = "MIT"
|
||||||
|
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||||
authors = [{ name = "RPCScope contributors" }]
|
authors = [{ name = "RPCScope contributors" }]
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
rpcscope = "rpcscope.cli:main"
|
rpcscope = "rpcscope.cli:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["rpcscope*"]
|
include = ["rpcscope*"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""RPCScope: normalize and correlate SMB/RPC enumeration evidence."""
|
"""RPCScope: normalize and correlate SMB/RPC enumeration evidence."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.1"
|
||||||
|
|
||||||
+6
-5
@@ -65,16 +65,17 @@ CHECKS: dict[str, CheckDefinition] = {
|
|||||||
),
|
),
|
||||||
"smbghost": CheckDefinition(
|
"smbghost": CheckDefinition(
|
||||||
key="smbghost",
|
key="smbghost",
|
||||||
label="CVE-2020-0796/SMBGHOST",
|
label="SMB3-COMPRESSION",
|
||||||
cves=("CVE-2020-0796",),
|
cves=("CVE-2020-0796",),
|
||||||
surface="SMBv3 compression",
|
surface="SMBv3 compression",
|
||||||
nxc_module="smbghost",
|
nxc_module="smbghost",
|
||||||
ip_versions=(4,),
|
backend="native",
|
||||||
requires_credentials=False,
|
requires_credentials=False,
|
||||||
severity="critical",
|
severity="info",
|
||||||
reference="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2020-0796",
|
reference="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2020-0796",
|
||||||
positive_status="suspected",
|
positive_status="exposed",
|
||||||
note="Dialect/compression indicator only; does not distinguish patched SMB 3.1.1 servers.",
|
kind="capability",
|
||||||
|
note="Unauthenticated SMB2 negotiate only: reports compression capability, never SMBGhost patch state. No compressed transform or exploit is sent.",
|
||||||
),
|
),
|
||||||
"nopac": CheckDefinition(
|
"nopac": CheckDefinition(
|
||||||
key="nopac",
|
key="nopac",
|
||||||
|
|||||||
+23
-5
@@ -32,8 +32,12 @@ def index_messages(text: str) -> MessageIndex:
|
|||||||
address = str(ipaddress.ip_address(parts[1].strip("[]")))
|
address = str(ipaddress.ip_address(parts[1].strip("[]")))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
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)
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +63,15 @@ def classify_nxc(key: str, host: str, text: str, returncode: int = 0, *,
|
|||||||
if returncode:
|
if returncode:
|
||||||
return CheckResult("inconclusive", f"checker exited with status {returncode}; inspect its log")
|
return CheckResult("inconclusive", f"checker exited with status {returncode}; inspect its log")
|
||||||
messages = _messages(text, definition.nxc_module, host, definition.required_ports, index)
|
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":
|
if definition.kind == "exposure":
|
||||||
method = definition.nxc_options[0].partition("=")[2]
|
method = definition.nxc_options[0].partition("=")[2]
|
||||||
expected = f"VULNERABLE, {method}".casefold()
|
expected = f"VULNERABLE, {method}".casefold()
|
||||||
@@ -93,17 +106,22 @@ def classify_nxc(key: str, host: str, text: str, returncode: int = 0, *,
|
|||||||
positives.append(message)
|
positives.append(message)
|
||||||
elif re.fullmatch(re.escape(host) + r" does NOT appear vulnerable[.!]?", message, re.I):
|
elif re.fullmatch(re.escape(host) + r" does NOT appear vulnerable[.!]?", message, re.I):
|
||||||
negatives.append(message)
|
negatives.append(message)
|
||||||
elif key == "zerologon" and message.upper() == "VULNERABLE":
|
elif key == "zerologon":
|
||||||
positives.append(message)
|
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":
|
elif key == "printnightmare":
|
||||||
if re.fullmatch(r"Vulnerable, next step https://github\.com/ly4k/PrintNightmare/?", message, re.I):
|
if re.fullmatch(r"Vulnerable, next step https://github\.com/ly4k/PrintNightmare/?", message, re.I):
|
||||||
positives.append(message)
|
positives.append(message)
|
||||||
elif message.casefold() == "not vulnerable :'(" or message.casefold() == "not vulnerable":
|
elif message.casefold() == "not vulnerable :'(" or message.casefold() == "not vulnerable":
|
||||||
negatives.append(message)
|
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":
|
elif key == "nopac" and message.upper() == "VULNERABLE":
|
||||||
positives.append(message)
|
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:
|
if positives and negatives:
|
||||||
return CheckResult("inconclusive", "conflicting positive and negative checker messages")
|
return CheckResult("inconclusive", "conflicting positive and negative checker messages")
|
||||||
if positives:
|
if positives:
|
||||||
|
|||||||
+13
-8
@@ -16,6 +16,7 @@ from .rules import evaluate
|
|||||||
from .runner import ScanConfig, analyze_artifacts, doctor, normalize_credentials, scan
|
from .runner import ScanConfig, analyze_artifacts, doctor, normalize_credentials, scan
|
||||||
from .catalog import CHECKS, resolve_checks
|
from .catalog import CHECKS, resolve_checks
|
||||||
from .progress import ProgressReporter
|
from .progress import ProgressReporter
|
||||||
|
from .console import compact, use_color
|
||||||
from .storage import prepare_output
|
from .storage import prepare_output
|
||||||
from .advisories import ADVISORIES, sync_advisories
|
from .advisories import ADVISORIES, sync_advisories
|
||||||
|
|
||||||
@@ -79,8 +80,11 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
help="maximum expanded targets with TCP discovery (default: 4096)")
|
help="maximum expanded targets with TCP discovery (default: 4096)")
|
||||||
scan_p.add_argument("-o", "--output", type=Path, default=Path("rpcscope-output"))
|
scan_p.add_argument("-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("--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",
|
scan_p.add_argument("--progress", choices=("compact", "auto", "plain", "quiet"), default="compact",
|
||||||
help="live progress on stderr; auto adapts to terminals (default: auto)")
|
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("--no-progress", dest="progress", action="store_const", const="quiet")
|
||||||
scan_p.add_argument(
|
scan_p.add_argument(
|
||||||
"--i-am-authorized", "--authorised", "--authorized", dest="authorized", action="store_true",
|
"--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,
|
def _write_report(output: Path, mode: str, targets: list[str], lhost: str | None,
|
||||||
principal: 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)
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
host_list = sorted(hosts.values(), key=lambda item: address_key(item.address))
|
host_list = sorted(hosts.values(), key=lambda item: address_key(item.address))
|
||||||
report = Report(
|
report = Report(
|
||||||
generated_at=datetime.now(timezone.utc).isoformat(), mode=mode, targets=targets,
|
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),
|
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_json(report, output / "report.json")
|
||||||
write_markdown(report, output / "report.md")
|
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.
|
# hundreds of small host jobs finish together.
|
||||||
if now - last_checkpoint >= 0.5:
|
if now - last_checkpoint >= 0.5:
|
||||||
_write_report(args.output, "interrupted" if config.cancelled else "in-progress",
|
_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
|
last_checkpoint = now
|
||||||
|
|
||||||
try:
|
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)
|
hosts = scan(config, progress=progress, checkpoint=checkpoint)
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
report = _write_report(args.output, "interrupted" if config.cancelled else "live-scan",
|
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:
|
except KeyboardInterrupt:
|
||||||
print("Interrupted; existing report checkpoints were retained.", file=sys.stderr)
|
print("Interrupted; existing report checkpoints were retained.", file=sys.stderr)
|
||||||
return 130
|
return 130
|
||||||
@@ -220,7 +224,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(json.dumps(config.commands, indent=2))
|
print(json.dumps(config.commands, indent=2))
|
||||||
return 0
|
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)
|
print(f"Reports: {args.output.resolve()}", file=sys.stderr)
|
||||||
if config.cancelled:
|
if config.cancelled:
|
||||||
return 130
|
return 130
|
||||||
|
|||||||
@@ -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
@@ -62,6 +62,10 @@ class HostEvidence:
|
|||||||
services: dict[str, str] = field(default_factory=dict)
|
services: dict[str, str] = field(default_factory=dict)
|
||||||
check_attempts: list[CheckAttempt] = field(default_factory=list)
|
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:
|
def record_boolean(self, attribute: str, value: bool | None) -> None:
|
||||||
"""Keep conflicting observations visible instead of trusting input order."""
|
"""Keep conflicting observations visible instead of trusting input order."""
|
||||||
if attribute not in {"signing", "smb1", "null_session", "guest_session"}:
|
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.
|
# A later inconclusive run must never erase a confirmed positive result.
|
||||||
rank = {"vulnerable": 5, "suspected": 4, "exposed": 3, "observed": 3,
|
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}
|
"precondition-not-met": 3, "inconclusive": 2}
|
||||||
selected_incoming: set[str] = set()
|
selected_incoming: set[str] = set()
|
||||||
for key, status in other.checks.items():
|
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."
|
note = f"Conflicting {key} checker results (precondition met and not met); verify raw evidence."
|
||||||
if note not in self.notes:
|
if note not in self.notes:
|
||||||
self.notes.append(note)
|
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):
|
if previous is None or rank.get(status, 1) >= rank.get(previous, 1):
|
||||||
self.checks[key] = status
|
self.checks[key] = status
|
||||||
selected_incoming.add(key)
|
selected_incoming.add(key)
|
||||||
@@ -168,6 +176,7 @@ class Report:
|
|||||||
findings: list[Finding]
|
findings: list[Finding]
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
commands: list[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]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|||||||
+36
-6
@@ -11,6 +11,8 @@ import unicodedata
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, TextIO
|
from typing import Callable, TextIO
|
||||||
|
|
||||||
|
from .console import paint, use_color
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProgressEvent:
|
class ProgressEvent:
|
||||||
@@ -63,9 +65,10 @@ class ProgressReporter:
|
|||||||
mode: str = "auto",
|
mode: str = "auto",
|
||||||
clock: Callable[[], float] = time.monotonic,
|
clock: Callable[[], float] = time.monotonic,
|
||||||
interval: float = 10.0,
|
interval: float = 10.0,
|
||||||
|
color: str = "auto",
|
||||||
) -> None:
|
) -> None:
|
||||||
if mode not in {"auto", "plain", "quiet"}:
|
if mode not in {"auto", "compact", "plain", "quiet"}:
|
||||||
raise ValueError("progress mode must be auto, plain, or quiet")
|
raise ValueError("progress mode must be auto, compact, plain, or quiet")
|
||||||
if interval <= 0:
|
if interval <= 0:
|
||||||
raise ValueError("progress interval must be positive")
|
raise ValueError("progress interval must be positive")
|
||||||
self.stream = stream if stream is not None else sys.stderr
|
self.stream = stream if stream is not None else sys.stderr
|
||||||
@@ -73,8 +76,8 @@ class ProgressReporter:
|
|||||||
self.clock = clock
|
self.clock = clock
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
try:
|
try:
|
||||||
self._tty = mode == "auto" and self.stream.isatty()
|
self._tty = mode in {"auto", "compact"} and self.stream.isatty()
|
||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError, ValueError):
|
||||||
self._tty = False
|
self._tty = False
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
@@ -84,17 +87,20 @@ class ProgressReporter:
|
|||||||
self._active: dict[tuple[str, str], float] = {}
|
self._active: dict[tuple[str, str], float] = {}
|
||||||
self._completed = 0
|
self._completed = 0
|
||||||
self._total = 0
|
self._total = 0
|
||||||
|
self._phase_base = 0
|
||||||
|
self._phase = "discovery"
|
||||||
self._last_event: ProgressEvent | None = None
|
self._last_event: ProgressEvent | None = None
|
||||||
self._dashboard_visible = False
|
self._dashboard_visible = False
|
||||||
self._finished = False
|
self._finished = False
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
self._color = use_color(self.stream, color) if mode != "plain" else False
|
||||||
|
|
||||||
def __enter__(self) -> ProgressReporter:
|
def __enter__(self) -> ProgressReporter:
|
||||||
if self._thread is not None or self._closed:
|
if self._thread is not None or self._closed:
|
||||||
raise RuntimeError("progress reporter contexts cannot be reused")
|
raise RuntimeError("progress reporter contexts cannot be reused")
|
||||||
self._started_at = self.clock()
|
self._started_at = self.clock()
|
||||||
self._last_output_at = self._started_at
|
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(
|
self._thread = threading.Thread(
|
||||||
target=self._heartbeat_loop, name="rpcscope-progress", daemon=True
|
target=self._heartbeat_loop, name="rpcscope-progress", daemon=True
|
||||||
)
|
)
|
||||||
@@ -122,6 +128,9 @@ class ProgressReporter:
|
|||||||
now = self.clock()
|
now = self.clock()
|
||||||
self._completed = max(self._completed, event.completed)
|
self._completed = max(self._completed, event.completed)
|
||||||
self._total = max(self._total, event.total, self._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)
|
key = (event.stage, event.host)
|
||||||
if event.kind == "advance":
|
if event.kind == "advance":
|
||||||
# Discovery can finish thousands of unreachable targets. Count
|
# Discovery can finish thousands of unreachable targets. Count
|
||||||
@@ -141,6 +150,17 @@ class ProgressReporter:
|
|||||||
self._active.clear()
|
self._active.clear()
|
||||||
self._finished = True
|
self._finished = True
|
||||||
self._last_event = event
|
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 self._tty:
|
||||||
if event.kind in {"finish", "summary"}:
|
if event.kind in {"finish", "summary"}:
|
||||||
self._write("\r\x1b[2K" + self._event_line(event, duration, now) + "\n")
|
self._write("\r\x1b[2K" + self._event_line(event, duration, now) + "\n")
|
||||||
@@ -171,6 +191,14 @@ class ProgressReporter:
|
|||||||
return line
|
return line
|
||||||
|
|
||||||
def _dashboard(self, now: float) -> str:
|
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"]
|
parts = [self._counts(), "RUNNING" if self._active else "WAITING"]
|
||||||
if self._active:
|
if self._active:
|
||||||
stage, host = next(iter(self._active))
|
stage, host = next(iter(self._active))
|
||||||
@@ -185,6 +213,8 @@ class ProgressReporter:
|
|||||||
def _render(self, heartbeat: bool = True) -> None:
|
def _render(self, heartbeat: bool = True) -> None:
|
||||||
"""Render a heartbeat; factored out so timing is testable without sleeps."""
|
"""Render a heartbeat; factored out so timing is testable without sleeps."""
|
||||||
with self._lock:
|
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:
|
if self.mode == "quiet" or self._closed or self._finished or not self._active:
|
||||||
return
|
return
|
||||||
now = self.clock()
|
now = self.clock()
|
||||||
@@ -196,7 +226,7 @@ class ProgressReporter:
|
|||||||
line = self._dashboard(now)
|
line = self._dashboard(now)
|
||||||
if self._tty:
|
if self._tty:
|
||||||
width = max(1, shutil.get_terminal_size(fallback=(100, 24)).columns - 1)
|
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
|
self._dashboard_visible = True
|
||||||
else:
|
else:
|
||||||
self._write(line + "\n")
|
self._write(line + "\n")
|
||||||
|
|||||||
+26
-10
@@ -31,7 +31,7 @@ def _label(key: str) -> str:
|
|||||||
|
|
||||||
COMPLETED_CHECK_STATUSES = frozenset({
|
COMPLETED_CHECK_STATUSES = frozenset({
|
||||||
"vulnerable", "not-vulnerable", "suspected", "exposed", "observed", "inconclusive",
|
"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())
|
selected = sum(not _skipped(status) for status in host.checks.values())
|
||||||
check_failed = any(status not in COMPLETED_CHECK_STATUSES and not _skipped(status)
|
check_failed = any(status not in COMPLETED_CHECK_STATUSES and not _skipped(status)
|
||||||
for status in host.checks.values())
|
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())
|
and not _skipped(status) for status in host.collection.values())
|
||||||
return completed, selected, check_failed or collection_failed
|
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 ""))
|
lines.append(f" - {_code(_label(key))}: {_code(status)}" + (f" — {_code(detail)}" if detail else ""))
|
||||||
for note in host.notes:
|
for note in host.notes:
|
||||||
lines.append(f" - Note: {_code(note)}")
|
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:
|
for attempt in host.check_attempts:
|
||||||
lines.append(f" - Attempt: {_code(attempt.key)} / {_code(attempt.backend)} / {_code(attempt.status)} — {_code(attempt.evidence)}")
|
lines.append(f" - Attempt: {_code(attempt.key)} / {_code(attempt.backend)} / {_code(attempt.status)} — {_code(attempt.evidence)}")
|
||||||
if host.services:
|
if host.services:
|
||||||
@@ -132,6 +134,15 @@ def markdown(report: Report, include_unreachable: bool = False) -> str:
|
|||||||
if report.errors:
|
if report.errors:
|
||||||
lines.extend(["", "## Collection notes", ""])
|
lines.extend(["", "## Collection notes", ""])
|
||||||
lines.extend(f"- {_code(error)}" for error in report.errors)
|
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"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -166,14 +177,15 @@ def minimal(report: Report, include_unreachable: bool = False) -> str:
|
|||||||
continue
|
continue
|
||||||
identity = report.principal or host.domain
|
identity = report.principal or host.domain
|
||||||
prefix = display(host.address) + (f" ({display(identity)})" if identity else "")
|
prefix = display(host.address) + (f" ({display(identity)})" if identity else "")
|
||||||
positives = [_label(key) for key, status in sorted(host.checks.items()) if status == "vulnerable"]
|
conflicts = host.conflicting_checks()
|
||||||
suspected = [_label(key) for key, status in sorted(host.checks.items()) if status == "suspected"]
|
positives = [_label(key) for key, status in sorted(host.checks.items()) if status == "vulnerable" and key not in conflicts]
|
||||||
exposed = [_label(key) for key, status in sorted(host.checks.items()) if status == "exposed"]
|
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"]
|
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())
|
unknown = [_label(key) for key, status in sorted(host.checks.items())
|
||||||
if status in {"inconclusive", "unknown", "failed", "timeout"}]
|
if status in {"inconclusive", "unknown", "failed", "timeout"}]
|
||||||
preconditions = [("LONGLOGON" if key == "longlogon" else _label(key), status.removeprefix("precondition-").upper())
|
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())
|
skipped = sum(_skipped(status) for status in host.checks.values())
|
||||||
vulnerability_checks = {key: status for key, status in host.checks.items()
|
vulnerability_checks = {key: status for key, status in host.checks.items()
|
||||||
if key not in CHECKS or CHECKS[key].kind == "vulnerability"}
|
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:
|
elif suspected:
|
||||||
verdict = "SUSPECTED: " + " ".join(f"[{item}]" for item in suspected)
|
verdict = "SUSPECTED: " + " ".join(f"[{item}]" for item in suspected)
|
||||||
elif (vulnerability_checks and all(status == "not-vulnerable" for status in vulnerability_checks.values())
|
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)"
|
verdict = "NOT VULNERABLE (SELECTED CHECKS)"
|
||||||
elif pending:
|
elif pending:
|
||||||
verdict = "IN PROGRESS"
|
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)
|
verdict += " SUSPECTED: " + " ".join(f"[{item}]" for item in suspected)
|
||||||
if unknown:
|
if unknown:
|
||||||
verdict += " UNKNOWN: " + " ".join(f"[{item}]" for item in 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:
|
if preconditions:
|
||||||
verdict += " PRECONDITION: " + " ".join(f"[{label}:{status}]" for label, status in preconditions)
|
verdict += " PRECONDITION: " + " ".join(f"[{label}:{status}]" for label, status in preconditions)
|
||||||
exposures = sorted(set(exposed) | {
|
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)
|
verdict += " RPC: " + " ".join(f"[{item}]" for item in registered)
|
||||||
if observed:
|
if observed:
|
||||||
verdict += " INFO: " + " ".join(f"[{item}]" for item in 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:
|
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:
|
if host.services:
|
||||||
verdict += " PORTS: " + ",".join(f"{port}/{status}" for port, status in sorted(host.services.items()))
|
verdict += " PORTS: " + ",".join(f"{port}/{status}" for port, status in sorted(host.services.items()))
|
||||||
if selected:
|
if selected:
|
||||||
|
|||||||
+12
-2
@@ -186,14 +186,24 @@ def evaluate_host(host: HostEvidence) -> list[Finding]:
|
|||||||
recommendation, [reference], confidence="medium",
|
recommendation, [reference], confidence="medium",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
conflicting_checks = host.conflicting_checks()
|
||||||
for check_key, status in host.checks.items():
|
for check_key, status in host.checks.items():
|
||||||
status = status.casefold()
|
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
|
continue
|
||||||
definition = CHECKS.get(check_key)
|
definition = CHECKS.get(check_key)
|
||||||
label = definition.label if definition else check_key.upper()
|
label = definition.label if definition else check_key.upper()
|
||||||
reference = [definition.reference] if definition else []
|
reference = [definition.reference] if definition else []
|
||||||
severity = definition.severity if definition else "high"
|
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"
|
suspected = status == "suspected"
|
||||||
if status.startswith("precondition-"):
|
if status.startswith("precondition-"):
|
||||||
findings.append(_finding(
|
findings.append(_finding(
|
||||||
@@ -206,7 +216,7 @@ def evaluate_host(host: HostEvidence) -> list[Finding]:
|
|||||||
continue
|
continue
|
||||||
if status in {"exposed", "observed"}:
|
if status in {"exposed", "observed"}:
|
||||||
findings.append(_finding(
|
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}",
|
host.check_details.get(check_key) or f"{check_key}: {status}",
|
||||||
definition.note if definition else "The checker returned interface or inventory information.",
|
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.",
|
"Review whether this interface and its network exposure are required; validate authorization and patch policy separately.",
|
||||||
|
|||||||
+56
-6
@@ -17,8 +17,9 @@ from pathlib import Path
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from .catalog import CheckDefinition
|
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 .longlogon import probe_longlogon
|
||||||
|
from .smb_negotiate import probe_smb_compression
|
||||||
from .models import CheckAttempt, HostEvidence
|
from .models import CheckAttempt, HostEvidence
|
||||||
from .netexec import (NxcCapabilities, host_failures, inspect_capabilities, invocation_failed,
|
from .netexec import (NxcCapabilities, host_failures, inspect_capabilities, invocation_failed,
|
||||||
plan_batches, skip_reason, validate_literal_credentials)
|
plan_batches, skip_reason, validate_literal_credentials)
|
||||||
@@ -53,6 +54,7 @@ class ScanConfig:
|
|||||||
out_dir: Path = Path("rpcscope-output")
|
out_dir: Path = Path("rpcscope-output")
|
||||||
commands: list[list[str]] = field(default_factory=list)
|
commands: list[list[str]] = field(default_factory=list)
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
|
timings: list[dict] = field(default_factory=list)
|
||||||
cancelled: bool = False
|
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)]))
|
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,
|
def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None = None,
|
||||||
checkpoint: Callable[[dict[str, HostEvidence]], None] | None = None) -> dict[str, HostEvidence]:
|
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):
|
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))
|
*(["-o", *batch.options] if batch.options else [])], config.password))
|
||||||
for definition in config.checks:
|
for definition in config.checks:
|
||||||
if definition.enabled and definition.backend == "native":
|
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):
|
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),
|
config.commands.append(_redacted([nxc, "smb", address, *_nxc_auth(config),
|
||||||
"-M", definition.nxc_module,
|
"-M", definition.nxc_module,
|
||||||
@@ -272,6 +283,7 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
|
|||||||
raw.mkdir(mode=0o700)
|
raw.mkdir(mode=0o700)
|
||||||
cancel = threading.Event()
|
cancel = threading.Event()
|
||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
|
disabled_collectors: dict[str, str] = {}
|
||||||
total = len(scope) + 1 if config.tcp_discovery else 1
|
total = len(scope) + 1 if config.tcp_discovery else 1
|
||||||
completed = 0
|
completed = 0
|
||||||
|
|
||||||
@@ -310,9 +322,12 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
|
|||||||
with lock:
|
with lock:
|
||||||
if command != discovery:
|
if command != discovery:
|
||||||
config.commands.append(_redacted(command, config.password))
|
config.commands.append(_redacted(command, config.password))
|
||||||
|
started = time.monotonic()
|
||||||
code, text = _run(command, timeout, path, config.password, cancel=cancel)
|
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:
|
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
|
return code, text
|
||||||
|
|
||||||
discovery_start = time.monotonic()
|
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):
|
and not cancel.is_set() and not discovery_failed):
|
||||||
error("TCP discovery deadline reached; unprobed targets remain incomplete")
|
error("TCP discovery deadline reached; unprobed targets remain incomplete")
|
||||||
rc = 124
|
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",
|
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)")
|
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)")
|
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"
|
path = raw / f"nxc-{index // 128 + 1:03d}.txt"
|
||||||
# One discovery stage may have several command-line-sized batches.
|
# One discovery stage may have several command-line-sized batches.
|
||||||
config.commands.append(_redacted(command, config.password))
|
config.commands.append(_redacted(command, config.password))
|
||||||
|
started = time.monotonic()
|
||||||
code, output = _run(command, remaining, path, config.password, cancel=cancel)
|
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)):
|
if not code and invocation_failed(output, tuple(batch)):
|
||||||
code = 1
|
code = 1
|
||||||
error(f"SMB discovery invocation reported an error; see {path.name}")
|
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)
|
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:
|
def collector(stage: str, tool: str, action: Callable[[], str], ports: tuple[int, ...]) -> None:
|
||||||
|
with lock:
|
||||||
|
disabled = disabled_collectors.get(stage)
|
||||||
if cancel.is_set():
|
if cancel.is_set():
|
||||||
status = "skipped-cancelled"
|
status = "skipped-cancelled"
|
||||||
elif unavailable(ports):
|
elif unavailable(ports):
|
||||||
status = "skipped-service-unavailable"
|
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]:
|
elif not tools[tool]:
|
||||||
status = "missing"
|
status = "missing"
|
||||||
else:
|
else:
|
||||||
@@ -559,7 +582,29 @@ def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None
|
|||||||
# also prevents rpcdump from prompting for a legitimate blank password.
|
# also prevents rpcdump from prompting for a legitimate blank password.
|
||||||
command = [str(tools["rpcdump"]), "-port", "135", address]
|
command = [str(tools["rpcdump"]), "-port", "135", address]
|
||||||
code, text = run("rpcdump", address, command, path, config.timeout)
|
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"
|
return "success" if code == 0 else "failed"
|
||||||
|
|
||||||
def store_check(key: str, outcome: CheckResult, source: Path, backend: str = "netexec") -> None:
|
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":
|
elif definition.backend == "native":
|
||||||
emit("start", definition.key, address)
|
emit("start", definition.key, address)
|
||||||
with lock:
|
with lock:
|
||||||
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
|
||||||
outcome = probe_longlogon(address, timeout=min(3.0, config.timeout / 2), cancel=cancel)
|
["<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))
|
atomic_text(path, redact_text(f"{outcome.status}\n{outcome.evidence}\n", config.password))
|
||||||
else:
|
else:
|
||||||
command = [*nxc_command([address], 1), "-M", definition.nxc_module]
|
command = [*nxc_command([address], 1), "-M", definition.nxc_module]
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -29,6 +29,64 @@ def alive(addresses, on_result, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
class BatchExecutionTests(unittest.TestCase):
|
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):
|
def test_duplicate_api_checks_are_executed_and_counted_once(self):
|
||||||
definition = resolve_checks(["ms17-010"])[0]
|
definition = resolve_checks(["ms17-010"])[0]
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
@@ -84,7 +142,7 @@ class BatchExecutionTests(unittest.TestCase):
|
|||||||
self.assertEqual(hosts["192.0.2.1"].checks, {})
|
self.assertEqual(hosts["192.0.2.1"].checks, {})
|
||||||
self.assertTrue(any("failed locally" in message for message in config.errors))
|
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)]
|
addresses = [f"192.0.2.{i}" for i in range(1, 33)]
|
||||||
commands = []
|
commands = []
|
||||||
def execute(command, timeout, log, password, **kwargs):
|
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.inspect_capabilities", return_value=CURRENT), \
|
||||||
patch("rpcscope.runner.discover_services", side_effect=alive), \
|
patch("rpcscope.runner.discover_services", side_effect=alive), \
|
||||||
patch("rpcscope.runner._run", side_effect=execute), \
|
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)))
|
hosts = scan(config, progress=events.append, checkpoint=lambda value: snapshots.append(len(value)))
|
||||||
check_commands = [command for command in commands if "-M" in command]
|
check_commands = [command for command in commands if "-M" in command]
|
||||||
self.assertEqual(len(check_commands), 28)
|
self.assertEqual(len(check_commands), 24)
|
||||||
self.assertEqual(len(commands), 29) # one separate inventory invocation
|
self.assertEqual(len(commands), 25) # one separate inventory invocation
|
||||||
self.assertEqual(len(hosts), 32)
|
self.assertEqual(len(hosts), 32)
|
||||||
self.assertGreater(len(snapshots), 1)
|
self.assertGreater(len(snapshots), 1)
|
||||||
for host in hosts.values():
|
for host in hosts.values():
|
||||||
@@ -130,7 +189,7 @@ class BatchExecutionTests(unittest.TestCase):
|
|||||||
self.assertEqual(coverage(host), (12, 12, False))
|
self.assertEqual(coverage(host), (12, 12, False))
|
||||||
self.assertEqual(host.checks["zerologon"], "inconclusive")
|
self.assertEqual(host.checks["zerologon"], "inconclusive")
|
||||||
self.assertEqual(host.checks["ms17-010"], "not-vulnerable")
|
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.assertEqual(host.checks["oxid"], "observed")
|
||||||
self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS))
|
self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS))
|
||||||
self.assertEqual(config.errors, [])
|
self.assertEqual(config.errors, [])
|
||||||
|
|||||||
+25
-4
@@ -50,11 +50,33 @@ class CheckerOutputTests(unittest.TestCase):
|
|||||||
) + "\x1b[0m"
|
) + "\x1b[0m"
|
||||||
self.assertEqual(classify_nxc("printnightmare", HOST, text).status, "suspected")
|
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(
|
text = nxc_line(
|
||||||
"SMBGHOST", "[+] Potentially vulnerable to SMBGhost (CVE-2020-0796)",
|
"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):
|
def test_nonzero_exit_fails_closed_even_with_result_markers(self):
|
||||||
for message in ("[+] VULNERABLE", f"[-] {HOST} does NOT appear vulnerable"):
|
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):
|
def test_silence_and_upstream_errors_remain_inconclusive(self):
|
||||||
cases = [
|
cases = [
|
||||||
("ms17-010", "MS17-010", ""),
|
("ms17-010", "MS17-010", ""),
|
||||||
("zerologon", "ZEROLOGON", "[+] Attack failed. Target is probably patched."),
|
("zerologon", "ZEROLOGON", "[-] Error while connecting to host: DCERPCException, which means this is probably not a DC!"),
|
||||||
("printnightmare", "PRINTNIGHTMARE", "[-] Failed to bind: access denied"),
|
|
||||||
("smbghost", "SMBGHOST", "[-] Error while connecting to host: timed out"),
|
("smbghost", "SMBGHOST", "[-] Error while connecting to host: timed out"),
|
||||||
]
|
]
|
||||||
for key, module, message in cases:
|
for key, module, message in cases:
|
||||||
|
|||||||
+7
-4
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from rpcscope.cli import main
|
from rpcscope.cli import main
|
||||||
|
from rpcscope.checks import CheckResult
|
||||||
|
|
||||||
|
|
||||||
TOOLS = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None}
|
TOOLS = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None}
|
||||||
@@ -25,7 +26,8 @@ class CliTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
self.assertFalse(destination.exists())
|
self.assertFalse(destination.exists())
|
||||||
self.assertEqual(any(command[0] == "enum4linux-ng" for command in commands), deep)
|
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))
|
self.assertTrue(any(command[0] == "<native:longlogon>" for command in commands))
|
||||||
|
|
||||||
def test_tcp_share_dry_run_shows_gating_without_guessing_live_capabilities(self):
|
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), \
|
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \
|
||||||
contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics):
|
contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics):
|
||||||
code = main(["scan", "192.0.2.0/30", "--checks", "ms17-010", "--authorised",
|
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.assertEqual(code, 0)
|
||||||
self.assertIn("[3/3] SUMMARY scan complete", diagnostics.getvalue())
|
self.assertIn("[3/3] SUMMARY scan complete", diagnostics.getvalue())
|
||||||
self.assertIn("VULN: [MS17-010]", captured.getvalue())
|
self.assertIn("VULN: [MS17-010]", captured.getvalue())
|
||||||
@@ -97,15 +99,16 @@ class CliTests(unittest.TestCase):
|
|||||||
def execute(command, timeout, log, password, **kwargs):
|
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)")
|
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), \
|
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):
|
contextlib.redirect_stdout(output), contextlib.redirect_stderr(diagnostics):
|
||||||
code = main(["scan", "192.0.2.1", "--checks", "smbghost", "--discovery", "smb", "--authorised",
|
code = main(["scan", "192.0.2.1", "--checks", "smbghost", "--discovery", "smb", "--authorised",
|
||||||
"--no-enum4linux", "--no-rpcdump", "-o", str(Path(temporary) / "out")])
|
"--no-enum4linux", "--no-rpcdump", "-o", str(Path(temporary) / "out")])
|
||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
self.assertIn("UNKNOWN:", output.getvalue())
|
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("PARTIAL", output.getvalue())
|
||||||
self.assertNotIn("NOT VULNERABLE", 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):
|
def test_dry_run_has_no_files_and_keeps_json_on_stdout(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -101,7 +101,7 @@ class DiscoveryIntegrationTests(unittest.TestCase):
|
|||||||
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
|
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
|
||||||
self.assertEqual([host["address"] for host in report["hosts"]], [SMB_ONLY])
|
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("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):
|
def test_review_catalog_is_not_an_executable_check(self):
|
||||||
captured = io.StringIO()
|
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):
|
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)])
|
code = main(["scan", RPC_ONLY, "--checks", "recommended", "--authorised", "-o", str(output)])
|
||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
self.assertIn("SUMMARY scan complete", diagnostics.getvalue())
|
self.assertIn("COMPLETE |", diagnostics.getvalue())
|
||||||
self.assertNotIn(RPC_ONLY, 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())
|
report = json.loads((output / "report.json").read_text())
|
||||||
self.assertEqual(report["hosts"][0]["checks"], {})
|
self.assertEqual(report["hosts"][0]["checks"], {})
|
||||||
self.assertEqual(report["hosts"][0]["check_attempts"], [])
|
self.assertEqual(report["hosts"][0]["check_attempts"], [])
|
||||||
@@ -163,9 +163,12 @@ class DiscoveryIntegrationTests(unittest.TestCase):
|
|||||||
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["smbghost"]])
|
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["smbghost"]])
|
||||||
events = []
|
events = []
|
||||||
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=probe), \
|
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:
|
patch("rpcscope.runner._run", side_effect=run) as execute:
|
||||||
hosts = scan(config, progress=events.append)
|
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(hosts[dead].checks, {})
|
||||||
self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False))
|
self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False))
|
||||||
self.assertEqual(events[-1].status, "complete")
|
self.assertEqual(events[-1].status, "complete")
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ class ModelTests(unittest.TestCase):
|
|||||||
collection={"rpcdump": "success"}))
|
collection={"rpcdump": "success"}))
|
||||||
self.assertEqual(host.checks["check"], "suspected")
|
self.assertEqual(host.checks["check"], "suspected")
|
||||||
self.assertEqual(host.collection["rpcdump"], "success")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class NetExecTests(unittest.TestCase):
|
|||||||
for i in range(1, 33)}
|
for i in range(1, 33)}
|
||||||
batches, skipped = plan_batches(resolve_checks(["recommended"]), hosts, jobs=8,
|
batches, skipped = plan_batches(resolve_checks(["recommended"]), hosts, jobs=8,
|
||||||
tcp_discovery=True, has_nxc=True, username="")
|
tcp_discovery=True, has_nxc=True, username="")
|
||||||
self.assertEqual(len(batches), 28)
|
self.assertEqual(len(batches), 24)
|
||||||
self.assertEqual(skipped, [])
|
self.assertEqual(skipped, [])
|
||||||
self.assertTrue(all(len(batch.targets) == 8 for batch in batches))
|
self.assertTrue(all(len(batch.targets) == 8 for batch in batches))
|
||||||
bind_batches = [batch for batch in batches if batch.module == "coerce_plus"]
|
bind_batches = [batch for batch in batches if batch.module == "coerce_plus"]
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class ProgressTests(unittest.TestCase):
|
|||||||
def test_tty_dashboard_retains_completed_result(self):
|
def test_tty_dashboard_retains_completed_result(self):
|
||||||
output = Terminal()
|
output = Terminal()
|
||||||
clock = Clock()
|
clock = Clock()
|
||||||
reporter = ProgressReporter(output, clock=clock)
|
reporter = ProgressReporter(output, clock=clock, color="never")
|
||||||
reporter(ProgressEvent("start", "check", "10.0.0.1", total=1))
|
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())
|
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))
|
reporter(ProgressEvent("finish", "check", "10.0.0.1", "inconclusive", 1, 1))
|
||||||
|
|||||||
+14
-1
@@ -8,6 +8,19 @@ class MinimalReportTests(unittest.TestCase):
|
|||||||
def report(self, host, findings=None, principal=None):
|
def report(self, host, findings=None, principal=None):
|
||||||
return Report("now", "test", [], None, principal, [host], findings or [])
|
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):
|
def test_positive_vulnerability_line(self):
|
||||||
host = HostEvidence("10.10.10.25", checks={"zerologon": "vulnerable"})
|
host = HostEvidence("10.10.10.25", checks={"zerologon": "vulnerable"})
|
||||||
line = minimal(self.report(host, principal="LAB\\auditor"))
|
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):
|
def test_suspected_finishes_a_check_while_pending_remains_in_progress(self):
|
||||||
host = HostEvidence("10.10.10.25", checks={"smbghost": "suspected", "ms17-010": "pending"})
|
host = HostEvidence("10.10.10.25", checks={"smbghost": "suspected", "ms17-010": "pending"})
|
||||||
line = minimal(self.report(host))
|
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.assertIn("CHECKS: 1/2 (IN PROGRESS)", line)
|
||||||
self.assertNotIn("NO VULNERABILITIES CONFIRMED", line)
|
self.assertNotIn("NO VULNERABILITIES CONFIRMED", line)
|
||||||
self.assertNotIn(" VULN:", line)
|
self.assertNotIn(" VULN:", line)
|
||||||
|
|||||||
+9
-1
@@ -5,6 +5,14 @@ from rpcscope.rules import evaluate_host
|
|||||||
|
|
||||||
|
|
||||||
class RuleTests(unittest.TestCase):
|
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):
|
def test_rules_separate_exposure_from_confirmed_vulnerability(self):
|
||||||
host = HostEvidence(
|
host = HostEvidence(
|
||||||
address="10.10.10.25",
|
address="10.10.10.25",
|
||||||
@@ -78,7 +86,7 @@ class RuleTests(unittest.TestCase):
|
|||||||
self.assertEqual(finding.confidence, "low")
|
self.assertEqual(finding.confidence, "low")
|
||||||
self.assertIn("requires validation", finding.title)
|
self.assertIn("requires validation", finding.title)
|
||||||
self.assertEqual(finding.evidence, host.check_details["smbghost"])
|
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):
|
def test_checker_positive_includes_actual_evidence(self):
|
||||||
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
|
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user