diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a5e4acf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,73 @@ +name: Bug report +description: Report reproducible incorrect behavior, a crash, or an evidence-quality problem. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Remove secrets, account names, private addresses, target evidence, and full local paths before submitting. Report security vulnerabilities through the private security-policy link instead. + - type: input + id: version + attributes: + label: PortCVE version + description: Paste the complete output of `portcve version`. + placeholder: portcve 0.2.0-alpha.1+... + validations: + required: true + - type: dropdown + id: install + attributes: + label: Installation method + options: + - Signed managed installer + - Signed portable ZIP + - Built from source + - Other + validations: + required: true + - type: input + id: windows + attributes: + label: Windows version and architecture + placeholder: Windows 11 24H2, x64 + validations: + required: true + - type: textarea + id: command + attributes: + label: Exact command + description: Redact targets and paths where needed. Never include API keys or environment values. + render: powershell + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior and exit code + description: Include the exit code and sanitized stderr. Attach default-redacted JSON only after reviewing it. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Explain the smallest authorized/local setup that reproduces the issue. + validations: + required: true + - type: checkboxes + id: safety + attributes: + label: Safety confirmation + options: + - label: I removed credentials, secrets, private target evidence, account names, and unnecessary local paths. + required: true + - label: This is not a confidential security vulnerability requiring private disclosure. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..24f92fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability privately + url: https://github.com/Labeeb2339/PortCVE/security/policy + about: Do not disclose exploitable vulnerabilities, secrets, or sensitive target evidence in a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..48c6c6d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Propose a bounded defensive workflow or evidence improvement. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Operational problem + description: Describe the analyst or administrator task, not only the proposed implementation. + validations: + required: true + - type: textarea + id: workflow + attributes: + label: Desired command and workflow + description: Include an example command, expected evidence, and automation exit behavior. + render: powershell + validations: + required: true + - type: textarea + id: boundaries + attributes: + label: Safety, privacy, and authorization boundaries + description: Explain network activity, privileges, sensitive data, destructive actions, and likely false claims. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Existing tools or alternatives + description: Name current tools and explain the specific gap PortCVE would fill. + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e7f9b21 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Purpose + +Describe the user-visible problem and the evidence contract this change affects. + +## Safety and compatibility + +- [ ] No command gained implicit network access, privilege escalation, destructive behavior, or a weaker authorization gate. +- [ ] Privacy-reduced output and `--include-private` behavior were reviewed. +- [ ] Incomplete evidence still fails closed for `--strict` and finding gates. +- [ ] Versioned JSON/schema or CLI compatibility changes are documented and tested. +- [ ] New third-party actions and dependencies are pinned and justified. + +## Verification + +- [ ] `dotnet restore PortCVE.sln --locked-mode` +- [ ] `dotnet format PortCVE.sln --verify-no-changes --no-restore` +- [ ] `dotnet build PortCVE.sln -c Release --no-restore` +- [ ] `dotnet test PortCVE.sln -c Release --no-build --no-restore` +- [ ] Relevant PowerShell/live harnesses passed, or the omission is explained below. + +Sanitized evidence and omitted gates: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d55f1ec..ae20c66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,13 @@ permissions: jobs: windows: - runs-on: windows-latest + name: windows-${{ matrix.os }} + runs-on: ${{ matrix.os }} timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 @@ -50,15 +55,23 @@ jobs: $json = ./artifacts/win-x64/portcve.exe snapshot --no-firewall 2>$null | ConvertFrom-Json if ($json.schema_version -ne 1) { throw 'Unexpected snapshot schema.' } + - name: Verify authorized remote loopback workflow + shell: powershell + run: ./scripts/Test-RemoteHostIntegration.ps1 -SkipBuild -PortCVEPath ./artifacts/win-x64/portcve.exe + + - name: Verify daily-workflow performance budgets + shell: powershell + run: ./scripts/Test-Performance.ps1 -PortCVEPath ./artifacts/win-x64/portcve.exe -LocalIterations 5 -RemotePortCount 256 -EnforceBudgets + - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: test-results + name: test-results-${{ matrix.os }} path: TestResults/ - name: Upload smoke artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: portcve-win-x64 + name: portcve-win-x64-${{ matrix.os }} path: artifacts/win-x64/portcve.exe diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..8f2a171 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,47 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + csharp: + name: csharp-windows-2025 + runs-on: windows-2025 + timeout-minutes: 20 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.101 + + - name: Initialize CodeQL + uses: github/codeql-action/init@c4dd10e44af883a891fe31ced449bcb4a6728b9b # v3.37.6 + with: + languages: csharp + build-mode: manual + + - name: Restore locked dependencies + run: dotnet restore PortCVE.sln --locked-mode + + - name: Build for analysis + run: dotnet build PortCVE.sln -c Release --no-restore + + - name: Analyze + uses: github/codeql-action/analyze@c4dd10e44af883a891fe31ced449bcb4a6728b9b # v3.37.6 + with: + category: /language:csharp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddd63f3..a55e216 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,10 @@ jobs: - name: Test run: dotnet test PortCVE.sln -c Release --no-build --logger "trx;LogFileName=release-tests.trx" --results-directory TestResults + - name: Test installer lifecycle under Windows PowerShell 5.1 + shell: powershell + run: .\scripts\tests\Test-Installer.ps1 + - name: Publish unsigned candidate run: dotnet publish src/PortCVE/PortCVE.csproj -c Release -r win-x64 --self-contained true --no-build --no-restore -o artifacts/unsigned @@ -137,10 +141,6 @@ jobs: permissions: contents: read env: - ES_USERNAME: ${{ secrets.ES_USERNAME }} - ES_PASSWORD: ${{ secrets.ES_PASSWORD }} - CREDENTIAL_ID: ${{ secrets.CREDENTIAL_ID }} - ES_TOTP_SECRET: ${{ secrets.ES_TOTP_SECRET }} EXPECTED_SIGNER_SUBJECT: ${{ vars.EXPECTED_SIGNER_SUBJECT }} steps: @@ -158,6 +158,11 @@ jobs: - name: Fail closed on missing signing configuration shell: pwsh + env: + ES_USERNAME: ${{ secrets.ES_USERNAME }} + ES_PASSWORD: ${{ secrets.ES_PASSWORD }} + CREDENTIAL_ID: ${{ secrets.CREDENTIAL_ID }} + ES_TOTP_SECRET: ${{ secrets.ES_TOTP_SECRET }} run: | $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest @@ -433,11 +438,43 @@ jobs: Copy-Item -LiteralPath schema -Destination (Join-Path $packageRoot 'schema') -Recurse $zipName = "portcve-$($env:RELEASE_TAG)-win-x64.zip" - Compress-Archive -Path (Join-Path $packageRoot '*') -DestinationPath (Join-Path $releaseRoot $zipName) + $zipPath = Join-Path $releaseRoot $zipName + Compress-Archive -Path (Join-Path $packageRoot '*') -DestinationPath $zipPath Copy-Item -LiteralPath artifacts/signed/portcve.exe -Destination $releaseRoot Copy-Item -LiteralPath artifacts/signed/install.ps1 -Destination $releaseRoot Copy-Item -LiteralPath artifacts/signed/SIGNING-METADATA.json -Destination $releaseRoot + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($zipPath) + try { + $zipExecutables = @($archive.Entries | Where-Object { + [StringComparer]::Ordinal.Equals($_.FullName.Replace('\', '/'), 'portcve.exe') + }) + if ($zipExecutables.Count -ne 1) { + throw "Portable ZIP must contain exactly one root portcve.exe; found $($zipExecutables.Count)." + } + $zipStream = $zipExecutables[0].Open() + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $zipExecutableHash = ([BitConverter]::ToString($sha256.ComputeHash($zipStream))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + $zipStream.Dispose() + } + } + finally { + $archive.Dispose() + } + + $signedExecutableHash = (Get-FileHash -LiteralPath artifacts/signed/portcve.exe -Algorithm SHA256).Hash.ToLowerInvariant() + $signingMetadata = Get-Content -LiteralPath artifacts/signed/SIGNING-METADATA.json -Raw | ConvertFrom-Json + if (-not [StringComparer]::Ordinal.Equals($zipExecutableHash, $signedExecutableHash) -or + -not [StringComparer]::Ordinal.Equals([string]$signingMetadata.artifact.sha256, $signedExecutableHash)) { + throw 'Portable ZIP, standalone executable, and signing metadata do not contain the exact same signed executable.' + } + $checksumTargets = @(Get-ChildItem -LiteralPath $releaseRoot -File | Sort-Object Name) $checksumLines = @($checksumTargets | ForEach-Object { $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() diff --git a/CHANGELOG.md b/CHANGELOG.md index b27c62c..e52c2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,18 @@ All notable changes will be documented here. The project follows semantic versio - Renamed the project, executable, namespaces, schemas, scripts, and release artifacts from BindWitness (`bindwitness`) to PortCVE (`portcve`); no behavior changed as part of the rename. - Added `scan` for offline known-advisory matching against immutable local Docker image IDs and explicit local SBOMs, with a versioned JSON schema, redaction, database-freshness evidence, and `--strict`/`--fail-on` exit gates. +- Added explicit `db status` and `db update` commands for an externally installed, locally validated Trivy executable; scans remain offline and never install or update the engine or advisory database implicitly. - Hardened the Trivy boundary with local non-reparse cache/SBOM/temp validation, inherited environment scrubbing, strict result parsing, bounded process termination, and guarded cleanup. +- Added `scan-host` for explicitly authorized, rate-limited TCP host/CIDR discovery, protocol-bound greeting/HTTP/TLS fingerprinting, privacy-reduced remote JSON, and bounded safe-active HTTP/TLS posture checks. +- Added exact catalog-backed banner identities for Dropbear SSH, ProFTPD, vsftpd, and Exim, while retaining unresolved results for headers, ports, ambiguous banners, and unsupported versions. +- Added explicit-online, catalog-backed NVD correlation with provenance-bound identities, preserved applicability conditions and enrichment status, candidate-only wording, and process-wide rate limiting. +- Added import-only Nmap XML and Nuclei JSONL normalization with local non-reparse inputs, bounded parsers, source hashing, versioned JSON, and no scanner/template execution. - Added a file-backed, self-verifying PowerShell installer template and a fail-closed release workflow that signs and independently verifies both `portcve.exe` and `install.ps1`. +- Added receipt-bound managed update, exact-version rollback, offline uninstall, guarded user-`PATH` changes, and transactional restoration tests; portable release ZIPs remain side-effect free. - Added cryptographic RFC 3161 token decoding, signer-info imprint binding, trusted TSA matching, full-SHA GitHub Actions pinning, release checksums, metadata, and provenance attestation. -- Live-validated Docker TCP/UDP correlation and the offline vulnerability path; see `docs/validation.md` for dated evidence and claim boundaries. +- Expanded CI to fresh Windows Server 2022 and 2025 runners with live loopback remote-assessment and enforceable performance budgets, plus local socket-churn and Docker-forwarding validation. +- Added a daily-use runbook and structured privacy-aware issue and pull-request templates for expert feedback. +- Live-validated Docker TCP/UDP correlation, the offline vulnerability path, and authorized adaptive HTTP discovery on a random loopback port; see `docs/validation.md` and `docs/remote-live-validation.md` for dated evidence and claim boundaries. ## 0.1.0-alpha.1 - 2026-08-09 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14a70ab..2f7ab43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,23 +17,28 @@ dotnet restore PortCVE.sln --locked-mode dotnet format PortCVE.sln --verify-no-changes --no-restore dotnet build PortCVE.sln -c Release --no-restore dotnet test PortCVE.sln -c Release --no-build +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\tests\Test-Installer.ps1 ``` NuGet lockfiles are committed. Keep them synchronized with intentional package changes; `--locked-mode` should fail unexpected dependency-resolution drift. When changing native collection, also compare a live fixture against structured `Get-NetTCPConnection -State Listen` and `Get-NetUDPEndpoint` output. Do not use localized `netstat` text as a parser or test oracle. -For Docker correlation changes, build Release and run `scripts\Test-DockerIntegration.ps1 -ValidateLockCheck`. The script may pull `alpine:3.22` and creates/removes a labeled test container. Its default publications are loopback-only; `-AllowWildcardUdp` intentionally exposes the UDP echo fixture on `0.0.0.0` for the duration of the test. +For Docker correlation changes, build Release and run `scripts\Test-DockerIntegration.ps1 -ValidateLockCheck -ValidateRemoteScan`. The script may pull `alpine:3.22` and creates/removes a labeled test container. Its default publications are loopback-only; `-AllowWildcardUdp` intentionally exposes the UDP echo fixture on `0.0.0.0` for the duration of the test. + +For remote-scanner changes, run the loopback-only `scripts\Test-RemoteHostIntegration.ps1` harness. Never use a public, third-party, or local-network target as a release test without explicit authorization from its owner. Run `scripts\Test-Performance.ps1 -EnforceBudgets` when changing collection, planning, concurrency, or serialization paths. When changing firewall reasoning, add tests for both the intended match and a near-miss. An unavailable or unsupported predicate must reduce confidence; it must not silently become `allow` or `block`. ## Design rules -- Keep v1 read-only. +- Keep local collection and all assessment workflows non-destructive. +- Require explicit authorization for active network assessment, preserve rate/concurrency/time/evidence caps, and never add an unlimited mode. - Preserve JSON stdout; diagnostics belong on stderr. - Never collect process environment variables. - Do not add command-line collection without a separate privacy design and explicit opt-in. -- Keep external reachability `unknown` unless a future external verifier actually tests it. +- Keep local listener reachability conservative. A successful `scan-host` TCP connection proves only that exact tested path and observation time, not Internet-wide reachability or exploitability. +- Do not infer CPEs or vulnerability matches from a port number, filename, or untrusted HTTP header. - A collector failure is evidence degradation, not proof that an endpoint disappeared. - Add or update schema fixtures for compatibility changes. - Isolate Windows interop from correlation and diff logic. diff --git a/README.md b/README.md index 1e9d30f..c9acd11 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # PortCVE -**Explain local ports. Check what backs them. Lock what you expect.** +**Audit local listeners. Fingerprint authorized remote services. Correlate evidence to CVEs.** -PortCVE is a read-only Windows CLI that connects the facts other port tools leave separate: +PortCVE is a non-destructive Windows CLI that connects the facts other port tools leave separate: - which TCP listeners and UDP endpoints exist; - which process or Windows service owns each bind; - which local Docker Engine publication maps a container port to that observed host bind; - which known vulnerability advisories match packages in an exactly identified local Docker image or explicitly supplied SBOM; +- which TCP services are reachable on an explicitly authorized host or IPv4 CIDR, with bounded HTTP/TLS/greeting evidence; +- which strong remote product/version fingerprints map to direct, conditional, or inconclusive NVD CVE candidates; +- how to normalize existing Nmap XML and Nuclei JSONL evidence without executing either scanner; - whether the bind is loopback-only, interface-specific, or wildcard; - which active interfaces and network profiles it covers; - what a static evaluation of the merged Windows Firewall policy suggests; and @@ -15,7 +18,7 @@ PortCVE is a read-only Windows CLI that connects the facts other port tools leav PortCVE does not call a wildcard bind “Internet exposed” or an advisory match “exploitable.” It reports observed host facts, known-advisory evidence, confidence, and limitations separately. -> Status: `0.1.0-alpha.1`. Windows x64 is the only supported release target today. The CLI and JSON schemas can still change before `1.0`. +> Status: `0.2.0-alpha.1` under development. Windows x64 is the only supported release target today. The CLI and JSON schemas can still change before `1.0`. > > Naming status: **PortCVE is the current project and CLI name.** The `v0.1.0-alpha.1` release was originally published as **BindWitness**; that historical artifact remains a BindWitness build. Exact PortCVE name checks on 2026-08-09 found no repository or package collision across GitHub, PyPI, npm, crates.io, or NuGet. This screening is not formal trademark clearance. @@ -25,7 +28,7 @@ PortCVE does not call a wildcard bind “Internet exposed” or an advisory matc > What opened this port, where can it receive traffic, what does the host firewall say, do its exact packages match known advisories, and is this new? -It is designed for developers, defenders, incident responders, lab machines, and Windows hardening checks—not remote scanning. +It is designed for developers, defenders, incident responders, authorized pentesters, lab machines, and Windows hardening checks. Remote assessment is explicit, rate-limited, non-authenticated, and non-destructive; PortCVE is not an exploit framework. ## Quick demo @@ -89,21 +92,31 @@ The offline scan path was exercised on 2026-08-09 with official Trivy `v0.73.0`, The same run validated default/private redaction, Draft 2020-12 schema conformance, hostile inherited `TRIVY_*` scrubbing, zero image pulls, and per-scan temp cleanup. These results prove the tested local correlation, parsing, policy, and exit-code paths—not that every finding is reachable or exploitable. Exact hashes, representative findings, and claim boundaries are recorded in [docs/validation.md](docs/validation.md). +### Live remote validation + +The authorized remote path was exercised on 2026-08-09 against disposable listeners bound only to `127.0.0.1`. PortCVE observed OpenSSH `9.6p1` and a silent Apache HTTP Server `2.4.58` fixture on two OS-assigned high ports. Discovery sent zero bytes to the unknown HTTP service and did not guess its identity. `--active` then identified it through exactly one fresh `HEAD /` request with evidence source `active-adaptive-http-head`; no other method or path was observed. Default/private redaction, schema-compatible output, timeouts, process cleanup, listener cleanup, and temporary-file cleanup passed. + +This proves the tested loopback discovery and adaptive-HTTP path—not authorization for another target, external reachability, adaptive TLS, CVE applicability, or exploitability. The reproducible harness and exact claim boundary are in [docs/remote-live-validation.md](docs/remote-live-validation.md). + ## Install ### Signed installer -For finalized signed releases, download, checksum, Authenticode-verify, inspect, and run the release's file-backed `install.ps1`. It refuses piped or in-memory execution, verifies its own signer before network or filesystem activity, installs without administrator rights to `%LOCALAPPDATA%\Programs\PortCVE`, verifies the versioned ZIP and signed executable, and updates the user `PATH` with rollback protection. See the complete [installer instructions and trust checks](docs/install.md). +For finalized signed releases, download, checksum, Authenticode-verify, inspect, and run the release's file-backed `install.ps1`. It refuses piped or in-memory execution, verifies its own signer before network or filesystem activity, installs without administrator rights to `%LOCALAPPDATA%\Programs\PortCVE`, verifies the versioned ZIP and signed executable, keeps a verified signed installer copy for maintenance, and updates the user `PATH` transactionally. Running the installed signed file again updates PortCVE; `-Version ` selects an exact signed release; and `-Uninstall` removes only a receipt-bound installation and its exact user `PATH` entry without making a network request. See the complete [install, update, rollback, uninstall, and trust instructions](docs/install.md). The checked-in `scripts/install.ps1` is an unsigned, unfinalized template and deliberately refuses to run. Production installation requires the separately downloaded and signed release asset; pipe-to-execution installation is refused. -The installer never permits an unsigned production install. The historical `v0.1.0-alpha.1` release is unsigned and is intentionally rejected. +The installer never permits an unsigned production install. The historical `v0.1.0-alpha.1` BindWitness-era release is unsigned and is intentionally rejected. + +> Availability: no finalized signed PortCVE release exists yet. Current users must build from source; the managed installer and portable signed-release commands apply only after the first verified signed release is published. + +### Portable release ZIP -### Manual release binary +Download `portcve--win-x64.zip` from [PortCVE Releases](https://github.com/Labeeb2339/PortCVE/releases), verify its exact entry in `SHA256SUMS.txt`, extract it, and verify the embedded `portcve.exe` Authenticode signature before use. The portable ZIP does not change `PATH` and has no managed update or uninstall state. -Download the Windows x64 ZIP from the repository's Releases page, verify its SHA-256 file, extract `portcve.exe`, and place it somewhere on your `PATH`. +Release binaries are self-contained; the .NET runtime is not required. Do not treat a checksum alone as a substitute for the required signature on finalized releases. -Release binaries are self-contained; the .NET runtime is not required. Alpha binaries are not yet code-signed, so verify checksums before running them. +After installation, follow the concise [daily-use workflow](docs/daily-use.md) for readiness checks, baselines, authorized assessments, and evidence handling. ### Build from source @@ -127,6 +140,10 @@ portcve list --process node.exe # filter by process or service portcve snapshot --json # full versioned evidence document portcve scan tcp:8080 --strict # offline advisory matches for one exact listener portcve scan --all --fail-on high # deduplicated Docker-image scan and CI gate +portcve scan-host 10.0.0.5 --authorized # bounded remote TCP discovery and fingerprinting +portcve scan-host app.test --ports 22,443 --authorized --online-advisories +portcve import nmap .\scan.xml # normalize existing Nmap XML evidence +portcve import nuclei .\findings.jsonl # normalize existing Nuclei JSONL evidence portcve lock -o listeners.lock.json # normalized baseline, no PID or raw args portcve lock --include-udp # opt into noisier UDP baseline tracking portcve diff listeners.lock.json # report all current drift @@ -172,13 +189,14 @@ Container evidence has its own completeness field. If the local Docker Engine re ## Evidence model -PortCVE keeps five layers separate: +PortCVE keeps six layers separate: 1. **Observed bind:** address, port, protocol, owning PID, executable/service, and bind scope. 2. **Local runtime correlation:** Docker Engine published-port metadata joined to an observed bind by protocol, host address, and host port, always with medium confidence and a tuple-correlation limitation. 3. **Static host-policy inference:** merged Windows Firewall profiles and matching inbound-rule evidence, with `allow`, `block`, `mixed`, or `unknown` plus confidence and limitations. 4. **Known-advisory match:** Trivy results for an immutable local Docker image ID or explicit local SBOM, with database identity/freshness and no exploitability claim. -5. **External path:** always `not tested` in the current release. +5. **Authorized remote observation:** a successful TCP connection, protocol greeting, HTTP response, or TLS handshake observed by `scan-host`, with bounded evidence and no exploitability claim. +6. **Remote advisory candidate:** a strong protocol-banner identity mapped through the verified catalog and NVD configuration evidence. Conditional and inconclusive applicability remain separate from direct candidates. `UNKNOWN` is a valid result. Missing permissions, unsupported firewall constraints, process churn, WSL, third-party WFP filters, IPsec, and upstream network controls are not converted into false certainty. A Docker publication with no matching Windows endpoint remains a diagnostic and never becomes a synthetic listener. @@ -199,11 +217,12 @@ The native socket row is direct point-in-time evidence from the host, although c The default contract is deliberately small: -- read-only—no process killing or firewall changes; -- local/offline by default—collection uses local OS APIs and, when available, the local Docker Engine named pipe; vulnerability scans use a separately installed Trivy executable and pre-populated local database with online/update/telemetry paths disabled; PortCVE performs no reputation lookup, image pull, or sample upload; +- local host state is read-only—no process killing or firewall changes; +- local/offline by default—local collection uses OS APIs and, when available, the local Docker Engine named pipe; local vulnerability scans use a separately installed Trivy executable and pre-populated local database with online/update/telemetry paths disabled; - no target-process environment-variable reads; - no command-line collection; -- no remote scan or external reachability probe; and +- remote connections occur only through `scan-host` after `--authorized`; NVD access additionally requires `--online-advisories`; +- remote mode has no credentials, brute force, exploit payloads, state-changing requests, crawling, fuzzing, denial-of-service checks, stealth/evasion, or arbitrary template/code execution; and - diagnostics go to stderr so JSON stdout remains machine-readable. Running elevated may reveal more process metadata, but the socket inventory still works for a standard user and reports gaps explicitly. @@ -220,7 +239,10 @@ All JSON uses snake_case, stable enum strings, a mandatory `schema_version`, det - [Snapshot schema v1](schema/portcve.snapshot.v1.schema.json) - [Lockfile schema v1](schema/portcve.lock.v1.schema.json) +- [Trivy database status schema v1](schema/portcve.database.v1.schema.json) - [Vulnerability report schema v1](schema/portcve.vulnerability.v1.schema.json) +- [Remote assessment schema v1](schema/portcve.remote.v1.schema.json) +- [External evidence import schema v1](schema/portcve.import.v1.schema.json) Human-readable output is not a compatibility API. JSON and lockfile schema changes follow the policy in [docs/versioning.md](docs/versioning.md). @@ -233,15 +255,18 @@ Included now: - loopback/interface/wildcard classification; - active Windows network-profile mapping; - local Docker Engine named-pipe collection and medium-confidence published-port correlation; -- offline known-advisory matching for immutable local Docker image IDs and explicit local SBOMs, with database freshness and CI exit gates; +- explicit offline Trivy database status and explicit-only database update, plus offline known-advisory matching for immutable local Docker image IDs and explicit local SBOMs with freshness and CI exit gates; +- authorized TCP host/CIDR discovery with frozen DNS, bounded concurrency/rate/timeouts/evidence, safe HTTP/TLS/greeting probes, and privacy-reduced JSON; +- conservative explicit-online NVD enrichment for verified catalog-backed remote identities, including conditional applicability; +- bounded import-only interoperability for Nmap XML and Nuclei JSONL; - opt-in static Windows Firewall correlation; -- list, inspect, scan, snapshot, lock, diff, check, watch, and doctor workflows; +- list, inspect, local scan, authorized scan-host, import, snapshot, lock, diff, check, watch, and doctor workflows; - text, JSON, and JSONL output; and - standard-user degradation with explicit diagnostics. Not included: -- remote port scanning; +- exploitation, credential attacks, brute force, fuzzing, denial of service, stealth/evasion, or state-changing remote checks; - traffic capture or throughput graphs; - process termination or automatic firewall changes; - a generic “risk score”; diff --git a/ROADMAP.md b/ROADMAP.md index a08a57b..42a6163 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,15 +2,17 @@ The order matters: correctness and evidence quality come before more platforms or a GUI. -## 0.1 stabilization +## Current stabilization -- Golden JSON and lockfile compatibility fixtures, including container publications and image-set identity -- More Windows 10, Windows 11, and Server integration coverage -- Broader Docker Desktop version, permission, absent-Engine, and published-port integration coverage beyond the validated 28.3.2 TCP/UDP fixture +Completed in the current `0.2.0-alpha.1` development line: golden JSON/schema and lockfile fixtures, Docker publication/image-set identity, socket-churn coverage, enforceable performance budgets, Windows 11 local validation, and fresh Windows Server 2022/2025 CI jobs with live loopback assessment. + +Remaining before a stable `1.0` claim: + +- Windows 10 compatibility validation and broader supported Windows 11 builds +- Broader Docker Desktop version, permission, absent-Engine, and published-port coverage beyond the validated 28.3.2 TCP/UDP fixture - Standard-user and administrator comparison tests - Disposable-VM firewall rule matrix -- Socket/process churn soak tests and performance budgets -- Signed Windows releases when signing infrastructure is available +- A public-trust Authenticode release after publisher identity validation and protected signing credentials are configured ## 0.2 guest and workload attribution @@ -19,7 +21,15 @@ The order matters: correctness and evidence quality come before more platforms o - Binary hashing as an explicit opt-in baseline field - Better protected-service owner-module enrichment when elevated -## 0.3 policy workflows +## 0.3 remote assessment hardening + +- More protocol negotiation without authentication: SMB, RDP, database greetings, SMTP STARTTLS, and curated UDP probes with honest `open|filtered` semantics +- Targets/exclusions files, engagement manifests, resumable/sharded scans, JSONL streaming, and remote baseline/diff +- Nuclei target export and a carefully allowlisted external runner that never enables code, headless, fuzz, brute-force, or denial-of-service templates +- Persistent NVD cache/delta updates and CISA KEV enrichment with source freshness +- More certificate, HTTP-header, cookie, SSH-algorithm, and TLS-posture observations without turning configuration absence into exploit claims + +## 0.4 policy workflows - Source-IP-aware firewall explanation - SARIF output for CI findings @@ -34,9 +44,9 @@ The order matters: correctness and evidence quality come before more platforms o ## Explicitly not planned for v1 -- Remote network scanning - Packet capture - Process killing - Automatic firewall modification - Cloud dashboards or telemetry - AI-generated risk scores +- Exploit execution, credential attacks, brute force, fuzzing, denial of service, stealth/evasion, or arbitrary remote template/code execution diff --git a/SECURITY.md b/SECURITY.md index 3e8849e..5f2ec02 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -PortCVE is currently alpha software. Only the latest tagged release receives security fixes. +PortCVE is currently alpha software and has no finalized signed PortCVE release yet. The historical unsigned `v0.1.0-alpha.1` BindWitness-era artifact is not a supported daily-use distribution. Once signed PortCVE releases begin, only the latest release line will receive security fixes unless a release note says otherwise. ## Report a vulnerability @@ -12,15 +12,19 @@ Include the affected version, Windows version, privilege level, reproduction ste ## Security boundaries -PortCVE parses local OS data that may change while it is being read. Its output is evidence, not an authorization decision or guarantee of network reachability. +PortCVE parses local OS, container, scanner-import, package, and network observations that may be partial or change while they are being read. Its output is evidence, not an authorization decision, exploitability proof, or general guarantee of network reachability. -The current release: +The current development line: -- is read-only; -- performs no remote scan or external probe; -- sends no telemetry or reputation request; +- does not close ports, kill processes, change firewall policy, install security updates, exploit services, submit credentials, brute-force, fuzz, or perform denial-of-service checks; +- requires an explicit `--authorized` assertion before `scan-host` makes bounded TCP, greeting, HTTP, or TLS identification connections; +- permits third-party network access only through explicit commands/options: `db update` downloads Trivy advisory data, and `scan-host --online-advisories` sends only a reviewed catalog-backed CPE to the NVD API; +- does not send target addresses, hostnames, banners, credentials, or process inventory to NVD; +- disables Trivy telemetry/version checks and keeps local vulnerability scans offline; - does not read process environment variables or command lines; - invokes Windows PowerShell only with bundled constant scripts and no user-controlled script interpolation; and -- may return partial metadata for protected or rapidly exiting processes. +- may return partial metadata for protected or rapidly exiting processes, inconclusive network states, unresolved software identities, or provider evidence that cannot be safely matched. Do not run a binary from an untrusted source merely to inspect it. PortCVE inspects running local endpoints; it is not a malware sandbox. + +Remote connections are observable and can create application, firewall, IDS, or rate-limit logs. Only assess systems within a scope you are authorized to test. Import files and JSON reports can still contain sensitive assessment metadata after default reduction; review them before sharing. diff --git a/docs/architecture.md b/docs/architecture.md index 718d3e5..d5f7709 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -PortCVE is a collection-and-correlation CLI. It does not sniff packets and does not execute untrusted code. +PortCVE is a collection-and-correlation CLI. It does not sniff packets, execute untrusted code, or run exploit payloads. Its remote path makes bounded TCP/application connections only after an authorization assertion. ## Pipeline @@ -12,7 +12,10 @@ PortCVE is a collection-and-correlation CLI. It does not sniff packets and does 6. The optional firewall collector reads the merged `ActiveStore` through structured NetSecurity CIM objects and joins rule filters by stable rule ID. 7. The evaluator separates exact matches from conditional or unsupported rules. Unresolved predicates lower confidence and can produce `mixed` or `unknown`. 8. For `scan`, the vulnerability layer selects only immutable correlated Docker image IDs or an explicitly supplied local SBOM, invokes a separately installed Trivy process in offline mode, and records database freshness and provider completeness. -9. Renderers emit human text, versioned JSON, JSONL events, normalized lockfiles, or vulnerability reports. +9. For `scan-host`, the planner bounds target/port expansion, freezes DNS once, and runs TCP discovery plus protocol-specific greeting, HTTP, and TLS probes through a shared rate limiter. +10. Strong protocol-banner product/version identities may enter the small verified CPE catalog. Explicit-online NVD requests preserve CVE applicability/configuration conditions; headers and port-number guesses never enter that path. +11. Forward-only, cardinality-bounded Nmap XML and byte-streamed Nuclei JSONL importers normalize already-produced evidence without launching either external scanner or fetching templates/links. Normalization retains safe endpoint and finding identifiers while discarding raw NSE output, extracted values, requests, responses, curl commands, and template content. +12. Renderers emit human text, versioned JSON, JSONL events, normalized lockfiles, vulnerability reports, remote reports, or import documents. Native socket collection and a bounded Docker named-pipe probe run for every live collection. If the pipe is absent, the Docker collector returns `unavailable` quickly and does not start Docker Desktop or any container. Windows Firewall collection is intentionally opt-in for inventory, lock, and watch because effective rule enumeration is much slower. @@ -29,12 +32,35 @@ PortCVE labels evidence by source because the sources have different semantics: | Docker published-port metadata | Local Docker Engine `/version` and negotiated `/containers/json` over `\\.\pipe\docker_engine` | Runtime-declared mapping for a running container; correlated to a host socket by tuple with medium confidence, not proof that the container owns the Windows socket. | | Firewall profile/rules/filters | Local NetSecurity commands against `ActiveStore` | Static configuration evidence consumed by PortCVE's evaluator, not a live WFP packet-classification result. | | Package advisory matches | Separately installed Trivy, immutable local Docker image ID or explicit local SBOM, and pre-populated local database | Known-advisory match for an observed package version; not proof of reachability, exploitability, or compromise. | +| Remote TCP/protocol evidence | Direct TCP connection, bounded greeting/HTTP response, or TLS handshake after `--authorized` | Observed network behavior at that time; not proof of every path, product installation, vulnerability, or exploitability. | +| Remote CVE candidates | Strong banner identity, provenance-bound CPE catalog mapping, and explicit-online NVD CVE/configuration data | Candidate only. Compound cofactors stay conditional; insufficient or negated applicability stays inconclusive. | +| Imported Nmap/Nuclei evidence | Existing local files supplied by the operator | External scanner claims retained with source/confidence; never promoted to verified PortCVE observations automatically. | The PowerShell scripts are bundled constants and do not interpolate CLI input. They run locally, but `--resolve-accounts` separately calls Windows account lookup APIs; Windows can contact domain services when a SID is not local or cached. ## Data boundaries -The core model contains platform-neutral listeners, owners, interfaces, container publications, policy evidence, vulnerability subjects/findings/provider runs, diagnostics, and evidence status. Win32, Docker transport, and Trivy parser structures remain in their collection layers. +The core model contains platform-neutral listeners, owners, interfaces, container publications, policy evidence, vulnerability subjects/findings/provider runs, remote observations/applicability, imported evidence, diagnostics, and evidence status. Win32, Docker transport, Trivy, remote protocol, NVD, Nmap, and Nuclei parser structures remain in their collection layers. + +## Authorized remote assessment + +The planner accepts one hostname/IP or IPv4 CIDR, rejects URL/path syntax, defaults to at most 256 expanded addresses, and requires `--authorized`. DNS names are resolved once and every later connection uses the frozen numeric set. A scanner instance owns one monotonic connection-rate limiter shared across all targets in the run; overall host and per-host concurrency stay within the CLI bound. Each frozen address/port set and each stored response has a hard cap. + +Discovery never depends on ICMP. TCP refusal, timeout, unreachable, error, and successful connection are different states; timeout is not relabeled `filtered`. Passive identification reads bounded greetings or issues `HEAD /` on configured HTTP ports and performs TLS/HTTPS negotiation on configured TLS ports. Safe-active mode adds only `OPTIONS`/`HEAD` requests and separate TLS version handshakes. A silent nonstandard port may receive a fresh adaptive `HEAD /` and then a TLS ClientHello; valid HTTP framing or a completed TLS handshake is required, and HTTPS requires HTTP/1.1 ALPN. Cross-host redirects are recorded as headers and never followed. + +Product extraction is protocol-bound. A strong product/version pattern in a protocol greeting may be submitted to the provenance-bearing catalog as `strong`; an HTTP `Server`/`X-Powered-By` self-report remains review-only. The NVD client is disabled unless explicitly requested, sends only the selected CPE, rate-limits requests process-wide, honors server cooldown, and never records the optional API key. A run queries at most 64 unique catalog-backed identities. Provider findings are normalized once in `advisory_results`; endpoint assessments reference them by `advisory_result_id`, preventing repeated services from multiplying full CVE/configuration payloads. Applicability trees, vulnerability status, source timestamps, and limitations are retained. Direct candidates, conditional candidates, and inconclusive records are separate output states; exploitability is always `not_assessed`. + +Catalog eligibility is deliberately narrower than product-name recognition. The complete first greeting line must match an anchored vendor form and the observed version must be dotted numeric. OpenSSH portable `p` levels are losslessly split into the CPE update component; ProFTPD additionally permits one stable patch letter because the Official CPE Dictionary represents releases such as `1.3.8a` in the version component itself. Release-candidate, distribution, and custom suffixes remain unresolved rather than being truncated. The supported strong greeting mappings are: + +| Protocol-bound greeting form | Catalog identity | CPE vendor/product | Primary basis | +| --- | --- | --- | --- | +| SSH software field `OpenSSH_p` | OpenSSH portable | `openbsd:openssh` | [RFC 4253 identification field](https://www.rfc-editor.org/rfc/rfc4253.html#section-4.2) and [OpenSSH portable version definition](https://github.com/openssh/openssh-portable/blob/master/version.h) | +| SSH software field `dropbear_` | Dropbear SSH | `dropbear_ssh_project:dropbear_ssh` | [Dropbear upstream identification definition](https://github.com/mkj/dropbear/blob/1442f00d3f0d755d9f8ba83c5edcd893aa4d71db/src/sysoptions.h#L6-L14) | +| FTP `220 ProFTPD Server (...) [...]` | ProFTPD | `proftpd:proftpd` | [Historical upstream greeting implementation](https://github.com/proftpd/proftpd/blob/v1.3.5a/src/session.c#L301-L316); [current ServerIdent documentation](https://www.proftpd.org/docs/modules/mod_core.html#ServerIdent) confirms that modern defaults omit the version | +| FTP `220 (vsFTPd )` | vsftpd | `vsftpd_project:vsftpd` | [Official vsftpd 3.0.5 source archive](https://security.appspot.com/downloads/vsftpd-3.0.5.tar.gz), `prelogin.c` and `vsftpver.h` | +| SMTP `220 ESMTP Exim ...` | Exim | `exim:exim` | [RFC 5321 greeting grammar](https://www.rfc-editor.org/rfc/rfc5321.html#section-4.2) and [Exim's default `smtp_banner`](https://www.exim.org/exim-html-current/doc/html/spec_html/ch-main_configuration.html) | + +The vendor/product pairs above were checked on 2026-08-10 through the [NVD CPE API 2.0](https://nvd.nist.gov/developers/products) against the Official CPE Dictionary. This is provenance for the static namespace mapping, not a claim that every newly observed version already has a dictionary record. A successful zero-result NVD response remains a qualified zero candidate result for that query; it is not proof that the service is vulnerability-free. ## Offline vulnerability assessment diff --git a/docs/cli.md b/docs/cli.md index 6a7a46f..27775c3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI reference -PortCVE is read-only. Commands observe local Windows state, write JSON or a lockfile when requested, and never kill a process, close a socket, edit firewall policy, or probe a remote host. +PortCVE never changes local firewall/process state and never exploits a remote service. Local commands are offline except for explicitly documented account resolution and the explicit `db update` command. `scan-host` performs authorized, rate-limited TCP connections and safe identification probes only when the operator supplies `--authorized`. ## Commands @@ -15,6 +15,11 @@ portcve diff Show current drift from a baseline portcve check Gate security-relevant drift portcve scan Check exact subjects for one TCP listener portcve scan --all Check exact Docker image IDs for all TCP listeners +portcve db status Inspect local Trivy and database freshness offline +portcve db update Explicitly download and validate the Trivy database +portcve scan-host --authorized Scan an authorized host or IPv4 CIDR +portcve import nmap Normalize an existing Nmap XML file +portcve import nuclei Normalize existing Nuclei JSONL findings portcve watch Poll and report endpoint changes portcve doctor Report collector coverage portcve help Show the concise built-in reference @@ -29,7 +34,7 @@ Every live collection also performs a bounded probe of the local Docker Engine n `scan` maps selected TCP listeners only to immutable Docker `sha256:` image IDs. Native Windows process names and paths are not guessed into products or CPEs. For one exact TCP port, `--sbom ` adds an explicitly declared local SBOM subject; it cannot be combined with `--all`. -The scanner launches a separately installed Trivy executable without a shell, selects the local Docker daemon only, and supplies update, telemetry, version-check, VEX-update, and online dependency-resolution disable flags. It never downloads Trivy or a database. Set `PORTCVE_TRIVY_PATH` for a trusted local non-default executable and `PORTCVE_TRIVY_CACHE_DIR` for a non-default cache. The executable path (or the caller's `PATH` lookup when unset) is an explicit trust boundary and must not resolve through UNC storage or a reparse point. The cache, its database directory, metadata, and database file must resolve on an allowed local drive without reparse traversal before Trivy starts. The expected database metadata is `\db\metadata.json`; a missing or invalid database makes the subject unavailable, while a database older than 72 hours makes evidence partial. +The scanner launches a separately installed Trivy executable without a shell, selects the local Docker daemon only, and supplies update, telemetry, version-check, VEX-update, and online dependency-resolution disable flags. A `scan` never downloads Trivy or a database. Set `PORTCVE_TRIVY_PATH` to the absolute path of a trusted local non-default executable and `PORTCVE_TRIVY_CACHE_DIR` for a non-default cache. The executable and cache must be on an allowed local drive without reparse traversal. The expected database metadata is `\db\metadata.json`; a missing or invalid database makes the subject unavailable, while a database older than 72 hours makes evidence partial. | Option | Behavior | | --- | --- | @@ -41,6 +46,76 @@ The scanner launches a separately installed Trivy executable without a shell, se Human output and vulnerability JSON say `known_advisory_match`: they do not claim the package is reachable or exploitable. JSON uses `schema/portcve.vulnerability.v1.schema.json` and is redacted unless `--include-private` is supplied. If Trivy cannot run or no subject produces scan evidence, `scan` exits `3`; a selector with no matching TCP listener exits `1`. +`--fail-on` is a security gate, not a best-effort filter. It returns `3` instead of passing when the vulnerability database or selected scan evidence is incomplete, even without a separate `--strict` flag. + +### Trivy database lifecycle + +PortCVE never updates vulnerability data implicitly. Database maintenance is split into two explicit commands: + +```powershell +portcve db status +portcve db status --json +portcve db update +``` + +Trivy is an optional external dependency; PortCVE neither bundles nor installs it. For first-time setup, download the Windows x64 archive and its checksum file from the [official Trivy GitHub release](https://github.com/aquasecurity/trivy/releases), verify the archive's SHA-256 before extracting it to a protected local directory, set `PORTCVE_TRIVY_PATH` to the absolute `trivy.exe` path, and optionally set `PORTCVE_TRIVY_CACHE_DIR` to a protected local cache. Then run `portcve db update` followed by `portcve db status`. PortCVE does not use Winget/Scoop, a piped `irm | iex` installer, or an automatic download hidden inside `scan`. + +`db status` performs no network request. It resolves Trivy to a validated local `.exe`, runs a bounded offline `--version` check, and verifies the configured cache, database metadata schema, `trivy.db` file, update time, next-update time, and 72-hour freshness limit. Before reporting ready, it also makes Trivy open that database in a bounded offline vulnerability scan of a newly created empty private directory. This validation scans no user files, executes no target code, disables every update path, and rejects a corrupt/truncated database even when its metadata is fresh. Human output reports the executable path, Trivy version, cache directory, database schema version, timestamps, age, and a stable result code. JSON follows `schema/portcve.database.v1.schema.json` with `schema_version: 1` and `tool_version`. By default its `privacy_mode` is `reduced`, `executable_path` is `local-trivy-executable`, and `cache_directory` is `local-trivy-cache`; `--json --include-private` emits the exact validated paths with `privacy_mode: private`. A missing, stale, future-dated, malformed, unreadable, unsafe, or unavailable database returns exit code `3` instead of reporting readiness. + +`db update` is the only local vulnerability workflow that permits a database download. It invokes the validated Trivy executable directly without a shell and runs `trivy image --download-db-only` against the configured cache. The operation has a ten-minute timeout, bounded stdout/stderr, no progress output, a private per-invocation temporary directory, and post-update path, metadata, database-file, schema, and freshness validation. Trivy configuration, registry/cloud credentials, Docker endpoints, telemetry, version checks, proxy variables, and alternate-update environment variables are removed from the child environment; neither child output nor environment values are copied into PortCVE diagnostics. The command uses Trivy's built-in public database source and may require a direct outbound HTTPS path because ambient proxy configuration is intentionally not inherited. PortCVE's post-update check validates the local structure and freshness; it is not an independent cryptographic attestation of Trivy's database contents. + +When `PORTCVE_TRIVY_PATH` is unset, the database commands search absolute entries in the caller's `PATH` for `trivy.exe`, resolve the match to an absolute path, and reject UNC/device paths, mapped network drives, and reparse traversal before launch. If `PORTCVE_TRIVY_PATH` is set, it must itself be an absolute local `.exe` path. PortCVE does not download or install Trivy. + +These checks are path-based and repeated immediately before launch and verification. They do not claim to defeat a malicious same-user process that can replace a validated path during the remaining check/use window. Keep the Trivy executable and cache in directories that are not writable by untrusted local users, and do not run database maintenance concurrently with hostile local processes. + +## Authorized remote assessment + +`scan-host` is a separate evidence path from the local `scan` command. It accepts one hostname, IP address, or IPv4 CIDR, freezes DNS results once per target, and performs TCP connect discovery. The default `common` set is PortCVE's deterministic curated service-port list; it is not advertised as an industry top-N ranking. + +```powershell +portcve scan-host 10.20.30.40 --authorized +portcve scan-host 10.20.30.0/24 --ports 22,80,443,8000-8100 --authorized --max-hosts 256 +portcve scan-host app.example.test --ports all --authorized --active --rate 250 --concurrency 64 +portcve scan-host app.example.test --ports 22,443 --authorized --online-advisories --strict +``` + +`--authorized` records the operator's assertion that the selected scope may be tested. PortCVE cannot verify legal authority; it refuses to start without the assertion. CIDR expansion defaults to 256 addresses and has an explicit maximum of 65,536. A single in-memory report is limited to 1,000,000 planned target/port pairs; split larger engagements into runs instead of risking an out-of-memory failure. Connection rate, concurrency, connect timeout, read timeout, frozen address/port expansion, stored evidence, and response parsing are bounded. There is no unlimited-rate switch. + +Discovery performs a full TCP connection and then protocol-specific, non-authenticated identification where applicable: SSH/FTP/SMTP/POP3/IMAP greetings, HTTP `HEAD /`, TLS negotiation, certificate observation, and HTTPS `HEAD /`. `--active` adds only bounded `OPTIONS /`, `HEAD /robots.txt`, `HEAD /.well-known/security.txt`, and separate TLS 1.2/1.3 handshakes. For a silent service on a nonstandard port, active mode also tries a fresh `HEAD /` connection followed by a TLS ClientHello only when HTTP was not confirmed; HTTPS is attempted only after TLS explicitly negotiates HTTP/1.1 through ALPN. Any greeting suppresses the adaptive probes. PortCVE does not follow redirects, retain response bodies, submit credentials, crawl, upload, mutate application state, brute-force, fuzz, execute an exploit, or run denial-of-service checks. + +These connections are observable and can create server, firewall, IDS, or rate-limit logs. `HEAD` and `OPTIONS` are selected as non-mutating methods, but PortCVE cannot guarantee that a broken or unusual target implements them without side effects; authorization and operator judgment still matter. + +`--online-advisories` is the only remote-assessment option that contacts a third-party service. It sends a verified catalog-backed CPE—not the target IP, hostname, banner, or credentials—to the [NVD CVE API 2.0](https://nvd.nist.gov/developers/vulnerabilities). Only strong protocol-banner product/version evidence can enter the small built-in CPE catalog; HTTP `Server` headers, port numbers, and guesses cannot. NVD configuration trees and enrichment status are preserved so compound platform requirements are reported as conditional or inconclusive rather than flattened into a direct candidate. Requests share a non-bypassable process-wide six-second minimum interval and honor bounded `Retry-After` cooldowns. Set `PORTCVE_NVD_API_KEY` to use an NVD key without placing it in arguments or output. + +One run queries at most 64 unique catalog-backed CPE identities. Repeated endpoint observations of the same identity share one `advisory_results` record and retain endpoint association through `advisory_result_id`; CVE/configuration payloads are not copied into every assessment. The summary and `--fail-on` evaluate the shared direct matches. Additional unique identities are not queried, receive an `nvd_identity_cap_exceeded` endpoint diagnostic, and make the aggregate advisory status `partial`; `--strict` or `--fail-on` therefore returns incomplete-evidence exit code `3` instead of treating the bounded result as complete. + +The provenance-bound catalog intentionally resolves only reviewed, protocol-bound identities: OpenSSH portable releases, Apache HTTP Server/httpd, Dropbear SSH, ProFTPD, vsftpd, and Exim. A product is eligible only when its version and canonical protocol greeting match the reviewed grammar for that mapping. Other parsed products, ambiguous/custom banners, HTTP `Server` headers, release-candidate versions, and port-only observations remain useful fingerprint evidence but are `cpe_unresolved`; PortCVE never guesses a vendor/product CPE to increase finding count. + +Remote findings use `candidate`, `conditional_candidate`, or `inconclusive`; all retain `exploitability: not_assessed`. Remote `--fail-on` requires `--online-advisories`, applies only to direct high/critical candidates, and returns `3` rather than passing if advisory evidence is partial. Conditional or inconclusive records never trigger exit `1`. Default JSON aliases targets/addresses and removes raw banners/certificate identity; `--include-private` retains them. `-o` writes a redacted versioned JSON report unless `--include-private` is supplied and refuses to replace an existing file. + +NVD notice: This product uses data from the NVD API but is not endorsed or certified by the NVD. + +## External evidence imports + +`import` normalizes an existing local scanner result without launching that scanner, fetching templates, following links, resolving targets, or sending network traffic: + +```powershell +portcve import nmap .\scan.xml -o .\scan.portcve.json +portcve import nuclei .\findings.jsonl -o .\findings.portcve.json +``` + +The input must be an existing regular file on a local drive. UNC paths, mapped network drives, device paths, symbolic links, junctions, mount points, and cloud placeholders are rejected before the file is opened. Nmap XML is capped at 64 MiB. Nuclei JSONL is capped at 256 MiB, 100,000 nonblank records, 200,000 physical lines, and 1 MiB of UTF-8 bytes per record. Both importers enforce a 16 MiB aggregate retained-character budget before JSON serialization. The report records only the input leaf name, byte length, and SHA-256—not its full local path. + +Path validation is repeated close to file I/O, but it is path-based. It does not claim to defeat a malicious same-user process that can rename an ancestor and replace it with a junction in the validation/open race window. Use assessment input/output directories that are not writable by untrusted local users. + +Nmap XML is parsed forward-only; PortCVE never builds a DOM for the whole file. DTDs and external entities are prohibited, element depth and cardinality are bounded, and only the canonical unqualified `nmaprun/host/hostnames/hostname`, `nmaprun/host/ports/port`, direct port child, and `nmaprun/runstats/finished` paths count. PortCVE imports fields from [Nmap's documented XML format](https://nmap.org/book/output-formats-xml-output.html), maps `method=probed` confidence 8–10 to `strong`, confidence 5–7 to `moderate`, and port-table identity to `weak`. A safe NSE script identifier remains an `imported_match` with `unresolved` evidence strength, but raw NSE output and nested script content are discarded. A missing, misplaced, namespaced, or unsuccessful Nmap `finished` state marks the document incomplete. + +PortCVE does not bundle, download, or execute Nmap. Import-only keeps the tools operationally independent and avoids presenting Nmap's [separate licensing terms](https://nmap.org/book/man-legal.html) as PortCVE's MIT-licensed code. + +Nuclei JSONL is read in fixed-size byte chunks; an oversized strict record is rejected without first materializing the rest of a potentially 256 MiB line. PortCVE normalizes a safe template identifier, severity, sanitized target origin, matcher, canonical advisory references, and CVE identifiers; records explicitly carrying `matcher-status: false` are ignored. URL userinfo, query strings, fragments, and opaque token-like path segments are not republished. Extracted results—including possible credentials—raw requests, responses, curl commands, templates, encoded templates, and template URLs are discarded rather than copied into the normalized report. Imported matches require independent validation before reporting. Without `--strict`, malformed or oversized lines become diagnostics and valid later lines are retained in an incomplete report; `--strict` rejects the first malformed or oversized line. + +Import output always follows `schema/portcve.import.v1.schema.json`. Use `-o, --output` to write it to a file and `--force` to replace an existing output. The normalized report still contains target and scanner-result metadata and is not anonymous; review it before sharing. + ## Filters and collection | Option | Behavior | @@ -53,7 +128,7 @@ Human output and vulnerability JSON say `known_advisory_match`: they do not clai | `--no-firewall` | Skip host-policy collection, including for commands that enable it by default. | | `--evidence` | Enable firewall collection and show supporting evidence in human-readable inspection. | | `--resolve-accounts` | Resolve token SIDs to account names. Windows may contact a domain controller or global catalog; this is the only current opt-in that can cause a network account lookup. | -| `--strict`, `--require-complete` | Return exit code `3` when required core collection evidence is incomplete. An absent optional Docker Engine does not fail general strict mode; container-aware lockfiles have a separate completeness gate. `check` already refuses to pass on incomplete baseline/current evidence. | +| `--strict`, `--require-complete` | Return exit code `3` when required evidence is incomplete. For `scan-host --online-advisories`, unresolved strong identities, the 64-identity query cap, partial NVD status/applicability, or failed provider evidence are incomplete. An absent optional Docker Engine does not fail general local strict mode; container-aware lockfiles have a separate completeness gate. | `diff` and `check` use the selector and UDP choice stored in the lockfile. They do not accept new port, protocol, process, or scope filters. @@ -79,7 +154,7 @@ An Engine publication that cannot be matched to a Windows endpoint is reported a | `--json`, `--format json` | Emit versioned JSON. `watch --json` emits one compact JSON object per line. | | `--format jsonl` | Select machine-readable output; JSONL is meaningful for streaming `watch`. | | `--format table`, `--format text` | Select human-readable output. | -| `--include-private` | Disable default JSON/snapshot redaction and include collected local addresses, interface details, owner paths/identity, container IDs/names/image references, firewall-rule details, and raw evidence/diagnostics. It never enables command-line or environment-variable collection. | +| `--include-private` | Disable default JSON/snapshot redaction and include collected local or remote addresses, target names, interface details, owner paths/identity, container IDs/names/image references, firewall-rule details, and raw bounded evidence/diagnostics. It never enables command-line or environment-variable collection. | Default JSON is redacted and privacy-reduced, not anonymous. It replaces owner PIDs with `0` and removes creation time. For Docker correlations it replaces container IDs, names, and image references, omits image IDs, and normalizes host addresses; the existence of a mapping, host/container ports, protocol, runtime, and medium confidence remain visible. The output also contains bind scopes, process/service names, profile labels, policy verdicts, and collection metadata. Review it before publishing. Human-readable inspection is intended for local use and can show private host and container details. @@ -99,7 +174,7 @@ Watch is TCP-only unless `--include-udp` or a UDP protocol filter is supplied. I | `0` | Success, matching inspection, or passing check. An empty unfiltered list is still successful. | | `1` | No matching inspected endpoint or a failed security drift check. | | `2` | Invalid usage, schema, lockfile, or non-overwrite request. | -| `3` | Evidence is incomplete for the requested strict or gating operation, or no vulnerability subject could be scanned. | +| `3` | Evidence is incomplete for the requested strict or gating operation, no vulnerability subject could be scanned, no remote target resolved, or requested online advisory evidence failed. | | `4` | Required collection or runtime operation failed. | | `130` | Interrupted. | diff --git a/docs/daily-use.md b/docs/daily-use.md new file mode 100644 index 0000000..74b6360 --- /dev/null +++ b/docs/daily-use.md @@ -0,0 +1,177 @@ +# Daily use + +PortCVE is designed for three repeatable jobs on Windows: explain what is bound locally, detect exposure drift, and collect bounded vulnerability evidence for exact local artifacts or explicitly authorized remote services. + +It is non-destructive and does not change local security state. It does not close ports, change firewall rules, install updates, exploit services, brute-force credentials, or make a remote target safe to test. `scan-host` does make observable network connections and safe identification requests; use it only for systems you are authorized to assess. + +## Set up once + +1. Install a finalized signed release using the file-backed PowerShell procedure in [install.md](install.md), or use its portable ZIP in a controlled engagement directory. +2. Open a new terminal and verify the installation: + + ```powershell + portcve version + portcve doctor + ``` + + Review any partial collector evidence. Protected Windows processes can legitimately hide some owner metadata from a standard-user session; use `portcve doctor --strict` when an automated workflow must reject any incomplete core evidence. + +3. If Docker or SBOM vulnerability checks are needed, install a trusted Windows x64 Trivy release from the official Aqua Security release page and verify its published checksum. PortCVE does not silently install or update Trivy. Point PortCVE at the verified executable and a dedicated local cache: + + ```powershell + [Environment]::SetEnvironmentVariable( + 'PORTCVE_TRIVY_PATH', + 'C:\Tools\trivy\trivy.exe', + 'User') + [Environment]::SetEnvironmentVariable( + 'PORTCVE_TRIVY_CACHE_DIR', + "$env:LOCALAPPDATA\PortCVE\trivy-cache", + 'User') + ``` + +4. Open another terminal, explicitly fetch the advisory database, then verify readiness without network access: + + ```powershell + portcve db update + portcve db status --json + ``` + +`scan`, local inventory, baselines, and `db status` never update the database implicitly. Repeat `db update` when `db status` reports stale evidence. + +## Five-minute host review + +Start with a fast inventory, then inspect only the endpoints that need explanation: + +```powershell +portcve list +portcve list --scope non-loopback +portcve tcp:8080 --evidence +``` + +The first command answers what is bound. The scoped list highlights interface and wildcard binds. Exact inspection adds process, service, bind scope, interface, and static Windows Firewall evidence where it is available. A wildcard bind or static allow rule is not proof of Internet reachability. + +Use private JSON only when the output will remain controlled: + +```powershell +portcve snapshot --json -o .\host.portcve.json +portcve snapshot --json --include-private -o .\host.private.portcve.json +``` + +Default JSON is privacy-reduced, not anonymous. Review every report before sharing it. + +## Baseline and drift + +Create a baseline only after reviewing the current machine as known-good: + +```powershell +portcve lock -o .\listeners.lock.json +``` + +Review drift manually: + +```powershell +portcve diff .\listeners.lock.json +``` + +Use the same file as a CI or workstation gate: + +```powershell +portcve check .\listeners.lock.json --strict +if ($LASTEXITCODE -ne 0) { throw "PortCVE check failed with exit $LASTEXITCODE" } +``` + +Commit a reviewed privacy-reduced lockfile when it belongs to a repository policy. Do not create a baseline with `--allow-incomplete` and then treat it as a passing security control. Include UDP only when its extra churn is operationally useful. + +For interactive change observation: + +```powershell +portcve watch --json --interval 1s +``` + +## Known-advisory checks for local listeners + +Docker-published listeners can be mapped to immutable local image IDs and scanned against the local Trivy database without pulling an image: + +```powershell +portcve db status +portcve scan tcp:8080 --strict +portcve scan --all --fail-on high +``` + +An explicit local CycloneDX or SPDX SBOM can be associated with one selected listener: + +```powershell +portcve scan tcp:8080 --sbom .\app.cdx.json --strict +``` + +Results mean a known advisory matched an observed package identity. They do not prove the listening service is reachable, affected in its runtime configuration, or exploitable. Native Windows binaries are left unresolved unless exact supported evidence exists; PortCVE does not invent a product or CPE from a filename. + +## Authorized remote assessment + +Keep the target and port scope explicit. Begin passive, then use `--active` only when the safe probe set is appropriate for the engagement: + +```powershell +portcve scan-host 10.20.30.40 --ports 22,80,443 --authorized -o .\host.remote.json +portcve scan-host 10.20.30.40 --ports 22,80,443 --authorized --active -o .\host.active.json +``` + +For reviewed strong banner identities, online NVD enrichment must be explicitly enabled: + +```powershell +portcve scan-host 10.20.30.40 --ports 22,443 ` + --authorized --online-advisories --strict --fail-on high ` + -o .\host.advisories.json +``` + +`--authorized` records the operator's assertion; PortCVE cannot verify authority. Connections and safe probes can appear in server, firewall, IDS, and rate-limit logs. Online enrichment sends only a reviewed catalog-backed CPE to NVD, not the target address, hostname, banner, or credentials. Findings remain candidates with exploitability not assessed. + +For larger approved scopes, split work into bounded runs. PortCVE intentionally has host, endpoint, concurrency, rate, timeout, evidence, and advisory-identity caps instead of an unlimited mode. + +## Reuse Nmap and Nuclei evidence + +PortCVE can normalize existing local outputs without launching those tools or contacting their targets: + +```powershell +portcve import nmap .\scan.xml -o .\scan.portcve.json --strict +portcve import nuclei .\findings.jsonl -o .\findings.portcve.json --strict +``` + +The importers are bounded and discard sensitive raw request, response, script-output, and extracted-value fields. Normalized output still contains assessment metadata and must be reviewed before publication. + +## Exit codes for automation + +Treat exit codes as part of the command contract: + +| Code | Meaning | +| ---: | --- | +| `0` | The requested operation completed and its configured gate passed. | +| `1` | No exact endpoint matched, drift failed, or the configured finding threshold matched. | +| `2` | Usage, schema, input, or overwrite policy was invalid. | +| `3` | Required evidence was incomplete; do not treat this as a clean result. | +| `4` | A required collector or runtime operation failed. | +| `130` | The operation was interrupted. | + +Use `--strict` for automation. A finding gate such as `--fail-on high` also fails closed when the evidence needed to evaluate that gate is incomplete. + +## Update, rollback, and uninstall + +The managed install keeps a verified signed installer copy. Run it to update to the latest stable release, or provide an exact signed tag to roll back: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy AllSigned ` + -File "$env:LOCALAPPDATA\Programs\PortCVE\install.ps1" + +powershell.exe -NoProfile -ExecutionPolicy AllSigned ` + -File "$env:LOCALAPPDATA\Programs\PortCVE\install.ps1" ` + -Version v1.0.0 +``` + +Uninstall is local and removes only a receipt-bound managed installation and its exact user `PATH` entry: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy AllSigned ` + -File "$env:LOCALAPPDATA\Programs\PortCVE\install.ps1" ` + -Uninstall +``` + +See [install.md](install.md) for the signature, checksum, rollback, custom-directory, and portable-ZIP details. See [cli.md](cli.md) for the complete option and evidence contract. diff --git a/docs/install.md b/docs/install.md index 7734efa..d29579a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,12 +1,14 @@ # Installing PortCVE on Windows -The production installer requires 64-bit Windows and PowerShell 5.1 or newer. It is itself Authenticode-signed, must run from a downloaded `install.ps1` file, installs for the current user at `%LOCALAPPDATA%\Programs\PortCVE`, and adds that directory to the user `PATH`; administrator rights are not required. +The managed installer supports 64-bit Windows and Windows PowerShell 5.1 or newer. It installs for the current user at `%LOCALAPPDATA%\Programs\PortCVE`, so administrator rights are not required. -The checked-in [`scripts/install.ps1`](../scripts/install.ps1) file is a release template. It deliberately refuses to run until the trusted release workflow embeds the exact expected Authenticode signer subject. Download `install.ps1` from a signed GitHub Release, not from the repository source tree. +The checked-in [`scripts/install.ps1`](../scripts/install.ps1) file is an unsigned release template. It deliberately refuses to install or uninstall anything until the protected release workflow embeds the exact publisher subject and signs it. Use the `install.ps1` asset from a finalized [PortCVE GitHub Release](https://github.com/Labeeb2339/PortCVE/releases), not the source-tree template. -## Recommended: download, verify, inspect, then run +## Download, verify, inspect, then run -This example downloads the latest stable installer and its checksum with `curl.exe`, verifies the exact `install.ps1` entry and Windows trust result, leaves the script available for inspection, and then runs that file: +Do not pipe the installer into `iex`, `Invoke-Expression`, or another in-memory execution method. File-backed execution lets Windows and the installer verify the exact script before it performs network or filesystem activity. + +This example downloads the latest stable installer, checks its exact release checksum entry, requires a trusted Authenticode signer and timestamp, leaves it available for inspection, and then runs it under `AllSigned` policy: ```powershell $base = 'https://github.com/Labeeb2339/PortCVE/releases/latest/download' @@ -16,52 +18,116 @@ New-Item -ItemType Directory -Force $dir | Out-Null curl.exe --fail --location --proto '=https' --tlsv1.2 "$base/install.ps1" --output "$dir/install.ps1" curl.exe --fail --location --proto '=https' --tlsv1.2 "$base/SHA256SUMS.txt" --output "$dir/SHA256SUMS.txt" -$lines = Get-Content "$dir/SHA256SUMS.txt" -$entry = @($lines | Where-Object { $_ -match '^(?[0-9a-fA-F]{64})\s+\*?install\.ps1$' }) -if ($entry.Count -ne 1) { throw 'Expected exactly one install.ps1 checksum.' } -$entry[0] -match '^(?[0-9a-fA-F]{64})' | Out-Null -$expected = $Matches.hash.ToLowerInvariant() -$actual = (Get-FileHash "$dir/install.ps1" -Algorithm SHA256).Hash.ToLowerInvariant() +$lines = Get-Content -LiteralPath "$dir/SHA256SUMS.txt" +$entry = @($lines | Where-Object { $_ -cmatch '^(?[0-9a-f]{64}) install\.ps1$' }) +if ($entry.Count -ne 1) { throw 'Expected exactly one canonical install.ps1 checksum.' } +$entry[0] -cmatch '^(?[0-9a-f]{64})' | Out-Null +$expected = $Matches.hash +$actual = (Get-FileHash -LiteralPath "$dir/install.ps1" -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -cne $expected) { throw 'Installer checksum mismatch.' } $signature = Get-AuthenticodeSignature -LiteralPath "$dir/install.ps1" -if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or $null -eq $signature.TimeStamperCertificate) { +if ($signature.Status -ne 'Valid' -or + $null -eq $signature.SignerCertificate -or + $null -eq $signature.TimeStamperCertificate) { throw "Installer Authenticode verification failed: $($signature.StatusMessage)" } -$signature.SignerCertificate.Subject # compare with the documented PortCVE publisher +$signature.SignerCertificate.Subject # compare with the publisher stated by the release + +Get-Content -LiteralPath "$dir/install.ps1" # inspect before execution +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File "$dir/install.ps1" +``` + +If `curl.exe` is unavailable, use file-backed PowerShell downloads in place of the two `curl.exe` lines; do not append `| iex`: + +```powershell +Invoke-WebRequest -UseBasicParsing -Uri "$base/install.ps1" -OutFile "$dir/install.ps1" +Invoke-WebRequest -UseBasicParsing -Uri "$base/SHA256SUMS.txt" -OutFile "$dir/SHA256SUMS.txt" +``` + +GitHub's `releases/latest` endpoint selects the latest stable release, not a prerelease. To install a prerelease or another exact version, download `install.ps1` and `SHA256SUMS.txt` from that release page and pass its exact tag: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File "$dir/install.ps1" -Version v1.0.0-rc.1 +``` + +An optional destination on a fixed local Windows drive can be selected with `-InstallDirectory`. UNC, mapped network, removable-drive, root, and existing reparse-point paths are refused. Repeat the exact same path for later updates, rollback, or uninstall. + +## Update and rollback + +The managed installation keeps the exact verified signed installer as `%LOCALAPPDATA%\Programs\PortCVE\install.ps1`. Run that file to update to the latest stable release: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy AllSigned ` + -File "$env:LOCALAPPDATA\Programs\PortCVE\install.ps1" +``` + +An update is staged on the target volume. Before commit, the existing receipt-bound installation is moved to a guarded backup. If staging, replacement, or the user `PATH` update fails, the previous directory and `PATH` are restored. A backup-cleanup failure is reported separately after the new version is already committed; it is never misreported as a successful rollback. + +To deliberately roll back after a successful update, invoke a verified signed installer with the exact earlier signed release tag: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File "$dir/install.ps1" -Version v1.0.0 +``` + +The selected release must still exist, contain the exact PortCVE asset names, and carry the same release-bound signer identity expected by that installer. Unsigned historical builds cannot be selected as rollback targets. + +## Uninstall -Get-Content "$dir/install.ps1" # inspect before execution -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$dir/install.ps1" +Use the signed copy kept in the managed installation from a working directory outside the PortCVE install directory. Uninstall verifies that file's own signature first, makes no network request, validates the managed receipt and exact directory contents, removes only the exact PortCVE user `PATH` entry, and deletes the guarded installation directory: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy AllSigned ` + -File "$env:LOCALAPPDATA\Programs\PortCVE\install.ps1" ` + -Uninstall ``` -The no-argument installer selects GitHub's latest stable release. To install an explicit release, including a release candidate, pass its exact tag: +For a custom location: ```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$dir/install.ps1" -Version v1.0.0 +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File "$dir/install.ps1" ` + -Uninstall ` + -InstallDirectory 'C:\Users\you\Tools\PortCVE' ``` -An optional per-user destination can be selected with `-InstallDirectory`. The installer refuses dangerous roots, reparse-point targets, and directories containing files it does not manage. Piped, dot-generated, or other in-memory installation is not supported: download and invoke the signed file. +If the installed script or executable is missing, damaged, or no longer matches its receipt, every signed installer deliberately refuses automatic deletion. Inspect the exact directory and receipt, then remove the confirmed damaged directory manually; after it is absent, a newly downloaded and verified signed installer invoked with `-Uninstall` can remove only the exact stale user `PATH` entry. `-Version` and `-Uninstall` cannot be combined. A target with an invalid receipt, extra file, directory, reparse point, hash mismatch, or invalid signature never enters the automatic deletion path. + +Open a new terminal after install or uninstall so it receives the updated user `PATH`. + +## Portable ZIP -## What the installer verifies +Each finalized release also contains `portcve--win-x64.zip`. This is the right option for an engagement folder, disposable VM, or environment where `PATH` should not be changed: -The installer has no unsigned or signature-bypass mode. Before changing the installation it: +1. Download the exact ZIP and `SHA256SUMS.txt` from the same PortCVE release. +2. Require one canonical checksum entry for the ZIP and compare its complete SHA-256 hash. +3. Extract to a new directory. +4. Require `Get-AuthenticodeSignature .\portcve.exe` to report `Valid` with the expected publisher and timestamp. +5. Run `.\portcve.exe --version` before use. + +The portable ZIP contains the same signed executable as the managed installer plus release documentation and schemas. It does not create an installation receipt, change `PATH`, update itself, or provide managed uninstall behavior. Delete the directory yourself when finished. + +## What the managed installer verifies + +The installer has no unsigned or signature-bypass mode. Before installing or updating it: 1. requires file-backed execution and requires Windows to report its own Authenticode signature and timestamp as trusted, with the exact embedded signer subject plus the Code Signing and Time Stamping EKUs, before any network or install-directory mutation; 2. resolves only `Labeeb2339/PortCVE` through GitHub's HTTPS API; -3. downloads the exact versioned Windows x64 ZIP and `SHA256SUMS.txt` with fixed size and timeout limits; +3. downloads the exact versioned `portcve--win-x64.zip` and `SHA256SUMS.txt` with fixed size and timeout limits; 4. requires one exact checksum entry and verifies the complete ZIP with SHA-256; 5. extracts only the root `portcve.exe` through traversal-safe ZIP handling; 6. requires Windows to report a valid trusted Authenticode signature chain; 7. compares the executable's full signer certificate subject exactly with the same release-embedded identity; 8. requires the Code Signing EKU and a Windows-validated timestamp certificate with the Time Stamping EKU; and -9. verifies the copied executable again before installation. +9. verifies both the copied executable and copied signed maintenance installer again before installation. + +The versioned receipt records schema version, product and repository identity, canonical install path, release tag, exact ZIP asset, ZIP, executable, and installer SHA-256 hashes, signer and timestamp subjects, and installation time. Existing non-empty directories must have exactly `portcve.exe`, signed `install.ps1`, and `install-receipt.json`; the actual executable and installer hashes, trusted Authenticode state, exact release-bound signer, executable timestamp, and required EKUs must still pass before update or uninstall. -Files are prepared in bounded, uniquely named staging directories on the target volume. Updates move the prior installation to a guarded backup, atomically move the staged directory into place, update only the user `PATH`, and restore the prior directory and `PATH` if a later step fails. Cleanup is limited to validated installer-owned temporary, staging, backup, or failed-install paths. +Use the default per-user directory or another parent writable only by the same trusted user. The receipt and hash checks catch damage, partial replacement, and mixed installation state; the receipt is not itself a signed authorization object. The installer checks fixed-local-drive, component, ancestor, reparse-point, receipt, hash, signature, and managed-child boundaries initially and again immediately before commit. These checks cannot create an isolation boundary against another process already running as the same user that replaces both files and receipt or wins the residual race after the final check. -The installer sends no telemetry. Its only network requests are release metadata and assets from GitHub. An installation receipt records the release tag, ZIP checksum, signer subject, timestamp subject, and installation time locally. +The installer sends no telemetry. Its only network requests are release metadata and assets from GitHub, and uninstall makes no network request. Windows PowerShell 5.1 does not expose .NET's `Rfc3161TimestampToken.VerifySignatureForSignerInfo` primitive. The installer therefore relies on Windows Authenticode trust for its timestamp and does not claim to independently prove RFC 3161 message-imprint binding. The release workflow performs that separate proof for both published signed files under PowerShell 7 and refuses publication if decoding, signature binding, or trusted TSA matching fails. -## Current unsigned alpha +## Historical unsigned alpha -`v0.1.0-alpha.1` was published before code signing was configured. The production installer intentionally cannot install that unsigned artifact. Build it from source or manually verify the historical checksum only if you explicitly accept that alpha's unsigned status. +`v0.1.0-alpha.1` was published under the former BindWitness name before code signing was configured. Its ZIP and executable are unsigned, its assets use historical names, and it has no production signed installer asset. The PortCVE installer intentionally rejects it; it is not a valid daily-use installation or rollback target. diff --git a/docs/release-signing.md b/docs/release-signing.md index e36c01d..16ac4b8 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -2,13 +2,13 @@ PortCVE's public Windows releases are fail-closed: the release workflow cannot publish an unsigned executable or production installer. A candidate is built and tested without signing credentials, the installer is finalized as UTF-8 with BOM, both files are approved through the protected `release-signing` environment and signed by SSL.com eSigner, independently verified, packaged, checksummed, attested, uploaded as a draft, and published only after GitHub reports matching SHA-256 asset digests. -This document is an operator runbook, not evidence that the current repository or certificate account is already configured. Repository settings and SSL.com identity validation must be completed by a maintainer before the first signed release. +This document is an operator runbook, not evidence that the current repository or certificate account is already configured. The repository cannot create a trusted publisher identity by itself. Before the first signed release, Labeeb must complete external certificate-provider identity validation, activate the signing credential, and configure the protected GitHub environment and secrets. Until those external steps are evidenced, the workflow must fail closed and no build should be described as signed or daily-ready. ## Malaysia signing route For a maintainer or organization based in Malaysia, SSL.com eSigner is the practical route currently wired into the workflow. The certificate holder must complete SSL.com's identity validation and obtain a code-signing credential that can be used with eSigner automation. The Windows publisher shown to users will be the validated legal subject in the certificate; it cannot honestly be made an arbitrary project nickname. -SSL.com currently lists an Individual Validation code-signing certificate from USD 129 per year and eSigner Tier 1 from USD 180 per year, before tax, with the first 30 days of eSigner included for new code-signing orders. Pricing and eligibility can change, so confirm them before purchase: +SSL.com currently lists an Individual Validation Authenticode certificate from USD 129 per year and eSigner Tier 1 at USD 15 per month for 240 signings, before tax. The validated personal or organization name becomes the Windows publisher and the signing key remains in SSL.com's cloud HSM. Pricing, quotas, and eligibility can change, so confirm them before purchase: - [SSL.com Individual Validation code signing](https://www.ssl.com/products/software-integrity/code-signing/iv/) - [SSL.com eSigner pricing](https://www.ssl.com/guide/esigner-pricing-for-code-signing/) @@ -16,7 +16,7 @@ SSL.com currently lists an Individual Validation code-signing certificate from U Expect government-ID, address, and liveness checks for an individual, or company-registration and authorized-representative checks for an organization. Keep the eSigner automation credential dedicated to PortCVE releases and grant only the access it needs. -Azure Artifact Signing is not a fallback for a Malaysian individual or Malaysia-incorporated organization under Microsoft's current country eligibility. Microsoft currently supports individual accounts only in the United States and Canada, and its organization-country list does not include Malaysia. Recheck the official [Artifact Signing prerequisites](https://learn.microsoft.com/en-us/azure/artifact-signing/quickstart) if Microsoft expands availability. +Microsoft Artifact Signing Public Trust is not currently a fallback for a Malaysian individual or Malaysia-incorporated organization. Microsoft's published eligibility limits individuals to the United States and Canada and organizations to the United States, Canada, the European Union, and the United Kingdom. Recheck the official [Artifact Signing prerequisites](https://learn.microsoft.com/en-us/azure/artifact-signing/quickstart) if Microsoft expands availability. An OV or EV certificate does not guarantee an immediate Microsoft Defender SmartScreen reputation. Microsoft describes reputation as based on signals including download history and antivirus results; do not promise that EV automatically removes warnings. See [Microsoft Defender SmartScreen and app reputation](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation). @@ -25,7 +25,7 @@ An OV or EV certificate does not guarantee an immediate Microsoft Defender Smart Configure these controls before creating a release tag: 1. Create an environment named exactly `release-signing`. -2. Add required reviewers, prevent self-review, restrict deployments to release tags, and do not allow administrators to bypass the protection. GitHub documents these controls under [deployment environments](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments). +2. The current solo-maintainer environment has a five-minute wait timer, requires approval from `Labeeb2339`, and restricts deployments with the custom `v*` tag policy. This is an explicit manual gate, not separation of duties: with Labeeb as the sole reviewer, prevent-self-review cannot be enabled, and administrators can currently bypass the environment. When another trusted maintainer is available, require that independent reviewer, enable prevent-self-review, and remove administrator bypass before making a stable high-assurance release claim. GitHub documents these controls under [deployment environments](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments). 3. Store these four values as environment secrets, never repository files or ordinary variables: - `ES_USERNAME` - `ES_PASSWORD` @@ -46,9 +46,9 @@ GitHub should also limit the `release-signing` environment's deployment tag patt The workflow in `.github/workflows/release.yml` has three security boundaries: -- `build` has read-only repository access and no signing secrets. It restores locked dependencies, checks formatting, builds, tests, publishes exactly one unsigned `portcve.exe`, and smoke-tests it. +- `build` has read-only repository access and no signing secrets. It restores locked dependencies, checks formatting, builds, tests, runs the clean install/update/failed-update rollback/uninstall fixture under Windows PowerShell 5.1, publishes exactly one unsigned `portcve.exe`, and smoke-tests it. - `sign` runs only after approval in `release-signing`. It fails if any secret or the expected full signer subject is missing. It finalizes `install.ps1` with a UTF-8 BOM, verifies the exact SHA-256 of SSL.com CodeSignTool 1.3.0, and invokes the pinned SSL.com action separately for the exact `portcve.exe` and `install.ps1` paths. -- `package_publish` has the release and attestation permissions. It downloads only the two verified signed files, repeats signature verification, creates the ZIP, writes a checksum for every public asset other than the checksum file itself, generates GitHub provenance attestations, creates a draft, checks GitHub's recorded asset digests, and only then publishes it. +- `package_publish` has the release and attestation permissions. It downloads only the two verified signed files, repeats signature verification, creates `portcve--win-x64.zip`, proves that the ZIP, standalone executable, and signing metadata contain the exact same signed executable hash, writes a checksum for every public asset other than the checksum file itself, generates GitHub provenance attestations, creates a draft, checks GitHub's recorded asset digests, and only then publishes it. Signature verification requires all of the following: @@ -81,7 +81,7 @@ There is no unsigned fallback. Missing credentials, a changed action/tool downlo 5. Review and approve the `release-signing` environment deployment only after confirming the tag, commit SHA, workflow diff, and expected publisher subject. 6. Confirm the workflow's signature verification, signed smoke test, metadata validation, provenance attestation, draft digest verification, and final publish steps all passed. -7. Download the published assets on a separate Windows machine and run the consumer checks below. +7. Download the published assets on a clean separate Windows machine and run the checksum, signature, portable ZIP, and managed lifecycle checks below. Record install, update, explicit rollback, and uninstall results. Stable tags such as `v1.0.0` publish as the latest stable release. Policy-compatible prerelease tags such as `v1.0.0-rc.1` publish as prereleases. Numeric identifiers with leading zeroes, prerelease identifiers beginning with a hyphen, and build metadata such as `+build.5` are intentionally rejected by the same rule in the workflow and installer. @@ -115,21 +115,57 @@ gh attestation verify .\portcve.exe --repo Labeeb2339/PortCVE gh attestation verify .\portcve-v1.0.0-win-x64.zip --repo Labeeb2339/PortCVE ``` +On the clean verification machine, exercise the supported lifecycle with the downloaded, checksum-verified, Authenticode-valid `install.ps1`: + +```powershell +# Clean install of the candidate. +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File .\install.ps1 -Version v1.1.0 +portcve --version + +# Update or deliberate rollback use the same signed installer and exact tags. +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File .\install.ps1 -Version v1.0.0 +portcve --version +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File .\install.ps1 -Version v1.1.0 +portcve --version + +# Uninstall is offline and must remove the exact managed directory and PATH entry. +powershell.exe -NoProfile -ExecutionPolicy AllSigned -File .\install.ps1 -Uninstall +if (Test-Path "$env:LOCALAPPDATA\Programs\PortCVE") { throw 'PortCVE uninstall left its managed directory.' } +``` + +Replace the example tags with two actual compatible signed releases. For the first signed release, the offline Windows PowerShell fixture is the rollback evidence until a second signed release exists; do not invent an end-to-end cross-release result. Repeat the lifecycle with `-InstallDirectory` for the supported custom-path case. Open a new terminal before each `portcve --version` check so it receives the current user `PATH`. + ## Pre-1.0 release gate Do not call a build `1.0.0` until every item is evidenced: - SSL.com validation and the production eSigner credential are active. - `EXPECTED_SIGNER_SUBJECT` was copied from a controlled test signature and independently reviewed. -- `release-signing` has required reviewers, self-review prevention, and release-tag restrictions. +- `release-signing` has the recorded wait timer, explicit approval, and release-tag restriction. Before a stable high-assurance claim, an independent trusted reviewer is required and prevent-self-review plus no-administrator-bypass are enabled; the current solo-maintainer approval alone does not satisfy separation of duties. - `main` and `v*` tags are protected, immutable releases are enabled, and action SHA pinning is enforced where available. - A prerelease completed the entire production signing workflow without manual artifact substitution. - The PowerShell 7 release verifier accepted both downloaded signed files with the exact subject, Code Signing and Time Stamping EKUs, and a `VerifySignatureForSignerInfo` RFC 3161 binding proof; SignTool also accepted the executable's SHA-256 signature. - Windows PowerShell 5.1 parsed the finalized UTF-8 BOM installer with the exact non-ASCII test subject, and the installer rejected unsigned or in-memory execution before network or install-directory mutation. +- The Windows PowerShell 5.1 offline lifecycle fixture passed clean install, managed update, invalid installed-signature rejection, executable/installer/receipt tamper rejection, pre-commit failed-update rollback, exact PATH removal, receipt rejection, and guarded uninstall checks without adding a production bypass or trusting a test root CA. - `portcve.exe --version` and a no-firewall snapshot smoke test passed after signing and after download. - Every file in `SHA256SUMS.txt` matched, the installer rejected a tampered ZIP/executable, and GitHub provenance verification passed. -- The ZIP contains the same signed executable hash recorded in `SIGNING-METADATA.json`. +- The portable ZIP, standalone `portcve.exe`, and `SIGNING-METADATA.json` contain the same signed executable hash. +- A clean Windows machine completed managed install, update, explicit signed-version rollback, and receipt-bound offline uninstall; if only one signed release exists, record that cross-release rollback remains pending instead of claiming it passed. - Release notes, license, security policy, schema files, and vulnerability-data limitations are accurate for 1.0. - Defender/SmartScreen behavior was observed on a clean Windows machine and described honestly, without promising reputation or warning-free execution. Record the test tag, release URL, workflow run ID, executable SHA-256, signer subject, and verification machine details in the release evidence. Never record the four eSigner secrets or authentication logs. + +## Historical BindWitness-era release + +The public `v0.1.0-alpha.1` prerelease was created before the repository was renamed. Its description now identifies it as historical and unsigned and uses the current PortCVE changelog URL. Its two historical assets remain `bindwitness-v0.1.0-alpha.1-win-x64.zip` and `SHA256SUMS.txt`; they must not be presented as current PortCVE artifacts. + +If the release description is ever repaired again, change only its explanatory text and keep this claim boundary: + +```markdown +Historical unsigned BindWitness-era prerelease. This artifact is not accepted by the PortCVE signed installer and is not recommended for daily use. + +Full Changelog: https://github.com/Labeeb2339/PortCVE/commits/v0.1.0-alpha.1 +``` + +Do not rename, replace, or re-upload the historical assets as PortCVE binaries. Future workflow-generated releases use the current `Labeeb2339/PortCVE` repository and exact `portcve.exe`, `install.ps1`, `portcve--win-x64.zip`, `SHA256SUMS.txt`, and `SIGNING-METADATA.json` asset names. diff --git a/docs/remote-live-validation.md b/docs/remote-live-validation.md new file mode 100644 index 0000000..06507cf --- /dev/null +++ b/docs/remote-live-validation.md @@ -0,0 +1,74 @@ +# Remote live validation + +`scripts/Test-RemoteHostIntegration.ps1` is a bounded Windows PowerShell 5.1+ +integration check for PortCVE's real `scan-host` executable path. It does not +scan an Internet or LAN address. + +## Run it + +From the repository root: + +```powershell +.\scripts\Test-RemoteHostIntegration.ps1 +``` + +The default run builds `src\PortCVE\PortCVE.csproj` in Release and uses +`src\PortCVE\bin\Release\net10.0\win-x64\portcve.exe`. A previously built +Release executable can be tested explicitly: + +```powershell +.\scripts\Test-RemoteHostIntegration.ps1 ` + -SkipBuild ` + -PortCVEPath .\artifacts\win-x64\portcve.exe +``` + +The harness emits one JSON result only after listener, process, and temporary +file cleanup succeeds. Its static PowerShell 5.1 safety test is: + +```powershell +.\scripts\tests\Test-RemoteHostIntegrationHarness.ps1 +``` + +## What it proves + +Each run creates two listeners bound explicitly to `127.0.0.1`: + +- an SSH fixture on an OS-assigned temporary port, returning an + `OpenSSH_9.6p1` identification string; +- a silent HTTP fixture on a separate OS-assigned temporary port, returning + `Server: Apache/2.4.58` only after a request and logging request lines. + +PortCVE is invoked with `--authorized` and `--ports` containing exactly those +two selected ports. The harness checks: + +- both endpoints are reported open; +- the discovery profile sends no bytes to the nonstandard HTTP fixture and does + not invent an application identity for it; +- the safe-active profile uses a fresh connection, sends exactly one + `HEAD /`, records the `active-adaptive-http-head` evidence source, and parses + Apache `2.4.58`; +- no method or path beyond the exact adaptive `HEAD /` reaches the fixture; +- default discovery and active JSON remove the target, address, and raw + banner/header evidence; +- `--include-private` discovery retains the SSH evidence, while private active + output also retains the adaptive HTTP header evidence; +- every CLI/build process has a timeout and is killed if it outlives it; and +- listeners and a uniquely named, verified system-temp directory are cleaned + in `finally` blocks. + +This end-to-end harness exercises the bounded adaptive fallback on a real +nonstandard port. Its first connection is greeting-read-only. Active mode then +uses one fresh connection for `HEAD /` and stops immediately when valid HTTP is +confirmed; it does not add `OPTIONS`, endpoint probes, or TLS. TLS fallback is +attempted only when adaptive HTTP is not confirmed, and HTTPS additionally +requires HTTP/1.1 ALPN. The harness passes only its two exact OS-assigned ports +to PortCVE; it never performs a range or common-port scan. + +## Claim boundary + +This validates loopback TCP discovery, adaptive HTTP fingerprinting on an +OS-assigned nonstandard port, output privacy modes, and cleanup on the tested +Windows host. It does not validate the separate adaptive TLS branch. It also +does not prove external reachability, CVE applicability, exploitability, +authorization for another target, or every remote service. The harness never +enables NVD enrichment and does not send credentials or exploit attempts. diff --git a/docs/threat-model.md b/docs/threat-model.md index e7ae35c..6f6a5c0 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -7,6 +7,8 @@ - Privacy of local process and network metadata - Integrity and portability of baseline files - Honest confidence and limitation reporting +- Authorized remote target scope, rate limits, and evidence provenance +- Prevention of remote CVE false positives from banners, headers, or conditional applicability ## Trusted inputs @@ -30,6 +32,9 @@ Lockfiles are user-provided data. Their schema is validated before comparison, b - Unexpected domain/network lookup when resolving account SIDs - Argument injection, hangs, unbounded output, or child-process escape in the external vulnerability scanner - Stale, missing, malformed, or changing local vulnerability evidence +- DNS rebinding, oversized CIDR/port expansion, slow services, control characters, and cross-protocol banner spoofing +- Misleading or conditional NVD configuration trees, incomplete enrichment status, rate-limit responses, and identity/CPE mismatch +- Malformed or oversized Nmap XML and Nuclei JSONL, including XXE and secret-bearing raw request/response fields ## Out of scope @@ -42,8 +47,12 @@ Lockfiles are user-provided data. Their schema is validated before comparison, b - Proving that a port is reachable from the Internet - Safely executing an untrusted binary - Proving a known advisory is exploitable, reachable through the selected port, or applicable to code loaded at runtime -- Inferring a product or CPE from a native process name, executable metadata, port number, or banner +- Proving that a version-bearing greeting was not deliberately spoofed or rewritten by an intermediary +- Inferring a product or CPE from a native process name, executable metadata, port number, generic header, or unverified banner - Downloading or updating Trivy or its vulnerability database +- Legal authorization for a remote target; `--authorized` records the operator's assertion but cannot verify permission +- Exploit success, authentication, application state changes, crawling, brute force, fuzzing, denial of service, stealth, or evasion +- Defending path-based import/output validation against a malicious same-user process that can concurrently replace a writable ancestor with a junction between validation and file I/O ## Safe failure rules @@ -54,7 +63,7 @@ Lockfiles are user-provided data. Their schema is validated before comparison, b - Docker publication correlation is always medium confidence. An unmatched publication produces a diagnostic and never a synthetic listener. - An absent Docker pipe degrades quickly to optional `unavailable` evidence and never starts Docker Desktop or a container. Access denial, timeout, or failed Engine collection cannot become complete container baseline evidence. - Watch does not report removals from a failed endpoint snapshot. -- V1 never kills a process, closes a socket, changes a firewall rule, or sends a probe. +- V1 never kills a process, closes a local socket, changes a firewall rule, executes an exploit, submits credentials, or sends state-changing remote requests. - Vulnerability subjects are limited to exact immutable Docker image IDs and explicitly supplied SBOMs. Unresolved native processes are `not_supported`, never silently clean. - Trivy is launched directly with an argument list, bounded time and output, process-tree termination on cancellation or limits, and offline/update/telemetry flags. Post-kill waiting also has a fixed grace period, so failed termination cannot hang PortCVE indefinitely. PortCVE does not invoke a shell or fall back from the local Docker image source to a registry. - Every inherited `TRIVY_*` variable is removed case-insensitively before PortCVE sets its small offline allowlist. Each scan gets a validated local temp directory through `TMP` and `TEMP`; cleanup is limited to the exact generated child and runs for success, failure, timeout, output overflow, or cancellation. @@ -62,6 +71,14 @@ Lockfiles are user-provided data. Their schema is validated before comparison, b - Missing or invalid database metadata is `unavailable`. A database older than 72 hours is `partial`; `--strict` returns exit code `3`. - SBOMs must be local regular files. UNC paths, mapped network drives, and reparse-point traversal are rejected before collection. Files are hashed before and after scanning; changed input findings are discarded and the scan cannot become successful partial evidence. - A zero-match result is qualified by database date and completeness. A finding is a package/advisory match, not proof of exploitability or reachability. +- Remote scans require `--authorized`, freeze DNS once, use explicit bounded ports/targets/concurrency/rate/timeouts, and retain distinct connection states. Active mode is limited to non-authenticated HTTP `OPTIONS`/`HEAD` and TLS handshakes. +- Remote TCP, HTTP, and TLS activity is observable and can create logs or rate-limit state. The selected HTTP methods are intended to be non-mutating, but a target with non-compliant method handling may still have side effects. +- Remote product/version evidence is protocol-bound. HTTP headers remain ineligible for automatic NVD correlation. Only a strong banner plus a provenance-bound catalog resolution may trigger an explicit-online NVD query. +- Catalog-eligible greetings use anchored, product-specific grammar over a complete first protocol line. Unsupported release, distribution, and custom suffixes stay unresolved; PortCVE does not truncate them into a different upstream version. Modern versionless ProFTPD greetings remain FTP evidence only. +- Banner identities remain self-reported candidates even when their syntax matches an upstream default. The static catalog proves only the reviewed NVD vendor/product namespace mapping and a lossless version representation; it does not authenticate the remote binary or guarantee that NVD already contains that exact release. +- NVD output preserves configuration/applicability and enrichment status. Compound cofactors are conditional, negated or insufficient applicability is inconclusive, and every result keeps `exploitability: not_assessed`. At most 64 unique catalog-backed identities are queried per run, and repeated endpoint observations reference one normalized provider result instead of duplicating attacker-amplifiable CVE payloads. +- Default remote JSON replaces diagnostic messages with scope-specific safe text while retaining diagnostic codes; raw endpoint-formatted exception text and remote-controlled `Allow` header values remain private-only. +- Nmap and Nuclei support is import-only. Their files are untrusted local inputs, cannot traverse network/reparse paths, are size/count/depth/retained-output bounded, and never cause PortCVE to execute scanners, templates, URLs, requests, responses, or curl commands. XML structure is matched by canonical unqualified parent paths so nested lookalike elements cannot forge endpoints or successful completion. Default normalized output removes URL credentials/query/fragment/token-like components and does not retain NSE output, extracted results, raw requests/responses, curl commands, or template content. ## Privacy modes @@ -76,3 +93,5 @@ The dated live fixture described in the README validated TCP and UDP echo, indep Account-name resolution is off by default. `--resolve-accounts` uses Windows `LookupAccountSid`, which can contact a domain controller or global catalog when data is not available locally. This opt-in weakens the otherwise local/offline collection boundary and is documented separately from `--include-private`. Vulnerability JSON is redacted by default. It retains advisory IDs, package names and versions, severities, fix metadata, selected ports, bind scope, and database freshness because those are the report's operational content. It replaces Docker image references and SBOM names, omits artifact IDs/hashes, normalizes listener keys, and sanitizes free-form limitations and diagnostics. `--include-private` can expose local SBOM paths, immutable image IDs, image references, and detailed scanner diagnostics; review it before sharing. + +Remote JSON is also redacted by default. It replaces selector/target/address values with run-local aliases, clears raw greeting and HTTP/TLS evidence, and drops identity-bearing fingerprint attributes while retaining ports, protocol/service categories, parsed product/version candidates, CPEs, advisory IDs, severity, applicability, and limitations. `--include-private` retains frozen addresses, hostnames, raw bounded evidence, certificate identity, and other potentially sensitive assessment metadata. diff --git a/docs/validation.md b/docs/validation.md index 7ef6019..8db8160 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -15,10 +15,11 @@ This file records dated release-candidate evidence. It is not a guarantee about The reproducible harness is: ```powershell -.\scripts\Test-DockerIntegration.ps1 -ValidateLockCheck +.\scripts\Test-DockerIntegration.ps1 -ValidateLockCheck -ValidateRemoteScan ``` The script creates and removes a uniquely labelled local container. Its safe default publishes only loopback host ports; wildcard UDP requires the explicit `-AllowWildcardUdp` option. +The optional remote leg scans only the temporary loopback TCP publication, requires PortCVE's normal authorization assertion, confirms the real Docker-forwarded endpoint is open, and rejects a false application identity for the generic echo protocol. ## Offline known-advisory path — 2026-08-09 @@ -69,12 +70,51 @@ Seven live JSON variants and a hostile-environment rerun validated against the D The hostile environment set remote/suppressive `TRIVY_*` values. PortCVE removed them before setting its offline allowlist and still returned the full report. Docker event/inventory checks showed zero pulls and no image-inventory change. Per-invocation scanner temp directories and test containers were absent after completion. +## Authorized remote path — 2026-08-09 + +- Windows PowerShell `5.1.26100.8875`, Windows x64. +- Two disposable listeners were bound only to `127.0.0.1` on OS-assigned high ports. +- The SSH fixture returned `OpenSSH_9.6p1`; PortCVE reported OpenSSH `9.6p1`. +- The silent HTTP fixture returned `Server: Apache/2.4.58` only after receiving a request. +- Discovery sent zero HTTP requests and did not infer an identity for the silent unknown service. +- `--active` opened a fresh connection, sent exactly `HEAD / HTTP/1.1`, and reported Apache HTTP Server `2.4.58` with evidence source `active-adaptive-http-head`. +- No other HTTP method or path, external target, online-advisory request, credential, or exploit payload was used. +- Default/private privacy checks, schema-compatible output, command timeouts, process cleanup, listener cleanup, and temporary-directory cleanup passed. + +The static PowerShell harness passed 29 checks. The reproducible live command is: + +```powershell +.\scripts\Test-RemoteHostIntegration.ps1 +``` + +Detailed behavior and the narrower adaptive-TLS limitation are recorded in [remote-live-validation.md](remote-live-validation.md). + +## Daily-readiness integration — 2026-08-10 + +The current `0.2.0-alpha.1` development tree was rebuilt and exercised on Windows `10.0.26200.0` x64 as a standard user: + +- locked restore, repository-wide formatting verification, and Release build passed with zero warnings or errors; +- the full .NET suite passed `386/386`, including socket churn, importer caps/redaction, remote authorization/rate/timeout gates, corrupt-database rejection, and schema contracts; +- the Windows PowerShell 5.1 installer lifecycle harness passed `66` checks for clean install, managed update, failure rollback, exact-version rollback, receipt/hash tamper rejection, guarded `PATH` handling, and offline uninstall; +- the self-contained publish produced exactly one `portcve.exe`, with no PDB or local build path embedded; it remains intentionally unsigned because no verified publisher credential has been configured; +- the Docker Desktop `28.3.2` fixture passed real TCP and UDP echo, Windows CIM tuple confirmation, default redaction, TCP/UDP lock-and-check, and an authorized scan of the temporary loopback-forwarded TCP port with no false product identity; cleanup left zero labelled containers or integration temp directories; +- the loopback remote harness again identified OpenSSH `9.6p1` and an Apache HTTP Server `2.4.58` fixture on OS-assigned nonstandard ports, with active HTTP limited to one `HEAD /` request and no online-advisory request; +- an explicit local Trivy `0.73.0` database update produced schema `2`, `UpdatedAt` `2026-08-10T01:00:06.25992962Z`, and a `1,227,595,776`-byte `trivy.db` with SHA-256 `75a2042291878bdb2cc564e4d0b5486c1b28a1ca6d1dfa4db5d78929aef0875c`; +- `db status` forced Trivy to open that database offline and returned ready/exit `0`; a junk database returned `vulnerability_db_unreadable`/exit `3`, and reduced JSON contained no local user path; +- the pinned local `docker/welcome-to-docker` image again produced `87` known-advisory matches (`3` critical and `17` high), while `--fail-on high` returned exit `1` and default JSON omitted the immutable image ID; and +- current NuGet sources reported no known vulnerable direct or transitive package in either project. + +The performance harness passed its enforced budgets with ten local-inventory iterations over `144` observed endpoints (`203 ms` median, `215 ms` p95), a passive authorized loopback report covering `1,000` requested ports in `1,206 ms`, and `51.7 MiB` peak working set. These figures describe this host and run only; they are regression evidence, not universal performance guarantees. + +Fresh Windows Server 2022 and 2025 CI jobs, each including the live loopback and smaller performance harness, are configured in `.github/workflows/ci.yml`. Compatibility is claimed only after those jobs pass on the published commit. + ## Claim boundary -The evidence above supports the tested Windows collection, Docker tuple-correlation, Trivy adapter, parser, redaction, schema, cleanup, and exit-policy paths. It does not prove: +The evidence above supports the tested Windows collection, Docker tuple-correlation, Trivy adapter, authorized loopback discovery/adaptive HTTP, parsers, redaction, schemas, cleanup, and exit-policy paths. It does not prove: - that a wildcard bind is reachable from a LAN or the Internet; - that a matched package is reachable, exploitable, or compromised; - that no unreported vulnerability exists; - that a zero-finding result is safe beyond the named database snapshot; or +- authorization, behavior, or reachability for any untested remote target; or - compatibility with every Windows, Docker, Trivy, image, SBOM, or firewall configuration. diff --git a/docs/versioning.md b/docs/versioning.md index f20353e..0108efb 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -7,6 +7,9 @@ Published schema documents use location-independent URNs: - `urn:portcve:schema:snapshot:v1` - `urn:portcve:schema:lock:v1` - `urn:portcve:schema:vulnerability:v1` +- `urn:portcve:schema:database:v1` +- `urn:portcve:schema:remote:v1` +- `urn:portcve:schema:import:v1` These identifiers do not imply that a `portcve.dev` website or schema host exists. @@ -22,4 +25,6 @@ After `1.0`: Consumers must inspect `schema_version` before parsing. Snapshot consumers may handle unknown optional fields defensively, but schema validation for a declared version remains authoritative. Lockfile readers reject unknown schema versions and unsupported selector or enum values rather than guessing. Never parse the human-readable table as an API. +Trivy database status/update JSON uses `schema/portcve.database.v1.schema.json`. V1 includes `tool_version`, the explicit `operation` and `network_requested` pair, readiness state, database schema/freshness evidence, stable result code, and a `privacy_mode`. Default `reduced` documents alias local executable/cache paths; `private` documents are emitted only when the operator supplies `--include-private`. Changing those aliases back to exact paths by default would be a privacy-breaking schema-contract change. + Lockfiles deliberately omit volatile and private values. V1 includes `includes_udp`, the port/protocol `selector`, ownership/bind/policy/container `evidence` completeness, `owner_identity_strength`, and `host_policy_confidence`. Container-correlated endpoints can use a deterministic hash of the sorted distinct image-ID set with strength `container_image`; raw container IDs, names, and image references are omitted. Two captures of the same normalized endpoint set, selector, UDP choice, evidence class, normalized owner identity, and tool version produce the same lockfile content. diff --git a/schema/portcve.database.v1.schema.json b/schema/portcve.database.v1.schema.json new file mode 100644 index 0000000..1669b74 --- /dev/null +++ b/schema/portcve.database.v1.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:portcve:schema:database:v1", + "title": "PortCVE Trivy database status v1", + "description": "Local Trivy executable and vulnerability-database readiness. Update documents record an explicit network request, while status documents remain offline. Structural and freshness validation is not independent cryptographic attestation of database contents.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "tool_version", + "provider", + "operation", + "state", + "ready", + "network_requested", + "privacy_mode", + "executable_path", + "cache_directory", + "maximum_database_age_seconds", + "duration_ms", + "code", + "message" + ], + "properties": { + "schema_version": { "const": 1 }, + "tool_version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "provider": { "const": "trivy" }, + "operation": { "enum": ["status", "update"] }, + "state": { + "enum": ["ready", "missing", "stale", "invalid", "unavailable", "failed"] + }, + "ready": { "type": "boolean" }, + "network_requested": { "type": "boolean" }, + "privacy_mode": { "enum": ["reduced", "private"] }, + "executable_path": { "type": "string", "minLength": 1, "maxLength": 32767 }, + "engine_version": { "type": "string", "minLength": 1, "maxLength": 64 }, + "cache_directory": { "type": "string", "minLength": 1, "maxLength": 32767 }, + "database_schema_version": { "type": "integer", "minimum": 1 }, + "database_updated_at": { "type": "string", "format": "date-time" }, + "database_next_update": { "type": "string", "format": "date-time" }, + "database_age_seconds": { "type": "integer", "minimum": 0 }, + "maximum_database_age_seconds": { "const": 259200 }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9_]+$" + }, + "message": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "allOf": [ + { + "if": { "properties": { "operation": { "const": "status" } }, "required": ["operation"] }, + "then": { "properties": { "network_requested": { "const": false } } } + }, + { + "if": { "properties": { "operation": { "const": "update" } }, "required": ["operation"] }, + "then": { "properties": { "network_requested": { "const": true } } } + }, + { + "if": { "properties": { "ready": { "const": true } }, "required": ["ready"] }, + "then": { + "properties": { "state": { "const": "ready" }, "code": { "const": "ok" } }, + "required": [ + "engine_version", + "database_schema_version", + "database_updated_at", + "database_age_seconds" + ] + }, + "else": { "not": { "properties": { "state": { "const": "ready" } }, "required": ["state"] } } + } + ] +} diff --git a/schema/portcve.import.v1.schema.json b/schema/portcve.import.v1.schema.json new file mode 100644 index 0000000..8f14c4c --- /dev/null +++ b/schema/portcve.import.v1.schema.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:portcve:schema:import:v1", + "title": "PortCVE external evidence import v1", + "description": "Bounded normalization of user-supplied Nmap XML or Nuclei JSONL. Imported matches remain external observations and do not establish exploitability.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "tool_version", + "generated_at", + "input", + "source", + "is_complete", + "endpoints", + "findings", + "diagnostics" + ], + "properties": { + "schema_version": { "const": 1 }, + "tool_version": { "type": "string", "minLength": 1 }, + "generated_at": { "type": "string", "format": "date-time" }, + "input": { "$ref": "#/$defs/input" }, + "source": { "enum": ["nmap_xml", "nuclei_jsonl"] }, + "source_version": { "type": "string", "minLength": 1 }, + "is_complete": { "type": "boolean" }, + "endpoints": { + "type": "array", + "items": { "$ref": "#/$defs/endpoint" } + }, + "findings": { + "type": "array", + "items": { "$ref": "#/$defs/finding" } + }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/diagnostic" } + } + }, + "$defs": { + "evidence_strength": { + "enum": ["direct", "strong", "moderate", "weak", "conflicting", "unresolved"] + }, + "claim_status": { + "enum": ["observed", "candidate", "imported_match", "inconclusive"] + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": ["file_name", "size_bytes", "sha256"], + "properties": { + "file_name": { "type": "string", "minLength": 1 }, + "size_bytes": { "type": "integer", "minimum": 0 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["cpes", "evidence_strength", "evidence_source"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "product": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "extra_info": { "type": "string", "minLength": 1 }, + "cpes": { + "type": "array", + "maxItems": 8, + "items": { "type": "string", "minLength": 1 } + }, + "evidence_strength": { "$ref": "#/$defs/evidence_strength" }, + "evidence_source": { "type": "string", "minLength": 1 } + } + }, + "endpoint": { + "type": "object", + "additionalProperties": false, + "required": ["target", "protocol", "port", "state"], + "properties": { + "target": { "type": "string", "minLength": 1 }, + "hostname": { "type": "string", "minLength": 1 }, + "protocol": { "enum": ["tcp", "udp"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "state": { "type": "string", "minLength": 1 }, + "state_reason": { "type": "string", "minLength": 1 }, + "service": { "$ref": "#/$defs/service" } + } + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "finding_id", + "title", + "severity", + "target", + "claim_status", + "evidence_strength", + "advisory_ids", + "references", + "source_record_sha256" + ], + "properties": { + "source": { "type": "string", "minLength": 1 }, + "finding_id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "severity": { "enum": ["unknown", "info", "low", "medium", "high", "critical"] }, + "target": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "protocol": { "type": "string", "minLength": 1 }, + "claim_status": { "$ref": "#/$defs/claim_status" }, + "evidence_strength": { "$ref": "#/$defs/evidence_strength" }, + "advisory_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "references": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "source_record_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "matcher": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 } + } + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/schema/portcve.remote.v1.schema.json b/schema/portcve.remote.v1.schema.json new file mode 100644 index 0000000..068c50b --- /dev/null +++ b/schema/portcve.remote.v1.schema.json @@ -0,0 +1,412 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:portcve:schema:remote:v1", + "title": "PortCVE authorized remote assessment v1", + "description": "Bounded TCP and protocol evidence for an operator-authorized target scope. CVE records are candidates, conditional candidates, or inconclusive and never establish exploitability.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "tool_version", + "generated_at", + "selector", + "transport", + "probe_profile", + "authorization_asserted", + "online_advisories_requested", + "advisory_status", + "advisory_identity_limit", + "requested_ports", + "hosts", + "advisory_assessments", + "advisory_results", + "summary", + "diagnostics", + "claim_boundary" + ], + "properties": { + "schema_version": { "const": 1 }, + "tool_version": { "type": "string", "minLength": 1 }, + "generated_at": { "type": "string", "format": "date-time" }, + "selector": { "type": "string", "minLength": 1 }, + "transport": { "const": "tcp" }, + "probe_profile": { "enum": ["discovery", "safe_active"] }, + "authorization_asserted": { "const": true }, + "online_advisories_requested": { "type": "boolean" }, + "advisory_status": { + "enum": ["not_requested", "complete", "partial", "unavailable", "failed"] + }, + "advisory_identity_limit": { "const": 64 }, + "requested_ports": { + "type": "array", + "minItems": 1, + "maxItems": 65535, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "hosts": { + "type": "array", + "items": { "$ref": "#/$defs/host" } + }, + "advisory_assessments": { + "type": "array", + "items": { "$ref": "#/$defs/advisory_assessment" } + }, + "advisory_results": { + "type": "array", + "maxItems": 64, + "items": { "$ref": "#/$defs/advisory_result" } + }, + "summary": { "$ref": "#/$defs/summary" }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/diagnostic" } + }, + "claim_boundary": { "type": "string", "minLength": 1 }, + "nvd_notice": { "type": "string", "minLength": 1 } + }, + "$defs": { + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + }, + "host": { + "type": "object", + "additionalProperties": false, + "required": ["target", "resolved_addresses", "ports", "diagnostics"], + "properties": { + "target": { "type": "string", "minLength": 1 }, + "resolved_addresses": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "ports": { + "type": "array", + "items": { "$ref": "#/$defs/port_result" } + }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/diagnostic" } + } + } + }, + "port_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "address", + "address_family", + "port", + "state", + "duration_ms", + "fingerprints", + "product_candidates", + "diagnostics" + ], + "properties": { + "address": { "type": "string", "minLength": 1 }, + "address_family": { "enum": ["ipv4", "ipv6"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "state": { "enum": ["open", "closed", "timed_out", "unreachable", "error"] }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "fingerprints": { + "type": "array", + "items": { "$ref": "#/$defs/fingerprint" } + }, + "product_candidates": { + "type": "array", + "items": { "$ref": "#/$defs/product_candidate" } + }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/diagnostic" } + } + } + }, + "fingerprint": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "service", "confidence", "source", "evidence", "attributes"], + "properties": { + "kind": { + "enum": [ + "greeting", + "ssh", + "ftp", + "smtp", + "pop3", + "imap", + "http", + "tls", + "http_options", + "http_endpoint", + "tls_protocol_probe" + ] + }, + "service": { "type": "string", "minLength": 1 }, + "confidence": { "enum": ["observed", "strong_pattern", "protocol_confirmed"] }, + "source": { "type": "string", "minLength": 1 }, + "evidence": { "type": "string" }, + "attributes": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "product_candidate": { + "type": "object", + "additionalProperties": false, + "required": ["product", "confidence", "source", "evidence"], + "properties": { + "product": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "confidence": { "enum": ["banner_pattern", "header_reported"] }, + "source": { "type": "string", "minLength": 1 }, + "evidence": { "type": "string" } + } + }, + "advisory_assessment": { + "type": "object", + "additionalProperties": false, + "required": [ + "subject_id", + "target", + "address", + "port", + "product", + "evidence_confidence", + "evidence", + "identity_disposition", + "diagnostics" + ], + "properties": { + "subject_id": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "address": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "product": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "evidence_confidence": { "enum": ["banner_pattern", "header_reported"] }, + "evidence": { "type": "string" }, + "identity_disposition": { "enum": ["resolved", "unresolved", "not_eligible"] }, + "cpe23_uri": { "type": "string", "pattern": "^cpe:2\\.3:" }, + "mapping_source": { "type": "string", "minLength": 1 }, + "advisory_result_id": { + "type": "string", + "pattern": "^remote-advisory-result-[0-9]{4}$" + }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/advisory_diagnostic" } + } + } + }, + "advisory_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "result_id", + "product", + "version", + "cpe23_uri", + "mapping_source", + "status", + "provider", + "network_mode", + "matches", + "diagnostics" + ], + "properties": { + "result_id": { + "type": "string", + "pattern": "^remote-advisory-result-[0-9]{4}$" + }, + "product": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "cpe23_uri": { "type": "string", "pattern": "^cpe:2\\.3:" }, + "mapping_source": { "type": "string", "minLength": 1 }, + "status": { + "enum": ["not_requested", "unresolved", "complete", "partial", "unavailable", "failed"] + }, + "provider": { "type": "string", "minLength": 1 }, + "network_mode": { "enum": ["offline", "online_explicit"] }, + "source_timestamp": { "type": "string", "format": "date-time" }, + "matches": { + "type": "array", + "items": { "$ref": "#/$defs/advisory_match" } + }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/advisory_diagnostic" } + } + } + }, + "advisory_diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + }, + "advisory_match": { + "type": "object", + "additionalProperties": false, + "required": [ + "advisory_id", + "classification", + "match_method", + "product", + "version", + "cpe23_uri", + "evidence", + "confidence", + "nvd_status", + "nvd_last_modified", + "applicability", + "severity", + "references", + "references_truncated", + "exploitability" + ], + "properties": { + "advisory_id": { "pattern": "^CVE-[0-9]{4}-[0-9]{4,}$" }, + "classification": { "enum": ["candidate", "conditional_candidate", "inconclusive"] }, + "match_method": { "const": "remote_banner_match" }, + "product": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "cpe23_uri": { "type": "string", "pattern": "^cpe:2\\.3:" }, + "evidence": { "type": "string" }, + "confidence": { "enum": ["exact", "strong", "heuristic", "unresolved"] }, + "nvd_status": { "type": "string", "minLength": 1 }, + "nvd_last_modified": { "type": "string", "format": "date-time" }, + "applicability": { "$ref": "#/$defs/applicability" }, + "severity": { "enum": ["unknown", "low", "medium", "high", "critical"] }, + "severity_source": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "references": { + "type": "array", + "items": { "type": "string", "format": "uri" } + }, + "references_truncated": { "type": "boolean" }, + "exploitability": { "const": "not_assessed" } + } + }, + "applicability": { + "type": "object", + "additionalProperties": false, + "required": [ + "disposition", + "queried_cpe_vulnerable_leaf_found", + "has_required_cofactors", + "configurations", + "limitations" + ], + "properties": { + "disposition": { "enum": ["direct_candidate", "conditional_candidate", "inconclusive"] }, + "queried_cpe_vulnerable_leaf_found": { "type": "boolean" }, + "has_required_cofactors": { "type": "boolean" }, + "configurations": { + "type": "array", + "items": { "$ref": "#/$defs/configuration" } + }, + "limitations": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "configuration": { + "type": "object", + "additionalProperties": false, + "required": ["negate", "nodes"], + "properties": { + "operator": { "type": "string", "minLength": 1 }, + "negate": { "type": "boolean" }, + "nodes": { + "type": "array", + "items": { "$ref": "#/$defs/applicability_node" } + } + } + }, + "applicability_node": { + "type": "object", + "additionalProperties": false, + "required": ["operator", "negate", "cpe_matches"], + "properties": { + "operator": { "type": "string", "minLength": 1 }, + "negate": { "type": "boolean" }, + "cpe_matches": { + "type": "array", + "items": { "$ref": "#/$defs/cpe_match" } + } + } + }, + "cpe_match": { + "type": "object", + "additionalProperties": false, + "required": [ + "vulnerable", + "criteria", + "match_criteria_id", + "identity_alignment", + "matches_queried_identity", + "has_unobserved_qualifiers" + ], + "properties": { + "vulnerable": { "type": "boolean" }, + "criteria": { "type": "string", "minLength": 1 }, + "match_criteria_id": { "type": "string", "minLength": 1 }, + "version_start_excluding": { "type": "string", "minLength": 1 }, + "version_start_including": { "type": "string", "minLength": 1 }, + "version_end_excluding": { "type": "string", "minLength": 1 }, + "version_end_including": { "type": "string", "minLength": 1 }, + "identity_alignment": { + "enum": ["no_match", "proven", "conditional_on_unobserved_qualifier", "inconclusive_constraint"] + }, + "matches_queried_identity": { "type": "boolean" }, + "has_unobserved_qualifiers": { "type": "boolean" } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "target_count", + "resolved_target_count", + "endpoint_count", + "open_port_count", + "product_candidate_count", + "advisory_assessment_count", + "advisory_result_count", + "advisory_match_count", + "conditional_count", + "inconclusive_count", + "critical_count", + "high_count", + "is_complete" + ], + "properties": { + "target_count": { "type": "integer", "minimum": 1 }, + "resolved_target_count": { "type": "integer", "minimum": 0 }, + "endpoint_count": { "type": "integer", "minimum": 0 }, + "open_port_count": { "type": "integer", "minimum": 0 }, + "product_candidate_count": { "type": "integer", "minimum": 0 }, + "advisory_assessment_count": { "type": "integer", "minimum": 0 }, + "advisory_result_count": { "type": "integer", "minimum": 0, "maximum": 64 }, + "advisory_match_count": { "type": "integer", "minimum": 0 }, + "conditional_count": { "type": "integer", "minimum": 0 }, + "inconclusive_count": { "type": "integer", "minimum": 0 }, + "critical_count": { "type": "integer", "minimum": 0 }, + "high_count": { "type": "integer", "minimum": 0 }, + "is_complete": { "type": "boolean" } + } + } + } +} diff --git a/scripts/Test-DockerIntegration.ps1 b/scripts/Test-DockerIntegration.ps1 index 4a92b4b..b646b5b 100644 --- a/scripts/Test-DockerIntegration.ps1 +++ b/scripts/Test-DockerIntegration.ps1 @@ -8,12 +8,15 @@ labeled container, publishes temporary TCP and UDP echo ports, and removes its container and temporary files in a guarded finally block. By default both publications are loopback-only. -AllowWildcardUdp intentionally publishes the UDP echo service on 0.0.0.0 for bind-scope validation and can briefly expose it -to the local network. +to the local network. -ValidateRemoteScan additionally runs PortCVE's authorized +active scanner against only the temporary loopback TCP publication and proves a +generic echo service is not promoted to an application identity. #> [CmdletBinding()] param( [string]$PortCVEPath, [switch]$ValidateLockCheck, + [switch]$ValidateRemoteScan, [switch]$AllowWildcardUdp, [ValidateRange(5, 120)] [int]$TimeoutSeconds = 30 @@ -277,6 +280,34 @@ function Get-PortCVESnapshot { } } +function Test-RemoteEchoScan { + param([int]$HostPort) + + $description = 'PortCVE authorized Docker-forwarded TCP scan' + $capture = Invoke-CapturedCommand ` + -FilePath $script:PortCVEPath ` + -ArgumentList @( + 'scan-host', '127.0.0.1', + '--ports', [string]$HostPort, + '--authorized', '--active', '--json', '--include-private', + '--connect-timeout', '2s', '--read-timeout', '2s' + ) ` + -Description $description + $report = ConvertFrom-CapturedJson -Json $capture.StdOut -Description $description + $matches = @($report.hosts | + ForEach-Object { @($_.ports) } | + Where-Object { [int]$_.port -eq $HostPort }) + Assert-Condition ($matches.Count -eq 1) "$description did not return exactly one selected endpoint." + Assert-Condition ($matches[0].state -eq 'open') "$description did not report the Docker-forwarded endpoint open." + Assert-Condition (@($matches[0].product_candidates).Count -eq 0) ` + "$description promoted a generic echo service to an application identity." + + return [pscustomobject]@{ + state = [string]$matches[0].state + product_candidate_count = @($matches[0].product_candidates).Count + } +} + function Assert-DockerCollectorComplete { param( [Parameter(Mandatory = $true)] @@ -516,6 +547,11 @@ try { $lockChecks += Test-LockCheckRoundTrip -Protocol 'udp' -Port $udpHostPort } + $remoteScan = $null + if ($ValidateRemoteScan) { + $remoteScan = Test-RemoteEchoScan -HostPort $tcpHostPort + } + $result = [ordered]@{ status = 'passed' docker_server_version = $serverVersion @@ -542,6 +578,8 @@ try { default_json_redaction = 'passed' lock_check = if ($ValidateLockCheck) { 'passed' } else { 'skipped' } lock_checks = $lockChecks + authorized_remote_scan = if ($ValidateRemoteScan) { 'passed' } else { 'skipped' } + remote_scan = $remoteScan } } catch { diff --git a/scripts/Test-Performance.ps1 b/scripts/Test-Performance.ps1 new file mode 100644 index 0000000..411f64b --- /dev/null +++ b/scripts/Test-Performance.ps1 @@ -0,0 +1,211 @@ +#requires -Version 5.1 + +<# +.SYNOPSIS +Measures bounded PortCVE daily-workflow performance on the local Windows host. + +.DESCRIPTION +Runs repeated no-firewall local inventory and one passive, authorized loopback +scan over a high-port range. The remote leg never targets a LAN or Internet +address and sends no adaptive probes. Temporary output is held only in memory +and the script emits one compact JSON result. +#> +[CmdletBinding()] +param( + [string]$PortCVEPath, + [ValidateRange(3, 50)] + [int]$LocalIterations = 10, + [ValidateRange(64, 4096)] + [int]$RemotePortCount = 1000, + [switch]$EnforceBudgets, + [ValidateRange(100, 10000)] + [int]$LocalP95BudgetMilliseconds = 2000, + [ValidateRange(1000, 120000)] + [int]$RemoteBudgetMilliseconds = 30000, + [ValidateRange(128, 2048)] + [int]$PeakWorkingSetBudgetMiB = 768 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +if ([string]::IsNullOrWhiteSpace($PortCVEPath)) { + $PortCVEPath = Join-Path $repositoryRoot 'src\PortCVE\bin\Release\net10.0\win-x64\portcve.exe' +} +elseif (-not [IO.Path]::IsPathRooted($PortCVEPath)) { + $PortCVEPath = [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $PortCVEPath)) +} +else { + $PortCVEPath = [IO.Path]::GetFullPath($PortCVEPath) +} + +if (-not (Test-Path -LiteralPath $PortCVEPath -PathType Leaf)) { + throw "PortCVE executable was not found at '$PortCVEPath'." +} + +function ConvertTo-WindowsProcessArgument { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) + + if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { + return $Value + } + + $builder = [Text.StringBuilder]::new() + [void]$builder.Append('"') + $slashes = 0 + foreach ($character in $Value.ToCharArray()) { + if ($character -eq '\') { + $slashes++ + continue + } + + if ($character -eq '"') { + [void]$builder.Append(('\' * (($slashes * 2) + 1))) + [void]$builder.Append('"') + } + else { + if ($slashes -gt 0) { [void]$builder.Append(('\' * $slashes)) } + [void]$builder.Append($character) + } + $slashes = 0 + } + + if ($slashes -gt 0) { [void]$builder.Append(('\' * ($slashes * 2))) } + [void]$builder.Append('"') + return $builder.ToString() +} + +function Invoke-MeasuredPortCVE { + param( + [string[]]$Arguments, + [int]$TimeoutMilliseconds + ) + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $script:PortCVEPath + $startInfo.Arguments = (($Arguments | ForEach-Object { + ConvertTo-WindowsProcessArgument -Value ([string]$_) + }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + try { + if (-not $process.Start()) { throw 'PortCVE process did not start.' } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $peakWorkingSetBytes = 0L + while (-not $process.HasExited) { + $process.Refresh() + $peakWorkingSetBytes = [Math]::Max( + $peakWorkingSetBytes, + [long]$process.WorkingSet64) + if ($stopwatch.ElapsedMilliseconds -ge $TimeoutMilliseconds) { + try { $process.Kill() } catch { } + throw "PortCVE exceeded the $TimeoutMilliseconds ms performance-harness timeout." + } + Start-Sleep -Milliseconds 10 + } + $process.WaitForExit() + try { + $process.Refresh() + $peakWorkingSetBytes = [Math]::Max( + $peakWorkingSetBytes, + [long]$process.PeakWorkingSet64) + } + catch { + # The sampled working set remains valid if Windows has already + # released the exited process accounting record. + } + $stopwatch.Stop() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + if ($process.ExitCode -ne 0) { + throw "PortCVE exited $($process.ExitCode): $stderr" + } + + return [pscustomobject]@{ + ElapsedMilliseconds = [int][Math]::Ceiling($stopwatch.Elapsed.TotalMilliseconds) + PeakWorkingSetBytes = $peakWorkingSetBytes + StdOut = $stdout + } + } + finally { + $stopwatch.Stop() + $process.Dispose() + } +} + +function Get-Percentile95 { + param([int[]]$Values) + + $ordered = @($Values | Sort-Object) + $index = [Math]::Max(0, [Math]::Ceiling($ordered.Count * 0.95) - 1) + return [int]$ordered[$index] +} + +$localDurations = [Collections.Generic.List[int]]::new() +$peakWorkingSet = 0L +$lastLocal = $null +for ($iteration = 0; $iteration -lt $LocalIterations; $iteration++) { + $measurement = Invoke-MeasuredPortCVE ` + -Arguments @('list', '--json', '--no-firewall') ` + -TimeoutMilliseconds 30000 + [void]$localDurations.Add($measurement.ElapsedMilliseconds) + $peakWorkingSet = [Math]::Max($peakWorkingSet, $measurement.PeakWorkingSetBytes) + $lastLocal = $measurement.StdOut | ConvertFrom-Json +} + +$remoteStartPort = 49152 +$remoteEndPort = $remoteStartPort + $RemotePortCount - 1 +$remote = Invoke-MeasuredPortCVE ` + -Arguments @( + 'scan-host', '127.0.0.1', + '--ports', "$remoteStartPort-$remoteEndPort", + '--authorized', '--json', + '--rate', '10000', '--concurrency', '256', + '--connect-timeout', '250ms', '--read-timeout', '250ms' + ) ` + -TimeoutMilliseconds ([Math]::Max(60000, $RemoteBudgetMilliseconds * 2)) +$peakWorkingSet = [Math]::Max($peakWorkingSet, $remote.PeakWorkingSetBytes) +$remoteReport = $remote.StdOut | ConvertFrom-Json + +$localP95 = Get-Percentile95 -Values $localDurations.ToArray() +$peakMiB = [Math]::Round($peakWorkingSet / 1MB, 1) +if ($EnforceBudgets) { + if ($localP95 -gt $LocalP95BudgetMilliseconds) { + throw "Local inventory p95 ${localP95}ms exceeded the ${LocalP95BudgetMilliseconds}ms budget." + } + if ($remote.ElapsedMilliseconds -gt $RemoteBudgetMilliseconds) { + throw "Remote loopback scan $($remote.ElapsedMilliseconds)ms exceeded the ${RemoteBudgetMilliseconds}ms budget." + } + if ($peakMiB -gt $PeakWorkingSetBudgetMiB) { + throw "Peak working set ${peakMiB}MiB exceeded the ${PeakWorkingSetBudgetMiB}MiB budget." + } +} + +[ordered]@{ + status = 'passed' + windows_version = [Environment]::OSVersion.Version.ToString() + portcve_version = (& $PortCVEPath --version) + local_inventory = [ordered]@{ + iterations = $LocalIterations + endpoint_count = @($lastLocal.listeners).Count + minimum_ms = ($localDurations | Measure-Object -Minimum).Minimum + median_ms = [int](@($localDurations | Sort-Object)[[Math]::Floor($localDurations.Count / 2)]) + p95_ms = $localP95 + maximum_ms = ($localDurations | Measure-Object -Maximum).Maximum + } + passive_loopback_scan = [ordered]@{ + requested_ports = $RemotePortCount + reported_endpoints = [int]$remoteReport.summary.endpoint_count + elapsed_ms = $remote.ElapsedMilliseconds + } + peak_working_set_mib = $peakMiB + budgets_enforced = [bool]$EnforceBudgets +} | ConvertTo-Json -Depth 5 diff --git a/scripts/Test-RemoteHostIntegration.ps1 b/scripts/Test-RemoteHostIntegration.ps1 new file mode 100644 index 0000000..2547516 --- /dev/null +++ b/scripts/Test-RemoteHostIntegration.ps1 @@ -0,0 +1,712 @@ +#requires -Version 5.1 + +<# +.SYNOPSIS +Runs the PortCVE remote scanner against disposable loopback-only fixtures. + +.DESCRIPTION +Builds the Release PortCVE executable (unless -SkipBuild is specified), starts +SSH and HTTP fixtures on separate OS-assigned ports, then invokes scan-host +against exactly those two 127.0.0.1 ports. Discovery restraint, adaptive +safe-active identification, default redaction, private output, product parsing, +and the HTTP method allowlist are asserted. + +This harness never accepts a target parameter, never requests online advisory +data, and never connects to a non-loopback address. Every child process, socket, +and temporary file is cleaned in guarded finally blocks. +#> +[CmdletBinding()] +param( + [string]$PortCVEPath, + [switch]$SkipBuild, + [ValidateRange(10, 120)] + [int]$CommandTimeoutSeconds = 30, + [ValidateRange(30, 600)] + [int]$BuildTimeoutSeconds = 180 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$loopbackTarget = '127.0.0.1' +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$projectPath = Join-Path $repositoryRoot 'src\PortCVE\PortCVE.csproj' +$defaultExecutablePath = Join-Path $repositoryRoot 'src\PortCVE\bin\Release\net10.0\win-x64\portcve.exe' +$runId = [Guid]::NewGuid().ToString('N') +$temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$temporaryPrefix = $temporaryRoot.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +$integrationTempDirectory = [IO.Path]::GetFullPath((Join-Path $temporaryRoot "portcve-remote-it-$runId")) +$sshFixture = $null +$httpFixture = $null +$primaryError = $null +$cleanupErrors = [Collections.Generic.List[string]]::new() +$result = $null + +if (-not $integrationTempDirectory.StartsWith($temporaryPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use a remote integration directory outside '$temporaryRoot'." +} +if (Test-Path -LiteralPath $integrationTempDirectory) { + throw "Refusing to reuse existing integration directory '$integrationTempDirectory'." +} +[void](New-Item -ItemType Directory -Path $integrationTempDirectory) + +function Assert-Condition { + param( + [bool]$Condition, + [string]$Message + ) + + if (-not $Condition) { + throw "Assertion failed: $Message" + } +} + +function ConvertTo-WindowsProcessArgument { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) + + if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { + return $Value + } + + $quoted = [Text.StringBuilder]::new() + [void]$quoted.Append('"') + $backslashCount = 0 + foreach ($character in $Value.ToCharArray()) { + if ($character -eq '\') { + $backslashCount++ + continue + } + if ($character -eq '"') { + [void]$quoted.Append(('\' * (($backslashCount * 2) + 1))) + [void]$quoted.Append('"') + $backslashCount = 0 + continue + } + + if ($backslashCount -gt 0) { + [void]$quoted.Append(('\' * $backslashCount)) + $backslashCount = 0 + } + [void]$quoted.Append($character) + } + if ($backslashCount -gt 0) { + [void]$quoted.Append(('\' * ($backslashCount * 2))) + } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-BoundedProcess { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList, + [Parameter(Mandatory = $true)] + [string]$Description, + [Parameter(Mandatory = $true)] + [int]$TimeoutSeconds + ) + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.Arguments = (($ArgumentList | ForEach-Object { + ConvertTo-WindowsProcessArgument -Value ([string]$_) + }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $timedOut = $false + $stdoutTask = $null + $stderrTask = $null + try { + Assert-Condition $process.Start() "$Description process did not start." + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + $timedOut = $true + try { $process.Kill() } catch { } + [void]$process.WaitForExit(5000) + } + else { + # WaitForExit() without a timeout flushes redirected async stream state. + $process.WaitForExit() + } + + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + + if ($timedOut) { + throw "$Description exceeded its $TimeoutSeconds second limit and was terminated." + } + $maximumCapturedCharacters = 4 * 1024 * 1024 + if ($stdout.Length -gt $maximumCapturedCharacters -or $stderr.Length -gt $maximumCapturedCharacters) { + throw "$Description exceeded the 4 MiB per-stream capture limit." + } + if ($process.ExitCode -ne 0) { + $details = (($stderr + [Environment]::NewLine + $stdout).Trim()) + if ($details.Length -gt 4000) { + $details = $details.Substring(0, 4000) + '...' + } + throw "$Description failed with exit code $($process.ExitCode). $details" + } + + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout + StdErr = $stderr + } + } + finally { + try { + if (-not $process.HasExited) { + $process.Kill() + [void]$process.WaitForExit(5000) + } + } + finally { + $process.Dispose() + } + } +} + +function ConvertFrom-CapturedJson { + param( + [Parameter(Mandatory = $true)] + [string]$Json, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + try { + return $Json | ConvertFrom-Json + } + catch { + throw "$Description returned invalid JSON: $($_.Exception.Message)" + } +} + +function Get-RemotePortResult { + param( + [Parameter(Mandatory = $true)] + [object]$Report, + [Parameter(Mandatory = $true)] + [int]$Port, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $matches = @($Report.hosts | ForEach-Object { $_.ports } | Where-Object { [int]$_.port -eq $Port }) + Assert-Condition ($matches.Count -eq 1) "$Description did not contain exactly one result for TCP port $Port." + Assert-Condition ([string]$matches[0].state -ceq 'open') "$Description did not report TCP port $Port open." + return $matches[0] +} + +function Assert-Product { + param( + [Parameter(Mandatory = $true)] + [object]$PortResult, + [Parameter(Mandatory = $true)] + [string]$Product, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $matches = @($PortResult.product_candidates | Where-Object { + [string]$_.product -ceq $Product -and [string]$_.version -ceq $Version + }) + Assert-Condition ($matches.Count -ge 1) "$Description did not parse $Product $Version." +} + +function Assert-NoApplicationIdentity { + param( + [Parameter(Mandatory = $true)] + [object]$PortResult, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + Assert-Condition (@($PortResult.fingerprints).Count -eq 0) ` + "$Description unexpectedly fingerprinted a silent nonstandard service." + Assert-Condition (@($PortResult.product_candidates).Count -eq 0) ` + "$Description unexpectedly assigned a product to a silent nonstandard service." +} + +function Assert-AdaptiveHttpFingerprint { + param( + [Parameter(Mandatory = $true)] + [object]$PortResult, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $matches = @($PortResult.fingerprints | Where-Object { + [string]$_.kind -ceq 'http' -and + [string]$_.service -ceq 'http' -and + [string]$_.source -ceq 'active-adaptive-http-head' + }) + Assert-Condition ($matches.Count -eq 1) ` + "$Description did not retain exactly one adaptive HTTP fingerprint." +} + +function Assert-RequestLog { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$RequestLines, + [Parameter(Mandatory = $true)] + [hashtable]$ExpectedRequests, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $observed = @{} + foreach ($requestLine in $RequestLines) { + Assert-Condition ($requestLine -match '^(?[A-Z]+) (?\S+) HTTP/1\.[01]$') ` + "$Description recorded a malformed request line '$requestLine'." + $method = $Matches.method + $path = $Matches.path + Assert-Condition ($method -in @('HEAD', 'OPTIONS')) ` + "$Description used unsafe or unexpected HTTP method '$method'." + $key = "$method $path" + if (-not $observed.ContainsKey($key)) { $observed[$key] = 0 } + $observed[$key]++ + } + + Assert-Condition ($observed.Count -eq $ExpectedRequests.Count) ` + "$Description produced an unexpected set of HTTP requests: $($RequestLines -join ', ')." + foreach ($key in $ExpectedRequests.Keys) { + Assert-Condition ($observed.ContainsKey($key)) "$Description did not send expected request '$key'." + Assert-Condition ($observed[$key] -eq $ExpectedRequests[$key]) ` + "$Description sent '$key' $($observed[$key]) times; expected $($ExpectedRequests[$key])." + } +} + +$fixtureSource = @' +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace PortCVEIntegration +{ + public sealed class LoopbackFixture : IDisposable + { + private readonly TcpListener listener; + private readonly string protocol; + private readonly ConcurrentQueue requestLines = new ConcurrentQueue(); + private readonly Task acceptTask; + private int stopped; + private string error; + + private LoopbackFixture(TcpListener startedListener, string fixtureProtocol) + { + listener = startedListener; + protocol = fixtureProtocol; + acceptTask = Task.Factory.StartNew( + AcceptLoop, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + public int Port + { + get { return ((IPEndPoint)listener.LocalEndpoint).Port; } + } + + public string Error + { + get { return error; } + } + + public static LoopbackFixture StartSsh() + { + TcpListener candidate = new TcpListener(IPAddress.Loopback, 0); + candidate.Start(16); + return new LoopbackFixture(candidate, "ssh"); + } + + public static LoopbackFixture StartHttp() + { + TcpListener candidate = new TcpListener(IPAddress.Loopback, 0); + candidate.Start(16); + return new LoopbackFixture(candidate, "http"); + } + + public string[] GetRequestLines() + { + return requestLines.ToArray(); + } + + public void ResetRequestLines() + { + string ignored; + while (requestLines.TryDequeue(out ignored)) { } + } + + public bool Stop() + { + if (Interlocked.Exchange(ref stopped, 1) == 0) + { + try { listener.Stop(); } catch { } + } + + try + { + return acceptTask.Wait(5000); + } + catch (AggregateException exception) + { + error = exception.Flatten().InnerException == null + ? exception.Message + : exception.Flatten().InnerException.Message; + return false; + } + } + + public void Dispose() + { + Stop(); + } + + private void AcceptLoop() + { + while (Thread.VolatileRead(ref stopped) == 0) + { + TcpClient client = null; + try + { + client = listener.AcceptTcpClient(); + HandleClient(client); + } + catch (SocketException exception) + { + if (Thread.VolatileRead(ref stopped) == 0) + { + error = exception.Message; + return; + } + } + catch (ObjectDisposedException) + { + if (Thread.VolatileRead(ref stopped) == 0) + { + error = "The listener was disposed unexpectedly."; + return; + } + } + catch (Exception exception) + { + error = exception.Message; + return; + } + finally + { + if (client != null) { client.Close(); } + } + } + } + + private void HandleClient(TcpClient client) + { + client.NoDelay = true; + client.ReceiveTimeout = 5000; + client.SendTimeout = 5000; + using (NetworkStream stream = client.GetStream()) + { + if (StringComparer.Ordinal.Equals(protocol, "ssh")) + { + byte[] banner = Encoding.ASCII.GetBytes("SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.13\r\n"); + stream.Write(banner, 0, banner.Length); + stream.Flush(); + return; + } + + string request = ReadHeaderBlock(stream, 16384); + string requestLine = FirstLine(request); + if (String.IsNullOrEmpty(requestLine)) + { + // Discovery and the first active connection only wait for a + // greeting. A silent HTTP fixture must not speak until the + // adaptive connection sends a bounded request. + return; + } + requestLines.Enqueue(requestLine); + + byte[] response = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + "Server: Apache/2.4.58\r\n" + + "Allow: HEAD, OPTIONS\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n\r\n"); + stream.Write(response, 0, response.Length); + stream.Flush(); + } + } + + private static string ReadHeaderBlock(Stream stream, int maximumBytes) + { + MemoryStream captured = new MemoryStream(); + try + { + int state = 0; + while (captured.Length < maximumBytes) + { + int value = stream.ReadByte(); + if (value < 0) { break; } + captured.WriteByte((byte)value); + if ((state == 0 || state == 2) && value == 13) { state++; } + else if ((state == 1 || state == 3) && value == 10) { state++; } + else { state = value == 13 ? 1 : 0; } + if (state == 4) { break; } + } + return Encoding.ASCII.GetString(captured.ToArray()); + } + finally + { + captured.Dispose(); + } + } + + private static string FirstLine(string value) + { + int lineEnd = value.IndexOf("\r\n", StringComparison.Ordinal); + if (lineEnd < 0) { lineEnd = value.IndexOf('\n'); } + return lineEnd < 0 ? value.Trim() : value.Substring(0, lineEnd).Trim(); + } + } +} +'@ + +try { + Add-Type -TypeDefinition $fixtureSource -Language CSharp + + if ([string]::IsNullOrWhiteSpace($PortCVEPath)) { + $PortCVEPath = $defaultExecutablePath + } + elseif (-not [IO.Path]::IsPathRooted($PortCVEPath)) { + $PortCVEPath = [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $PortCVEPath)) + } + else { + $PortCVEPath = [IO.Path]::GetFullPath($PortCVEPath) + } + + if (-not $SkipBuild) { + $dotnetCommand = Get-Command dotnet.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1 + [void](Invoke-BoundedProcess ` + -FilePath $dotnetCommand.Path ` + -ArgumentList @('build', $projectPath, '--configuration', 'Release', '--nologo') ` + -Description 'PortCVE Release build' ` + -TimeoutSeconds $BuildTimeoutSeconds) + } + + Assert-Condition (Test-Path -LiteralPath $PortCVEPath -PathType Leaf) ` + "PortCVE Release executable was not found at '$PortCVEPath'." + + $sshFixture = [PortCVEIntegration.LoopbackFixture]::StartSsh() + $httpFixture = [PortCVEIntegration.LoopbackFixture]::StartHttp() + $sshPort = [int]$sshFixture.Port + $httpPort = [int]$httpFixture.Port + Assert-Condition ($sshPort -ne $httpPort) 'The SSH and HTTP fixtures selected the same TCP port.' + $configuredProbePorts = @( + 80, 81, 3000, 5000, 8000, 8008, 8080, 8081, 8888, + 443, 465, 636, 853, 989, 990, 992, 993, 994, 995, 8443, 9443) + Assert-Condition ($httpPort -notin $configuredProbePorts) ` + "The OS assigned HTTP port $httpPort overlaps a configured protocol port; adaptive dispatch was not isolated." + $portSelector = '{0},{1}' -f $sshPort, $httpPort + + $baseArguments = @( + 'scan-host', $loopbackTarget, + '--authorized', + '--ports', $portSelector, + '--json', + '--concurrency', '2', + '--rate', '50', + '--connect-timeout', '2s', + '--read-timeout', '2s' + ) + + $httpFixture.ResetRequestLines() + $defaultCapture = Invoke-BoundedProcess ` + -FilePath $PortCVEPath ` + -ArgumentList $baseArguments ` + -Description 'redacted discovery scan' ` + -TimeoutSeconds $CommandTimeoutSeconds + $defaultReport = ConvertFrom-CapturedJson -Json $defaultCapture.StdOut -Description 'redacted discovery scan' + $defaultSsh = Get-RemotePortResult -Report $defaultReport -Port $sshPort -Description 'redacted discovery scan' + $defaultHttp = Get-RemotePortResult -Report $defaultReport -Port $httpPort -Description 'redacted discovery scan' + Assert-Product -PortResult $defaultSsh -Product 'OpenSSH' -Version '9.6p1' -Description 'redacted discovery scan' + Assert-NoApplicationIdentity -PortResult $defaultHttp -Description 'redacted discovery scan' + Assert-Condition ([string]$defaultReport.probe_profile -ceq 'discovery') 'Default scan did not use the discovery profile.' + Assert-Condition ([bool]$defaultReport.authorization_asserted) 'Default scan did not record the authorization assertion.' + Assert-Condition (@($defaultReport.requested_ports).Count -eq 2) 'Default scan did not retain exactly two requested ports.' + Assert-Condition (-not $defaultCapture.StdOut.Contains($loopbackTarget)) 'Default JSON exposed the raw loopback target/address.' + Assert-Condition (-not $defaultCapture.StdOut.Contains('SSH-2.0-OpenSSH_9.6p1')) 'Default JSON exposed the raw SSH banner.' + Assert-Condition (-not $defaultCapture.StdOut.Contains('Server: Apache/2.4.58')) 'Default JSON exposed the raw HTTP server evidence.' + Assert-RequestLog ` + -RequestLines @($httpFixture.GetRequestLines()) ` + -ExpectedRequests @{} ` + -Description 'redacted discovery scan' + + $httpFixture.ResetRequestLines() + $privateCapture = Invoke-BoundedProcess ` + -FilePath $PortCVEPath ` + -ArgumentList ($baseArguments + @('--include-private')) ` + -Description 'private discovery scan' ` + -TimeoutSeconds $CommandTimeoutSeconds + $privateReport = ConvertFrom-CapturedJson -Json $privateCapture.StdOut -Description 'private discovery scan' + $privateSsh = Get-RemotePortResult -Report $privateReport -Port $sshPort -Description 'private discovery scan' + $privateHttp = Get-RemotePortResult -Report $privateReport -Port $httpPort -Description 'private discovery scan' + Assert-Product -PortResult $privateSsh -Product 'OpenSSH' -Version '9.6p1' -Description 'private discovery scan' + Assert-NoApplicationIdentity -PortResult $privateHttp -Description 'private discovery scan' + Assert-Condition ([string]$privateReport.selector -ceq $loopbackTarget) 'Private JSON did not retain the explicit target.' + Assert-Condition ($privateCapture.StdOut.Contains('SSH-2.0-OpenSSH_9.6p1')) 'Private JSON did not retain the SSH evidence.' + Assert-RequestLog ` + -RequestLines @($httpFixture.GetRequestLines()) ` + -ExpectedRequests @{} ` + -Description 'private discovery scan' + + $httpFixture.ResetRequestLines() + $redactedActiveCapture = Invoke-BoundedProcess ` + -FilePath $PortCVEPath ` + -ArgumentList ($baseArguments + @('--active')) ` + -Description 'redacted safe-active scan' ` + -TimeoutSeconds $CommandTimeoutSeconds + $redactedActiveReport = ConvertFrom-CapturedJson ` + -Json $redactedActiveCapture.StdOut ` + -Description 'redacted safe-active scan' + $redactedActiveHttp = Get-RemotePortResult ` + -Report $redactedActiveReport ` + -Port $httpPort ` + -Description 'redacted safe-active scan' + Assert-Product ` + -PortResult $redactedActiveHttp ` + -Product 'Apache HTTP Server' ` + -Version '2.4.58' ` + -Description 'redacted safe-active scan' + Assert-AdaptiveHttpFingerprint -PortResult $redactedActiveHttp -Description 'redacted safe-active scan' + Assert-Condition (-not $redactedActiveCapture.StdOut.Contains($loopbackTarget)) ` + 'Default active JSON exposed the raw loopback target/address.' + Assert-Condition (-not $redactedActiveCapture.StdOut.Contains('Server: Apache/2.4.58')) ` + 'Default active JSON exposed the raw adaptive HTTP evidence.' + Assert-RequestLog ` + -RequestLines @($httpFixture.GetRequestLines()) ` + -ExpectedRequests @{ 'HEAD /' = 1 } ` + -Description 'redacted safe-active scan' + + $httpFixture.ResetRequestLines() + $activeCapture = Invoke-BoundedProcess ` + -FilePath $PortCVEPath ` + -ArgumentList ($baseArguments + @('--active', '--include-private')) ` + -Description 'private safe-active scan' ` + -TimeoutSeconds $CommandTimeoutSeconds + $activeReport = ConvertFrom-CapturedJson -Json $activeCapture.StdOut -Description 'private safe-active scan' + $activeSsh = Get-RemotePortResult -Report $activeReport -Port $sshPort -Description 'private safe-active scan' + $activeHttp = Get-RemotePortResult -Report $activeReport -Port $httpPort -Description 'private safe-active scan' + Assert-Product -PortResult $activeSsh -Product 'OpenSSH' -Version '9.6p1' -Description 'private safe-active scan' + Assert-Product -PortResult $activeHttp -Product 'Apache HTTP Server' -Version '2.4.58' -Description 'private safe-active scan' + Assert-AdaptiveHttpFingerprint -PortResult $activeHttp -Description 'private safe-active scan' + Assert-Condition ([string]$activeReport.probe_profile -ceq 'safe_active') 'Active scan did not use the safe_active profile.' + Assert-Condition ($activeCapture.StdOut.Contains('Server: Apache/2.4.58')) ` + 'Private active JSON did not retain the adaptive HTTP evidence.' + $activeRequests = @($httpFixture.GetRequestLines()) + Assert-RequestLog ` + -RequestLines $activeRequests ` + -ExpectedRequests @{ 'HEAD /' = 1 } ` + -Description 'private safe-active scan' + + Assert-Condition ([string]::IsNullOrEmpty($sshFixture.Error)) "SSH fixture failed: $($sshFixture.Error)" + Assert-Condition ([string]::IsNullOrEmpty($httpFixture.Error)) "HTTP fixture failed: $($httpFixture.Error)" + + $result = [ordered]@{ + status = 'passed' + powershell_version = $PSVersionTable.PSVersion.ToString() + portcve_path = $PortCVEPath + portcve_version = [string]$activeReport.tool_version + target = $loopbackTarget + requested_ports = @($sshPort, $httpPort) + ssh = [ordered]@{ + port = $sshPort + state = [string]$activeSsh.state + product = 'OpenSSH' + version = '9.6p1' + } + http = [ordered]@{ + port = $httpPort + state = [string]$activeHttp.state + product = 'Apache HTTP Server' + version = '2.4.58' + fingerprint_source = 'active-adaptive-http-head' + active_request_lines = $activeRequests + } + discovery = 'passed (silent nonstandard HTTP left unidentified)' + safe_active_adaptive_http = 'passed' + default_json_redaction = 'passed' + private_json_evidence = 'passed' + unsafe_http_methods_observed = @() + online_advisories = 'not requested' + fixture_cleanup = 'pending' + temporary_file_cleanup = 'pending' + } +} +catch { + $primaryError = $_ +} +finally { + foreach ($fixture in @($sshFixture, $httpFixture)) { + if ($null -eq $fixture) { continue } + try { + $stopped = $fixture.Stop() + if (-not $stopped) { + throw "Fixture did not stop cleanly: $($fixture.Error)" + } + $fixture.Dispose() + } + catch { + [void]$cleanupErrors.Add("Loopback fixture cleanup failed: $($_.Exception.Message)") + } + } + + try { + if (Test-Path -LiteralPath $integrationTempDirectory) { + $resolvedTemporaryDirectory = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $integrationTempDirectory).Path) + Assert-Condition ` + ($resolvedTemporaryDirectory.Equals($integrationTempDirectory, [StringComparison]::OrdinalIgnoreCase)) ` + 'Temporary cleanup target changed unexpectedly.' + Assert-Condition ` + ($resolvedTemporaryDirectory.StartsWith($temporaryPrefix, [StringComparison]::OrdinalIgnoreCase)) ` + 'Temporary cleanup target escaped the system temporary directory.' + Remove-Item -LiteralPath $resolvedTemporaryDirectory -Recurse -Force + } + } + catch { + [void]$cleanupErrors.Add("Temporary-file cleanup failed: $($_.Exception.Message)") + } +} + +if ($cleanupErrors.Count -gt 0) { + $message = $cleanupErrors -join ' ' + if ($null -ne $primaryError) { + $message = "$($primaryError.Exception.Message) Cleanup errors: $message" + } + throw $message +} +if ($null -ne $primaryError) { + throw $primaryError +} + +$result.fixture_cleanup = 'passed' +$result.temporary_file_cleanup = 'passed' +$result | ConvertTo-Json -Depth 8 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1eb2596..865f27d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2,7 +2,7 @@ <# .SYNOPSIS -Installs a signed PortCVE release for the current Windows user. +Installs, updates, or uninstalls PortCVE for the current Windows user. .DESCRIPTION Downloads a versioned ZIP and SHA256SUMS.txt from the official @@ -10,12 +10,16 @@ Labeeb2339/PortCVE GitHub release, verifies the ZIP checksum, then requires a trusted Authenticode signature, the release-bound signer subject, the Code Signing EKU, and a trusted timestamp before installing portcve.exe. +With -Uninstall, the same signed script removes only a receipt-bound PortCVE +installation and its exact user PATH entry without making a network request. + This script has no unsigned, local-asset, or signature-bypass mode. #> [CmdletBinding()] param( [string]$Version, - [string]$InstallDirectory + [string]$InstallDirectory, + [switch]$Uninstall ) Set-StrictMode -Version Latest @@ -28,6 +32,7 @@ $script:ReleaseTagPattern = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0 $script:InstallerUserAgent = 'PortCVE-Installer/1.0' $script:ApiLimitBytes = 2MB $script:ChecksumLimitBytes = 128KB +$script:ReceiptLimitBytes = 64KB $script:ZipLimitBytes = 256MB $script:ExecutableLimitBytes = 256MB $script:MaximumArchiveEntries = 256 @@ -328,17 +333,67 @@ function Get-CanonicalPath { function Assert-SafeInstallTarget { param([Parameter(Mandatory = $true)][string]$Path) + foreach ($rawComponent in ($Path -split '[\\/]')) { + if ([string]::IsNullOrEmpty($rawComponent) -or $rawComponent -eq '.' -or $rawComponent -eq '..' -or + $rawComponent -match '^[A-Za-z]:$') { + continue + } + if ($rawComponent.EndsWith('.', [StringComparison]::Ordinal) -or + $rawComponent.EndsWith(' ', [StringComparison]::Ordinal)) { + throw "Install directory '$Path' contains a component ending in a dot or space." + } + } + $full = Get-CanonicalPath $Path - $root = [IO.Path]::GetPathRoot($full).TrimEnd('\', '/') + $pathRoot = [IO.Path]::GetPathRoot($full) + if ([string]::IsNullOrWhiteSpace($pathRoot) -or $pathRoot -notmatch '^[A-Za-z]:[\\/]$') { + throw "Install directory '$full' must be on a local Windows drive." + } + $drive = [IO.DriveInfo]::new($pathRoot) + if ($drive.DriveType -ne [IO.DriveType]::Fixed) { + throw "Install directory '$full' must be on a fixed local Windows drive." + } + $root = $pathRoot.TrimEnd('\', '/') if ([string]::IsNullOrWhiteSpace($full) -or $full.TrimEnd('\', '/') -eq $root -or $full.Length -gt 220 ` - -or $full.Contains(';') -or $full.Contains('"')) { + -or $full.Contains(';') -or $full.Contains('"') -or $full.Contains('%')) { throw "Install directory '$full' is unsafe or too long." } + $relativePath = $full.Substring($pathRoot.Length) + foreach ($component in ($relativePath -split '[\\/]')) { + if ([string]::IsNullOrWhiteSpace($component) -or + $component.EndsWith('.', [StringComparison]::Ordinal) -or + $component.EndsWith(' ', [StringComparison]::Ordinal) -or + $component.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) { + throw "Install directory '$full' contains an unsafe path component." + } + $deviceStem = ($component -split '\.', 2)[0].TrimEnd(' ') + if ($deviceStem -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') { + throw "Install directory '$full' contains a reserved Windows device name." + } + } + + $cursor = $full + while (-not [string]::IsNullOrWhiteSpace($cursor)) { + if (Test-Path -LiteralPath $cursor) { + $cursorItem = Get-Item -LiteralPath $cursor -Force + if (($cursorItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Install directory '$full' must not traverse reparse point '$cursor'." + } + if (-not $cursorItem.PSIsContainer -and -not [string]::Equals($cursor, $full, [StringComparison]::OrdinalIgnoreCase)) { + throw "Install directory '$full' has a non-directory ancestor '$cursor'." + } + } + if ([string]::Equals($cursor.TrimEnd('\', '/'), $root, [StringComparison]::OrdinalIgnoreCase)) { break } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent -or [string]::Equals($parent.FullName, $cursor, [StringComparison]::OrdinalIgnoreCase)) { break } + $cursor = $parent.FullName + } + if (Test-Path -LiteralPath $full -PathType Leaf) { throw "Install target '$full' is a file." } if (Test-Path -LiteralPath $full -PathType Container) { $item = Get-Item -LiteralPath $full -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Install target must not be a reparse point.' } - $allowed = @('portcve.exe', 'install-receipt.json') + $allowed = @('portcve.exe', 'install.ps1', 'install-receipt.json') foreach ($child in Get-ChildItem -LiteralPath $full -Force) { if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $child.PSIsContainer -or $allowed -cnotcontains $child.Name) { throw "Install target contains unmanaged entry '$($child.Name)'; refusing to replace or delete it." @@ -366,6 +421,161 @@ function Get-UpdatedUserPath { return $updated } +function Get-UserPathWithoutInstall { + param( + [AllowNull()][string]$CurrentPath, + [Parameter(Mandatory = $true)][string]$InstallPath + ) + + if ($null -eq $CurrentPath) { return '' } + $canonicalInstall = Get-CanonicalPath $InstallPath + $segments = [regex]::Split($CurrentPath, ';') + $kept = New-Object 'Collections.Generic.List[string]' + $removed = $false + foreach ($entry in $segments) { + $candidate = $entry.Trim().Trim('"') + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + try { + $candidate = Get-CanonicalPath ([Environment]::ExpandEnvironmentVariables($candidate)) + if ([string]::Equals($candidate, $canonicalInstall, [StringComparison]::OrdinalIgnoreCase)) { + $removed = $true + continue + } + } + catch { + # Preserve unrelated malformed or unresolvable PATH entries verbatim. + } + } + $kept.Add($entry) + } + if (-not $removed) { return $CurrentPath } + return $kept -join ';' +} + +function Read-PortCVEInstallReceipt { + param( + [Parameter(Mandatory = $true)][string]$InstallPath + ) + + $receiptPath = Join-Path $InstallPath 'install-receipt.json' + if (-not (Test-Path -LiteralPath $receiptPath -PathType Leaf)) { + throw "Install target '$InstallPath' has no PortCVE installation receipt." + } + + try { + $receipt = Read-BoundedUtf8File -Path $receiptPath -MaximumBytes $script:ReceiptLimitBytes | ConvertFrom-Json + } + catch { + throw "Install target '$InstallPath' has an invalid PortCVE installation receipt: $($_.Exception.Message)" + } + + $expectedProperties = @( + 'schema_version', + 'product', + 'version', + 'repository', + 'install_path', + 'zip_asset', + 'zip_sha256', + 'executable_sha256', + 'installer_sha256', + 'signer_subject', + 'timestamp_subject', + 'installed_at_utc' + ) + $actualProperties = @($receipt.PSObject.Properties | ForEach-Object { $_.Name }) + if ($actualProperties.Count -ne $expectedProperties.Count) { + throw "Install target '$InstallPath' has an unexpected receipt shape." + } + foreach ($expectedProperty in $expectedProperties) { + if (@($actualProperties | Where-Object { [string]::Equals($_, $expectedProperty, [StringComparison]::Ordinal) }).Count -ne 1) { + throw "Install target '$InstallPath' is missing exact receipt field '$expectedProperty'." + } + } + + if ([int]$receipt.schema_version -ne 1 -or + -not [string]::Equals([string]$receipt.product, 'PortCVE', [StringComparison]::Ordinal) -or + -not [string]::Equals([string]$receipt.repository, $script:Repository, [StringComparison]::Ordinal)) { + throw "Install target '$InstallPath' is not a receipt-bound PortCVE installation." + } + $receiptVersion = [string]$receipt.version + if (-not [regex]::IsMatch($receiptVersion, $script:ReleaseTagPattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant) -or + -not [string]::Equals([string]$receipt.zip_asset, "portcve-$receiptVersion-win-x64.zip", [StringComparison]::Ordinal)) { + throw "Install target '$InstallPath' has inconsistent release identity in its receipt." + } + if (-not [string]::Equals((Get-CanonicalPath ([string]$receipt.install_path)), (Get-CanonicalPath $InstallPath), [StringComparison]::OrdinalIgnoreCase)) { + throw "Install target '$InstallPath' does not match the path recorded in its receipt." + } + if ([string]$receipt.zip_sha256 -notmatch '^[0-9a-f]{64}$' -or + [string]$receipt.executable_sha256 -notmatch '^[0-9a-f]{64}$' -or + [string]$receipt.installer_sha256 -notmatch '^[0-9a-f]{64}$' -or + [string]::IsNullOrWhiteSpace([string]$receipt.signer_subject) -or + [string]::IsNullOrWhiteSpace([string]$receipt.timestamp_subject)) { + throw "Install target '$InstallPath' has invalid integrity or signer metadata in its receipt." + } + $installedAt = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParse( + [string]$receipt.installed_at_utc, + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$installedAt) -or $installedAt.Offset -ne [TimeSpan]::Zero) { + throw "Install target '$InstallPath' has an invalid UTC installation time in its receipt." + } + return $receipt +} + +function Assert-ManagedInstallation { + param([Parameter(Mandatory = $true)][string]$InstallPath) + + $full = Assert-SafeInstallTarget $InstallPath + if (-not (Test-Path -LiteralPath $full -PathType Container)) { + throw "PortCVE is not installed at '$full'." + } + $children = @(Get-ChildItem -LiteralPath $full -Force) + $expectedNames = @('install-receipt.json', 'install.ps1', 'portcve.exe') + if ($children.Count -ne $expectedNames.Count) { + throw "Install target '$full' is not an exact managed PortCVE installation." + } + foreach ($expectedName in $expectedNames) { + if (@($children | Where-Object { -not $_.PSIsContainer -and [string]::Equals($_.Name, $expectedName, [StringComparison]::Ordinal) }).Count -ne 1) { + throw "Install target '$full' is missing managed file '$expectedName'." + } + } + if ((Get-Item -LiteralPath (Join-Path $full 'portcve.exe')).Length -le 0) { + throw "Install target '$full' contains an empty portcve.exe." + } + if ((Get-Item -LiteralPath (Join-Path $full 'install.ps1')).Length -le 0) { + throw "Install target '$full' contains an empty install.ps1." + } + $receipt = Read-PortCVEInstallReceipt -InstallPath $full + $actualExecutableHash = Get-Sha256 (Join-Path $full 'portcve.exe') + $actualInstallerHash = Get-Sha256 (Join-Path $full 'install.ps1') + if (-not [string]::Equals($actualExecutableHash, [string]$receipt.executable_sha256, [StringComparison]::Ordinal) -or + -not [string]::Equals($actualInstallerHash, [string]$receipt.installer_sha256, [StringComparison]::Ordinal)) { + throw "Install target '$full' no longer matches the executable and installer hashes in its receipt." + } + $executableSignature = Assert-TrustedReleaseExecutable (Join-Path $full 'portcve.exe') + $null = Assert-TrustedInstallerFile (Join-Path $full 'install.ps1') + if (-not [string]::Equals([string]$receipt.signer_subject, $executableSignature.SignerCertificate.Subject, [StringComparison]::Ordinal) -or + -not [string]::Equals([string]$receipt.timestamp_subject, $executableSignature.TimeStamperCertificate.Subject, [StringComparison]::Ordinal)) { + throw "Install target '$full' no longer matches the signer and timestamp identities in its receipt." + } + return $full +} + +function Assert-InstallTargetReadyForCommit { + param([Parameter(Mandatory = $true)][string]$InstallPath) + + $full = Assert-SafeInstallTarget $InstallPath + if (Test-Path -LiteralPath $full -PathType Container) { + $children = @(Get-ChildItem -LiteralPath $full -Force) + if ($children.Count -gt 0) { + return Assert-ManagedInstallation $full + } + } + return $full +} + function Assert-ManagedDirectory { param( [Parameter(Mandatory = $true)][string]$Candidate, @@ -390,7 +600,13 @@ function Remove-ManagedDirectory { ) $full = Assert-ManagedDirectory -Candidate $Candidate -ExpectedParent $ExpectedParent -ExpectedLeaf $ExpectedLeaf - if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force } + if (Test-Path -LiteralPath $full) { + $item = Get-Item -LiteralPath $full -Force + if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing cleanup of non-directory or reparse-point path '$full'." + } + Remove-Item -LiteralPath $full -Recurse -Force + } } function Invoke-AtomicInstall { @@ -419,7 +635,6 @@ function Invoke-AtomicInstall { [Environment]::SetEnvironmentVariable('Path', $UpdatedUserPath, [EnvironmentVariableTarget]::User) $pathChanged = $true } - if ($hadExisting) { Remove-ManagedDirectory -Candidate $backup -ExpectedParent $parent -ExpectedLeaf $backupLeaf } } catch { $failure = $_ @@ -445,12 +660,111 @@ function Invoke-AtomicInstall { } throw $failure } + + if ($hadExisting) { + try { + Remove-ManagedDirectory -Candidate $backup -ExpectedParent $parent -ExpectedLeaf $backupLeaf + } + catch { + throw "PortCVE was updated, but the previous-version backup could not be removed from '$backup': $($_.Exception.Message)" + } + } +} + +function Invoke-AtomicUninstall { + param( + [Parameter(Mandatory = $true)][string]$InstallPath, + [Parameter(Mandatory = $true)][string]$Token, + [Parameter(Mandatory = $true)][string]$OriginalUserPath, + [Parameter(Mandatory = $true)][string]$UpdatedUserPath + ) + + $parent = Split-Path -Parent $InstallPath + $leaf = Split-Path -Leaf $InstallPath + $quarantineLeaf = "$leaf.uninstall-$Token" + $quarantine = Join-Path $parent $quarantineLeaf + if (Test-Path -LiteralPath $quarantine) { + throw "Uninstall quarantine path '$quarantine' already exists." + } + + $moved = $false + $pathChanged = $false + try { + [IO.Directory]::Move($InstallPath, $quarantine) + $moved = $true + if (-not [string]::Equals($OriginalUserPath, $UpdatedUserPath, [StringComparison]::Ordinal)) { + [Environment]::SetEnvironmentVariable('Path', $UpdatedUserPath, [EnvironmentVariableTarget]::User) + $pathChanged = $true + } + } + catch { + $failure = $_ + $rollbackErrors = New-Object 'Collections.Generic.List[string]' + if ($pathChanged) { + try { [Environment]::SetEnvironmentVariable('Path', $OriginalUserPath, [EnvironmentVariableTarget]::User) } + catch { $rollbackErrors.Add("PATH rollback failed: $($_.Exception.Message)") } + } + if ($moved -and (Test-Path -LiteralPath $quarantine -PathType Container) -and -not (Test-Path -LiteralPath $InstallPath)) { + try { [IO.Directory]::Move($quarantine, $InstallPath) } + catch { $rollbackErrors.Add("installation restore failed: $($_.Exception.Message)") } + } + if ($rollbackErrors.Count -gt 0) { + throw "Uninstallation failed: $($failure.Exception.Message). Rollback was incomplete: $($rollbackErrors -join '; ')" + } + throw $failure + } + + try { + Remove-ManagedDirectory -Candidate $quarantine -ExpectedParent $parent -ExpectedLeaf $quarantineLeaf + } + catch { + throw "PortCVE was removed from the user PATH, but quarantined files could not be deleted from '$quarantine': $($_.Exception.Message)" + } +} + +function Invoke-PortCVEUninstall { + param([Parameter(Mandatory = $true)][string]$InstallPath) + + $installPath = Assert-SafeInstallTarget $InstallPath + $currentLocation = Get-Location + if ($null -ne $currentLocation.Provider -and + [string]::Equals($currentLocation.Provider.Name, 'FileSystem', [StringComparison]::OrdinalIgnoreCase)) { + $currentPath = Get-CanonicalPath $currentLocation.ProviderPath + $installPrefix = $installPath + [IO.Path]::DirectorySeparatorChar + if ([string]::Equals($currentPath, $installPath, [StringComparison]::OrdinalIgnoreCase) -or + $currentPath.StartsWith($installPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Change to a directory outside '$installPath' before uninstalling PortCVE." + } + } + $originalUserPath = [Environment]::GetEnvironmentVariable('Path', [EnvironmentVariableTarget]::User) + if ($null -eq $originalUserPath) { $originalUserPath = '' } + $updatedUserPath = Get-UserPathWithoutInstall -CurrentPath $originalUserPath -InstallPath $installPath + + if (-not (Test-Path -LiteralPath $installPath -PathType Container)) { + if (-not [string]::Equals($originalUserPath, $updatedUserPath, [StringComparison]::Ordinal)) { + [Environment]::SetEnvironmentVariable('Path', $updatedUserPath, [EnvironmentVariableTarget]::User) + } + Write-Host "PortCVE is not installed at '$installPath'; any exact stale user PATH entry was removed." + return + } + + $installPath = Assert-ManagedInstallation $installPath + $token = [Guid]::NewGuid().ToString('N') + Invoke-AtomicUninstall ` + -InstallPath $installPath ` + -Token $token ` + -OriginalUserPath $originalUserPath ` + -UpdatedUserPath $updatedUserPath + + Write-Host "PortCVE was uninstalled from '$installPath'." + Write-Host 'Open a new terminal to use the updated user PATH.' } function Invoke-PortCVEInstall { param( [string]$Version, [string]$InstallDirectory, + [switch]$Uninstall, [AllowNull()][string]$InstallerPath ) @@ -461,17 +775,27 @@ function Invoke-PortCVEInstall { throw 'PortCVE installation must run from the signed install.ps1 file; piped or in-memory execution is refused.' } $null = Assert-TrustedInstallerFile -Path $InstallerPath + $resolvedInstallerPath = (Resolve-Path -LiteralPath $InstallerPath -ErrorAction Stop).Path if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitOperatingSystem) { throw 'PortCVE installer supports 64-bit Windows only.' } + if ($Uninstall -and -not [string]::IsNullOrWhiteSpace($Version)) { + throw '-Version cannot be combined with -Uninstall.' + } + if ([string]::IsNullOrWhiteSpace($InstallDirectory)) { $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($localAppData)) { throw 'LocalAppData could not be resolved.' } $InstallDirectory = Join-Path $localAppData 'Programs\PortCVE' } $installPath = Assert-SafeInstallTarget $InstallDirectory + if ($Uninstall) { + Invoke-PortCVEUninstall -InstallPath $installPath + return + } + $installPath = Assert-InstallTargetReadyForCommit $installPath $installParent = Split-Path -Parent $installPath [IO.Directory]::CreateDirectory($installParent) | Out-Null @@ -506,12 +830,18 @@ function Invoke-PortCVEInstall { [IO.Directory]::CreateDirectory($staging) | Out-Null [IO.File]::Copy($executable, (Join-Path $staging 'portcve.exe'), $false) $null = Assert-TrustedReleaseExecutable (Join-Path $staging 'portcve.exe') + [IO.File]::Copy($resolvedInstallerPath, (Join-Path $staging 'install.ps1'), $false) + $null = Assert-TrustedInstallerFile (Join-Path $staging 'install.ps1') $receipt = [ordered]@{ + schema_version = 1 product = 'PortCVE' version = $release.Tag repository = $script:Repository + install_path = $installPath zip_asset = $release.ZipName zip_sha256 = $actualHash + executable_sha256 = Get-Sha256 (Join-Path $staging 'portcve.exe') + installer_sha256 = Get-Sha256 (Join-Path $staging 'install.ps1') signer_subject = $signature.SignerCertificate.Subject timestamp_subject = $signature.TimeStamperCertificate.Subject installed_at_utc = [DateTime]::UtcNow.ToString('o') @@ -521,6 +851,7 @@ function Invoke-PortCVEInstall { $originalUserPath = [Environment]::GetEnvironmentVariable('Path', [EnvironmentVariableTarget]::User) if ($null -eq $originalUserPath) { $originalUserPath = '' } $updatedUserPath = Get-UpdatedUserPath -CurrentPath $originalUserPath -InstallPath $installPath + $installPath = Assert-InstallTargetReadyForCommit $installPath Invoke-AtomicInstall -InstallPath $installPath -StagingPath $staging -Token $token -OriginalUserPath $originalUserPath -UpdatedUserPath $updatedUserPath Write-Host "PortCVE $($release.Tag) installed to '$installPath'." diff --git a/scripts/tests/Test-Installer.ps1 b/scripts/tests/Test-Installer.ps1 index 3cff856..1c99f71 100644 --- a/scripts/tests/Test-Installer.ps1 +++ b/scripts/tests/Test-Installer.ps1 @@ -38,9 +38,10 @@ $placeholderMatches = [regex]::Matches($source, [regex]::Escape('__PORTCVE_EXPEC Assert-True ($placeholderMatches.Count -eq 1) 'Installer must contain exactly one release-time signer placeholder.' $parameterNames = @($ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath }) -Assert-True (($parameterNames -join ',') -ceq 'Version,InstallDirectory') 'Installer exposed an unexpected production parameter.' +Assert-True (($parameterNames -join ',') -ceq 'Version,InstallDirectory,Uninstall') 'Installer exposed an unexpected production parameter.' Assert-True ($source -notmatch '(?i)skip(signature|checksum)|allowunsigned|localassets?|testassets?') 'Installer contains a bypass or local-asset surface.' Assert-True ($source -notmatch '(?i)Rfc3161|1\.3\.6\.1\.4\.1\.311\.3\.3\.1') 'PowerShell 5.1 installer contains an unsupported independent RFC 3161 claim or OID-only check.' +Assert-True ($source -cmatch "\`$script:Repository = 'Labeeb2339/PortCVE'") 'Installer repository identity is not the exact PortCVE repository.' $forbiddenCommands = @('cmd', 'cmd.exe', 'curl', 'curl.exe', 'Invoke-Expression', 'Start-Process') $commands = @($ast.FindAll({ @@ -58,6 +59,10 @@ $librarySource = $source.Substring(0, $markerIndex) Invoke-Expression $librarySource $workflowSource = [IO.File]::ReadAllText($releaseWorkflowPath) +Assert-True ($workflowSource -notmatch '(?i)BindWitness') 'Release workflow still contains the former project name.' +Assert-True ($workflowSource -cmatch 'portcve-\$\(\$env:RELEASE_TAG\)-win-x64\.zip') 'Release workflow does not create the canonical PortCVE ZIP name.' +Assert-True ($workflowSource -cmatch 'Test installer lifecycle under Windows PowerShell 5\.1') 'Release workflow does not gate on the PowerShell 5.1 lifecycle harness.' +Assert-True ($workflowSource -cmatch 'Portable ZIP, standalone executable, and signing metadata') 'Release workflow does not bind the portable ZIP to the signed executable metadata.' $workflowPatternMatch = [regex]::Match($workflowSource, "(?m)^\s+\`$pattern = '(?[^']+)'\s*$") Assert-True $workflowPatternMatch.Success 'Release workflow tag pattern was not found.' Assert-True ([StringComparer]::Ordinal.Equals($workflowPatternMatch.Groups['pattern'].Value, $script:ReleaseTagPattern)) 'Workflow and installer release-tag patterns diverged.' @@ -107,23 +112,179 @@ try { Assert-Throws { Expand-PortCVEExecutable -ZipPath $unsafeZip -DestinationDirectory (Join-Path $testRoot 'unsafe-expanded') } 'Zip traversal entry was accepted.' $installPath = Join-Path $testRoot 'PortCVE' + Assert-Throws { Assert-SafeInstallTarget '\\server\share\PortCVE' } 'UNC install target was accepted.' + Assert-Throws { Assert-SafeInstallTarget 'C:\Temp\PortCVE.' } 'Trailing-dot install target was accepted.' + Assert-Throws { Assert-SafeInstallTarget 'C:\Temp\NUL' } 'Reserved-device install target was accepted.' + Assert-Throws { Assert-SafeInstallTarget 'C:\Temp\%TEMP%\PortCVE' } 'Environment-expanding install target was accepted.' $updatedPath = Get-UpdatedUserPath -CurrentPath 'C:\Windows' -InstallPath $installPath Assert-True ($updatedPath.EndsWith(";$installPath", [StringComparison]::OrdinalIgnoreCase)) 'User PATH was not extended safely.' Assert-True ((Get-UpdatedUserPath -CurrentPath "C:\Windows;$installPath" -InstallPath $installPath) -ceq "C:\Windows;$installPath") 'Duplicate PATH entry was added.' + $removedPath = Get-UserPathWithoutInstall -CurrentPath "C:\Windows;$installPath;C:\Tools;$installPath" -InstallPath $installPath + Assert-True ($removedPath -ceq 'C:\Windows;C:\Tools') 'Uninstall did not remove every exact PortCVE PATH entry.' + Assert-True ((Get-UserPathWithoutInstall -CurrentPath "C:\Windows;${installPath}-other" -InstallPath $installPath) -ceq "C:\Windows;${installPath}-other") 'Uninstall removed a non-exact PATH entry.' [IO.Directory]::CreateDirectory($installPath) | Out-Null [IO.File]::WriteAllText((Join-Path $installPath 'unexpected.txt'), 'x') Assert-Throws { Assert-SafeInstallTarget $installPath } 'Unmanaged install content was accepted.' [IO.File]::Delete((Join-Path $installPath 'unexpected.txt')) - [IO.File]::WriteAllText((Join-Path $installPath 'portcve.exe'), 'old') + [IO.Directory]::Delete($installPath) - $token = [Guid]::NewGuid().ToString('N') - $staging = "$installPath.staging-$token" - [IO.Directory]::CreateDirectory($staging) | Out-Null - [IO.File]::WriteAllText((Join-Path $staging 'portcve.exe'), 'new') - [IO.File]::WriteAllText((Join-Path $staging 'install-receipt.json'), '{}') - Invoke-AtomicInstall -InstallPath $installPath -StagingPath $staging -Token $token -OriginalUserPath 'unchanged' -UpdatedUserPath 'unchanged' - Assert-True (([IO.File]::ReadAllText((Join-Path $installPath 'portcve.exe'))) -ceq 'new') 'Atomic replacement did not install staged bytes.' + function New-LifecycleStage { + param( + [Parameter(Mandatory = $true)][string]$StagePath, + [Parameter(Mandatory = $true)][string]$FinalInstallPath, + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$ExecutableText + ) + + [IO.Directory]::CreateDirectory($StagePath) | Out-Null + $executablePath = Join-Path $StagePath 'portcve.exe' + [IO.File]::WriteAllText($executablePath, $ExecutableText, [Text.UTF8Encoding]::new($false)) + $installedInstallerPath = Join-Path $StagePath 'install.ps1' + [IO.File]::WriteAllText($installedInstallerPath, '# signed-installer-fixture', [Text.UTF8Encoding]::new($false)) + $receipt = [ordered]@{ + schema_version = 1 + product = 'PortCVE' + version = $Version + repository = 'Labeeb2339/PortCVE' + install_path = $FinalInstallPath + zip_asset = "portcve-$Version-win-x64.zip" + zip_sha256 = 'a' * 64 + executable_sha256 = Get-Sha256 $executablePath + installer_sha256 = Get-Sha256 $installedInstallerPath + signer_subject = 'CN=PortCVE Test Signer' + timestamp_subject = 'CN=PortCVE Test TSA' + installed_at_utc = '2026-08-10T00:00:00.0000000Z' + } | ConvertTo-Json + [IO.File]::WriteAllText((Join-Path $StagePath 'install-receipt.json'), $receipt + "`r`n", [Text.UTF8Encoding]::new($false)) + } + + # The lifecycle fixture uses deterministic Authenticode results so the + # production signature path is exercised without trusting a test root CA. + $originalExpectedSignerSubject = $script:ExpectedSignerSubject + $originalEkuFunction = (Get-Item Function:\Test-CertificateEku).ScriptBlock + $script:ExpectedSignerSubject = 'CN=PortCVE Test Signer' + $script:AuthenticodeFixtureValid = $true + function Test-CertificateEku { return $true } + function Get-AuthenticodeSignature { + param([Parameter(Mandatory = $true)][string]$LiteralPath) + + if (-not $script:AuthenticodeFixtureValid) { + return [pscustomobject]@{ + Status = [Management.Automation.SignatureStatus]::NotSigned + StatusMessage = 'offline invalid-signature fixture' + SignatureType = 'None' + SignerCertificate = $null + TimeStamperCertificate = $null + } + } + return [pscustomobject]@{ + Status = [Management.Automation.SignatureStatus]::Valid + StatusMessage = 'offline valid-signature fixture' + SignatureType = 'Authenticode' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=PortCVE Test Signer' } + TimeStamperCertificate = [pscustomobject]@{ Subject = 'CN=PortCVE Test TSA' } + } + } + + # Offline lifecycle fixture: clean install. + $cleanToken = [Guid]::NewGuid().ToString('N') + $cleanStage = "$installPath.staging-$cleanToken" + New-LifecycleStage -StagePath $cleanStage -FinalInstallPath $installPath -Version 'v1.0.0' -ExecutableText 'version-one' + Invoke-AtomicInstall -InstallPath $installPath -StagingPath $cleanStage -Token $cleanToken -OriginalUserPath 'unchanged' -UpdatedUserPath 'unchanged' + $null = Assert-ManagedInstallation $installPath + Assert-True (([IO.File]::ReadAllText((Join-Path $installPath 'portcve.exe'))) -ceq 'version-one') 'Clean install did not install staged bytes.' + Assert-True (Test-Path -LiteralPath (Join-Path $installPath 'install.ps1') -PathType Leaf) 'Clean install did not retain its signed maintenance script fixture.' + + # Update replaces the exact managed directory and receipt. + $updateToken = [Guid]::NewGuid().ToString('N') + $updateStage = "$installPath.staging-$updateToken" + New-LifecycleStage -StagePath $updateStage -FinalInstallPath $installPath -Version 'v1.1.0' -ExecutableText 'version-two' + Invoke-AtomicInstall -InstallPath $installPath -StagingPath $updateStage -Token $updateToken -OriginalUserPath 'unchanged' -UpdatedUserPath 'unchanged' + $updatedReceipt = Read-PortCVEInstallReceipt $installPath + Assert-True (([string]$updatedReceipt.version -ceq 'v1.1.0') -and ([IO.File]::ReadAllText((Join-Path $installPath 'portcve.exe')) -ceq 'version-two')) 'Update did not replace the executable and receipt together.' + + $lateAddedChild = Join-Path $installPath 'added-after-validation.txt' + [IO.File]::WriteAllText($lateAddedChild, 'must block commit') + Assert-Throws { Assert-InstallTargetReadyForCommit $installPath } 'A child added after initial validation was accepted at commit.' + [IO.File]::Delete($lateAddedChild) + $null = Assert-InstallTargetReadyForCommit $installPath + + $script:AuthenticodeFixtureValid = $false + Assert-Throws { Assert-ManagedInstallation $installPath } 'Invalid installed signatures were accepted as managed.' + $script:AuthenticodeFixtureValid = $true + + $installedExecutablePath = Join-Path $installPath 'portcve.exe' + $installedInstallerPath = Join-Path $installPath 'install.ps1' + $installedReceiptPath = Join-Path $installPath 'install-receipt.json' + $originalExecutableBytes = [IO.File]::ReadAllBytes($installedExecutablePath) + $originalInstallerBytes = [IO.File]::ReadAllBytes($installedInstallerPath) + $originalReceiptBytes = [IO.File]::ReadAllBytes($installedReceiptPath) + + [IO.File]::WriteAllText($installedExecutablePath, 'tampered-executable') + Assert-Throws { Assert-InstallTargetReadyForCommit $installPath } 'Executable tampering after initial validation was accepted at commit.' + [IO.File]::WriteAllBytes($installedExecutablePath, $originalExecutableBytes) + + [IO.File]::WriteAllText($installedInstallerPath, '# tampered-installer') + Assert-Throws { Assert-ManagedInstallation $installPath } 'Tampered installed maintenance script still matched the managed receipt.' + [IO.File]::WriteAllBytes($installedInstallerPath, $originalInstallerBytes) + + $tamperedReceipt = [IO.File]::ReadAllText($installedReceiptPath) | ConvertFrom-Json + $tamperedReceipt.executable_sha256 = 'b' * 64 + [IO.File]::WriteAllText($installedReceiptPath, ($tamperedReceipt | ConvertTo-Json) + "`r`n", [Text.UTF8Encoding]::new($false)) + Assert-Throws { Assert-ManagedInstallation $installPath } 'Forged executable hash in the managed receipt was accepted.' + [IO.File]::WriteAllBytes($installedReceiptPath, $originalReceiptBytes) + + $tamperedReceipt = [IO.File]::ReadAllText($installedReceiptPath) | ConvertFrom-Json + $tamperedReceipt.installer_sha256 = 'c' * 64 + [IO.File]::WriteAllText($installedReceiptPath, ($tamperedReceipt | ConvertTo-Json) + "`r`n", [Text.UTF8Encoding]::new($false)) + Assert-Throws { Assert-ManagedInstallation $installPath } 'Forged installer hash in the managed receipt was accepted.' + [IO.File]::WriteAllBytes($installedReceiptPath, $originalReceiptBytes) + + $tamperedReceipt = [IO.File]::ReadAllText($installedReceiptPath) | ConvertFrom-Json + $tamperedReceipt.signer_subject = 'CN=Unexpected Signer' + [IO.File]::WriteAllText($installedReceiptPath, ($tamperedReceipt | ConvertTo-Json) + "`r`n", [Text.UTF8Encoding]::new($false)) + Assert-Throws { Assert-ManagedInstallation $installPath } 'Forged signer identity in the managed receipt was accepted.' + [IO.File]::WriteAllBytes($installedReceiptPath, $originalReceiptBytes) + $null = Assert-ManagedInstallation $installPath + + Push-Location $installPath + try { + Assert-Throws { Invoke-PortCVEUninstall $installPath } 'Uninstall was allowed while the current directory was inside the managed target.' + } + finally { + Pop-Location + } + Assert-True (Test-Path -LiteralPath $installPath -PathType Container) 'Rejected in-directory uninstall changed the managed installation.' + + # A pre-commit update failure restores the complete prior installation. + $rollbackToken = [Guid]::NewGuid().ToString('N') + $missingStage = "$installPath.staging-$rollbackToken" + Assert-Throws { + Invoke-AtomicInstall -InstallPath $installPath -StagingPath $missingStage -Token $rollbackToken -OriginalUserPath 'unchanged' -UpdatedUserPath 'unchanged' + } 'Failed update did not report an error.' + $rollbackReceipt = Read-PortCVEInstallReceipt $installPath + Assert-True (([string]$rollbackReceipt.version -ceq 'v1.1.0') -and ([IO.File]::ReadAllText((Join-Path $installPath 'portcve.exe')) -ceq 'version-two')) 'Failed update did not restore the prior installation.' + Assert-True (-not (Test-Path -LiteralPath "$installPath.backup-$rollbackToken")) 'Failed update left its guarded backup behind.' + + # Uninstall removes the exact receipt-bound installation. + $uninstallToken = [Guid]::NewGuid().ToString('N') + Invoke-AtomicUninstall -InstallPath $installPath -Token $uninstallToken -OriginalUserPath 'unchanged' -UpdatedUserPath 'unchanged' + Assert-True (-not (Test-Path -LiteralPath $installPath)) 'Uninstall left the managed installation in place.' + Assert-True (-not (Test-Path -LiteralPath "$installPath.uninstall-$uninstallToken")) 'Uninstall left its quarantine directory behind.' + + Remove-Item Function:\Get-AuthenticodeSignature + Set-Item Function:\Test-CertificateEku -Value $originalEkuFunction + $script:ExpectedSignerSubject = $originalExpectedSignerSubject + + # Receipt validation prevents an identically named but unmanaged directory + # from entering the update or uninstall lifecycle. + [IO.Directory]::CreateDirectory($installPath) | Out-Null + [IO.File]::WriteAllText((Join-Path $installPath 'portcve.exe'), 'not-managed') + [IO.File]::WriteAllText((Join-Path $installPath 'install.ps1'), '# not-managed') + [IO.File]::WriteAllText((Join-Path $installPath 'install-receipt.json'), '{}') + Assert-Throws { Assert-ManagedInstallation $installPath } 'Invalid receipt was accepted as a managed installation.' + [IO.Directory]::Delete($installPath, $true) $unicodeSubject = "CN=Jos$([char]0x00e9) O'Brien, O=PortCVE" $finalizedPath = Join-Path $testRoot 'finalized\install.ps1' @@ -151,6 +312,8 @@ try { $forbiddenInstallPath = Join-Path $testRoot 'must-not-exist' Assert-Throws { & $finalizedPath -Version 'v1.0.0' -InstallDirectory $forbiddenInstallPath } 'Unsigned finalized installer was accepted.' Assert-True (-not (Test-Path -LiteralPath $forbiddenInstallPath)) 'Unsigned installer mutated the install target before rejecting its own signature.' + Assert-Throws { & $finalizedPath -Uninstall -InstallDirectory $forbiddenInstallPath } 'Unsigned finalized uninstaller was accepted.' + Assert-True (-not (Test-Path -LiteralPath $forbiddenInstallPath)) 'Unsigned uninstaller mutated the target before rejecting its own signature.' } finally { $resolved = [IO.Path]::GetFullPath($testRoot) @@ -161,5 +324,6 @@ finally { } Assert-Throws { & $installerPath -Version 'v1.0.0' } 'Unfinalized template did not fail closed before network access.' +Assert-Throws { & $installerPath -Uninstall } 'Unfinalized template did not fail closed before uninstall activity.' Write-Host "Installer offline checks passed: $script:Passed" diff --git a/scripts/tests/Test-RemoteHostIntegrationHarness.ps1 b/scripts/tests/Test-RemoteHostIntegrationHarness.ps1 new file mode 100644 index 0000000..6be33c7 --- /dev/null +++ b/scripts/tests/Test-RemoteHostIntegrationHarness.ps1 @@ -0,0 +1,62 @@ +#requires -Version 5.1 + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$harnessPath = Join-Path $repositoryRoot 'scripts\Test-RemoteHostIntegration.ps1' +$source = [IO.File]::ReadAllText($harnessPath) +$tokens = $null +$parseErrors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { + throw "Remote integration harness has PowerShell syntax errors: $($parseErrors.Message -join '; ')" +} + +$script:Passed = 0 +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } + $script:Passed++ +} + +$parameterNames = @($ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath }) +Assert-True ($parameterNames -ccontains 'PortCVEPath') 'Harness does not expose the expected executable override.' +Assert-True ($parameterNames -cnotcontains 'Target') 'Harness must not accept an arbitrary network target.' +Assert-True ($parameterNames -cnotcontains 'Host') 'Harness must not accept an arbitrary network host.' +Assert-True ($parameterNames -cnotcontains 'Address') 'Harness must not accept an arbitrary network address.' + +$commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $null -ne $_ }) +foreach ($forbidden in @( + 'Invoke-WebRequest', 'Invoke-RestMethod', 'curl', 'curl.exe', 'wget', + 'nmap', 'nmap.exe', 'Test-NetConnection', 'Invoke-Expression')) { + Assert-True ($commands -cnotcontains $forbidden) "Harness invokes forbidden command '$forbidden'." +} + +Assert-True ($source.Contains("`$loopbackTarget = '127.0.0.1'")) 'Harness does not freeze its CLI target to IPv4 loopback.' +$ephemeralLoopbackListeners = [regex]::Matches( + $source, + [regex]::Escape('new TcpListener(IPAddress.Loopback, 0)')) +Assert-True ($ephemeralLoopbackListeners.Count -eq 2) 'Both fixtures must use distinct OS-assigned loopback ports.' +Assert-True ($source.Contains('public static LoopbackFixture StartHttp()')) 'HTTP fixture does not use an OS-assigned port.' +Assert-True (-not $source.Contains('candidatePorts')) 'Harness still exposes a configured HTTP-port candidate list.' +Assert-True ($source.Contains("'scan-host', `$loopbackTarget")) 'Harness does not exercise the scan-host CLI path.' +Assert-True ($source.Contains("'--authorized'")) 'Harness does not make the authorization assertion explicit.' +Assert-True ($source.Contains("@('--active', '--include-private')")) 'Harness does not exercise private safe-active mode.' +Assert-True (-not $source.Contains("'--online-advisories'")) 'Harness must not make an online advisory request.' +Assert-True ($source.Contains("`$method -in @('HEAD', 'OPTIONS')")) 'Harness does not enforce the safe HTTP method allowlist.' +Assert-True ($source.Contains("-ExpectedRequests @{ 'HEAD /' = 1 }")) 'Harness does not assert the one-request adaptive HTTP boundary.' +Assert-True ($source.Contains("'active-adaptive-http-head'")) 'Harness does not assert the adaptive HTTP evidence source.' +Assert-True ($source.Contains('finally {')) 'Harness does not contain guarded cleanup.' +Assert-True ($source.Contains('Remove-Item -LiteralPath $resolvedTemporaryDirectory -Recurse -Force')) 'Harness does not use the guarded resolved cleanup target.' +Assert-True ($source.Contains('$process.Kill()')) 'Harness does not terminate bounded child processes on timeout or cleanup.' +Assert-True ($source.Contains('[Diagnostics.Process]::new()')) 'Harness does not own its child process handle directly.' +Assert-True ($source.Contains('$fixture.Stop()')) 'Harness does not stop its listener fixtures explicitly.' + +Write-Output "Remote host integration harness checks passed: $script:Passed" diff --git a/src/PortCVE/Cli/CliApplication.cs b/src/PortCVE/Cli/CliApplication.cs index f5b6854..20a9cb3 100644 --- a/src/PortCVE/Cli/CliApplication.cs +++ b/src/PortCVE/Cli/CliApplication.cs @@ -4,6 +4,9 @@ using PortCVE.Collection; using PortCVE.Domain; using PortCVE.Output; +using PortCVE.Remote; +using PortCVE.Remote.Advisories; +using PortCVE.Remote.Imports; using PortCVE.Snapshots; using PortCVE.Vulnerabilities; @@ -15,13 +18,20 @@ public sealed class CliApplication private readonly LockfileService lockfileService; private readonly IVulnerabilityScanner vulnerabilityScanner; private readonly Func sbomPathValidator; + private readonly IRemoteHostScanner remoteHostScanner; + private readonly IRemoteAdvisoryClient remoteAdvisoryClient; + private readonly Func nvdApiKeyProvider; + private readonly ITrivyDatabaseService trivyDatabaseService; public CliApplication() : this( new SnapshotBuilder(), new LockfileService(), new TrivyVulnerabilityScanner(), - LocalPathPolicy.ValidateExistingLocalFile) + LocalPathPolicy.ValidateExistingLocalFile, + new RemoteHostScanner(), + CreateNvdClient(), + ReadNvdApiKey) { } @@ -30,7 +40,10 @@ internal CliApplication(ISnapshotBuilder snapshotBuilder, LockfileService lockfi snapshotBuilder, lockfileService, new TrivyVulnerabilityScanner(), - LocalPathPolicy.ValidateExistingLocalFile) + LocalPathPolicy.ValidateExistingLocalFile, + new RemoteHostScanner(), + CreateNvdClient(), + ReadNvdApiKey) { } @@ -42,7 +55,10 @@ internal CliApplication( snapshotBuilder, lockfileService, vulnerabilityScanner, - LocalPathPolicy.ValidateExistingLocalFile) + LocalPathPolicy.ValidateExistingLocalFile, + new RemoteHostScanner(), + CreateNvdClient(), + ReadNvdApiKey) { } @@ -51,11 +67,68 @@ internal CliApplication( LockfileService lockfileService, IVulnerabilityScanner vulnerabilityScanner, Func sbomPathValidator) + : this( + snapshotBuilder, + lockfileService, + vulnerabilityScanner, + sbomPathValidator, + new RemoteHostScanner(), + CreateNvdClient(), + ReadNvdApiKey) + { + } + + internal CliApplication( + ISnapshotBuilder snapshotBuilder, + LockfileService lockfileService, + IVulnerabilityScanner vulnerabilityScanner, + Func sbomPathValidator, + IRemoteHostScanner remoteHostScanner, + IRemoteAdvisoryClient remoteAdvisoryClient, + Func nvdApiKeyProvider) + : this( + snapshotBuilder, + lockfileService, + vulnerabilityScanner, + sbomPathValidator, + remoteHostScanner, + remoteAdvisoryClient, + nvdApiKeyProvider, + new TrivyDatabaseService()) + { + } + + internal CliApplication(ITrivyDatabaseService trivyDatabaseService) + : this( + new SnapshotBuilder(), + new LockfileService(), + new TrivyVulnerabilityScanner(), + LocalPathPolicy.ValidateExistingLocalFile, + new RemoteHostScanner(), + CreateNvdClient(), + ReadNvdApiKey, + trivyDatabaseService) + { + } + + internal CliApplication( + ISnapshotBuilder snapshotBuilder, + LockfileService lockfileService, + IVulnerabilityScanner vulnerabilityScanner, + Func sbomPathValidator, + IRemoteHostScanner remoteHostScanner, + IRemoteAdvisoryClient remoteAdvisoryClient, + Func nvdApiKeyProvider, + ITrivyDatabaseService trivyDatabaseService) { this.snapshotBuilder = snapshotBuilder; this.lockfileService = lockfileService; this.vulnerabilityScanner = vulnerabilityScanner; this.sbomPathValidator = sbomPathValidator; + this.remoteHostScanner = remoteHostScanner; + this.remoteAdvisoryClient = remoteAdvisoryClient; + this.nvdApiKeyProvider = nvdApiKeyProvider; + this.trivyDatabaseService = trivyDatabaseService; } public async Task RunAsync( @@ -70,6 +143,10 @@ public async Task RunAsync( CommandKind.Version => WriteVersion(output), CommandKind.List or CommandKind.Inspect => await RunListAsync(options, output, error, cancellationToken), CommandKind.Scan => await RunScanAsync(options, output, error, cancellationToken), + CommandKind.ScanHost => await RunScanHostAsync(options, output, error, cancellationToken), + CommandKind.Import => await RunImportAsync(options, output, error, cancellationToken), + CommandKind.DbStatus or CommandKind.DbUpdate => + await RunTrivyDatabaseAsync(options, output, error, cancellationToken), CommandKind.Lock => await RunLockAsync(options, output, error, cancellationToken), CommandKind.Snapshot => await RunSnapshotAsync(options, output, error, cancellationToken), CommandKind.Diff or CommandKind.Check => await RunDiffAsync(options, output, error, cancellationToken), @@ -79,6 +156,63 @@ public async Task RunAsync( }; } + private async Task RunTrivyDatabaseAsync( + CliOptions options, + TextWriter output, + TextWriter error, + CancellationToken cancellationToken) + { + var status = options.Command == CommandKind.DbUpdate + ? await trivyDatabaseService.UpdateAsync(cancellationToken) + : await trivyDatabaseService.GetStatusAsync(cancellationToken); + + if (options.Json) + { + var document = TrivyDatabaseDocument.FromStatus(status, Version); + if (!options.IncludePrivate) + { + document = TrivyDatabaseDocumentRedactor.Redact(document); + } + + await output.WriteLineAsync(JsonOutput.Serialize(document)); + } + else + { + output.WriteLine("Trivy vulnerability database"); + output.WriteLine($"State {status.State.ToString().ToLowerInvariant()}"); + output.WriteLine($"Operation {status.Operation.ToString().ToLowerInvariant()}"); + output.WriteLine($"Network requested {(status.NetworkRequested ? "yes" : "no")}"); + output.WriteLine($"Executable {status.ExecutablePath ?? "unresolved"}"); + output.WriteLine($"Trivy version {status.EngineVersion ?? "unavailable"}"); + output.WriteLine($"Cache {status.CacheDirectory ?? "invalid"}"); + output.WriteLine($"Database schema {status.DatabaseSchemaVersion?.ToString() ?? "unavailable"}"); + output.WriteLine($"Database updated {status.DatabaseUpdatedAt?.ToString("O") ?? "unavailable"}"); + output.WriteLine($"Next update {status.DatabaseNextUpdate?.ToString("O") ?? "unavailable"}"); + output.WriteLine($"Database age {FormatDatabaseAge(status.DatabaseAgeSeconds)}"); + output.WriteLine($"Result {status.Code}: {status.Message}"); + } + + if (status.Ready) + { + return ExitCodes.Success; + } + + error.WriteLine($"error: {status.Code}: {status.Message}"); + return status.State == TrivyDatabaseState.Failed + ? ExitCodes.RuntimeFailure + : ExitCodes.IncompleteEvidence; + } + + private static string FormatDatabaseAge(long? ageSeconds) + { + if (ageSeconds is null) + { + return "unavailable"; + } + + return $"{ageSeconds.Value / 3600d:0.#} hours"; + } + private async Task RunScanAsync( CliOptions options, TextWriter output, @@ -157,7 +291,7 @@ private async Task RunScanAsync( return ExitCodes.IncompleteEvidence; } - if (options.Strict && !report.Summary.IsComplete) + if ((options.Strict || options.FailOn is not null) && !report.Summary.IsComplete) { return ExitCodes.IncompleteEvidence; } @@ -171,6 +305,249 @@ private async Task RunScanAsync( return ExitCodes.Success; } + private async Task RunScanHostAsync( + CliOptions options, + TextWriter output, + TextWriter error, + CancellationToken cancellationToken) + { + if (!options.Authorized) + { + error.WriteLine( + "error: remote assessment requires --authorized to record that the operator has permission to test the target."); + return ExitCodes.UsageOrSchema; + } + + if (options.FailOn is not null && !options.OnlineAdvisories) + { + error.WriteLine( + "error: remote --fail-on requires --online-advisories; no advisory source was requested."); + return ExitCodes.UsageOrSchema; + } + + string? validatedOutputPath = null; + if (options.OutputPath is not null) + { + var outputValidation = LocalPathPolicy.ValidateOptionalRemoteOutputFile(options.OutputPath); + if (!outputValidation.IsValid) + { + error.WriteLine($"error: {outputValidation.Code}: {outputValidation.Message}"); + return ExitCodes.UsageOrSchema; + } + + validatedOutputPath = outputValidation.FullPath; + if (File.Exists(validatedOutputPath)) + { + error.WriteLine("error: the remote report output already exists; choose a new path."); + return ExitCodes.UsageOrSchema; + } + } + + RemoteTargetPlan targetPlan; + IReadOnlyList ports; + try + { + targetPlan = RemoteInputParser.ParseTargets( + options.RemoteTarget!, + options.MaximumHosts ?? RemoteInputParser.DefaultMaximumHosts); + ports = RemoteInputParser.ParsePorts(options.RemotePorts); + } + catch (RemoteInputException exception) + { + error.WriteLine($"error: {exception.Message}"); + return ExitCodes.UsageOrSchema; + } + + var service = new RemoteAuditService(remoteHostScanner, remoteAdvisoryClient); + RemoteAuditReport report; + try + { + report = await service.AssessAsync( + new( + Version, + targetPlan, + ports, + options.Active ? ProbeDepth.Active : ProbeDepth.Passive, + options.Authorized, + options.OnlineAdvisories, + options.Concurrency ?? 64, + options.Rate ?? 100, + options.ConnectTimeout ?? TimeSpan.FromMilliseconds(1500), + options.ReadTimeout ?? TimeSpan.FromSeconds(5), + options.OnlineAdvisories ? nvdApiKeyProvider() : null), + cancellationToken); + } + catch (ArgumentException exception) + { + error.WriteLine($"error: {exception.Message}"); + return ExitCodes.UsageOrSchema; + } + + string? serializedJsonReport = null; + if (validatedOutputPath is not null || options.Json) + { + var jsonReport = options.IncludePrivate ? report : RemoteAuditRedactor.Redact(report); + serializedJsonReport = JsonOutput.Serialize(jsonReport); + } + + if (validatedOutputPath is not null) + { + var outputRevalidation = LocalPathPolicy.ValidateOptionalRemoteOutputFile(validatedOutputPath); + if (!outputRevalidation.IsValid + || !string.Equals( + validatedOutputPath, + outputRevalidation.FullPath, + StringComparison.OrdinalIgnoreCase)) + { + error.WriteLine( + $"error: {outputRevalidation.Code}: the remote report output path changed or became unsafe during collection."); + return ExitCodes.UsageOrSchema; + } + + try + { + await WriteOutputFileAsync( + outputRevalidation.FullPath!, + serializedJsonReport!, + overwrite: false, + cancellationToken); + } + catch (IOException exception) + { + error.WriteLine($"error: could not write remote report: {exception.Message}"); + return ExitCodes.UsageOrSchema; + } + catch (UnauthorizedAccessException exception) + { + error.WriteLine($"error: could not write remote report: {exception.Message}"); + return ExitCodes.RuntimeFailure; + } + } + + if (options.Json) + { + await output.WriteLineAsync(serializedJsonReport!); + } + else + { + RemoteAuditTextRenderer.Render(report, output, error); + if (options.OutputPath is not null) + { + output.WriteLine($"JSON report: {Path.GetFullPath(options.OutputPath)}"); + } + } + + if (report.Summary.ResolvedTargetCount == 0 + || report.Summary.EndpointCount == 0 + || report.AdvisoryProviderFailed) + { + return ExitCodes.IncompleteEvidence; + } + + if ((options.Strict || options.FailOn is not null) && !report.Summary.IsComplete) + { + return ExitCodes.IncompleteEvidence; + } + + if (options.FailOn is not null && report.AdvisoryResults + .SelectMany(static item => item.Matches) + .Where(static match => string.Equals(match.Classification, "candidate", StringComparison.Ordinal)) + .Any(match => MeetsRemoteThreshold(match.Severity, options.FailOn.Value))) + { + return ExitCodes.NegativeResult; + } + + return ExitCodes.Success; + } + + private async Task RunImportAsync( + CliOptions options, + TextWriter output, + TextWriter error, + CancellationToken cancellationToken) + { + PentestImportDocument document; + try + { + document = new PentestImportService().Import( + options.ImportFormat!.Value, + options.InputPath!, + Version, + options.Strict, + cancellationToken); + } + catch (ImportPathException exception) + { + error.WriteLine($"error: {exception.Code}: {exception.Message}"); + return ExitCodes.UsageOrSchema; + } + catch (InvalidDataException exception) + { + error.WriteLine($"error: import evidence is invalid: {exception.Message}"); + return ExitCodes.UsageOrSchema; + } + catch (System.Xml.XmlException exception) + { + error.WriteLine($"error: import evidence is invalid XML: {exception.Message}"); + return ExitCodes.UsageOrSchema; + } + catch (IOException exception) + { + error.WriteLine($"error: could not read import evidence: {exception.Message}"); + return ExitCodes.RuntimeFailure; + } + catch (UnauthorizedAccessException exception) + { + error.WriteLine($"error: could not read import evidence: {exception.Message}"); + return ExitCodes.RuntimeFailure; + } + + var json = JsonOutput.Serialize(document); + if (options.OutputPath is not null) + { + var validation = LocalPathPolicy.ValidateOptionalImportOutputFile(options.OutputPath); + if (!validation.IsValid) + { + error.WriteLine($"error: {validation.Code}: {validation.Message}"); + return ExitCodes.UsageOrSchema; + } + + var inputFullPath = Path.GetFullPath(options.InputPath!); + if (string.Equals(inputFullPath, validation.FullPath, StringComparison.OrdinalIgnoreCase)) + { + error.WriteLine("error: import output must not replace the source evidence file."); + return ExitCodes.UsageOrSchema; + } + + try + { + await WriteOutputFileAsync( + validation.FullPath!, + json, + options.Force, + cancellationToken); + } + catch (IOException exception) + { + error.WriteLine($"error: could not write import report: {exception.Message}"); + return ExitCodes.UsageOrSchema; + } + catch (UnauthorizedAccessException exception) + { + error.WriteLine($"error: could not write import report: {exception.Message}"); + return ExitCodes.RuntimeFailure; + } + } + + await output.WriteLineAsync(json); + if (options.Strict && !document.IsComplete) + { + return ExitCodes.IncompleteEvidence; + } + + return ExitCodes.Success; + } + private async Task RunListAsync( CliOptions options, TextWriter output, @@ -787,6 +1164,87 @@ private static bool MeetsThreshold( _ => false, }; + private static bool MeetsRemoteThreshold( + RemoteAdvisorySeverity actual, + VulnerabilitySeverity threshold) => actual switch + { + RemoteAdvisorySeverity.Critical => true, + RemoteAdvisorySeverity.High => threshold == VulnerabilitySeverity.High, + _ => false, + }; + + private static async Task WriteOutputFileAsync( + string path, + string contents, + bool overwrite, + CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath) + ?? throw new IOException("The output path has no parent directory."); + if (!Directory.Exists(directory)) + { + throw new IOException("The output directory does not exist."); + } + + var temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + await using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) + { + await writer.WriteAsync(contents.AsMemory(), cancellationToken); + await writer.FlushAsync(cancellationToken); + stream.Flush(flushToDisk: true); + } + + if (overwrite && File.Exists(fullPath)) + { + File.Replace(temporaryPath, fullPath, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(temporaryPath, fullPath, overwrite: false); + } + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static IRemoteAdvisoryClient CreateNvdClient() + { + var handler = new SocketsHttpHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = System.Net.DecompressionMethods.All, + UseCookies = false, + }; + var client = new HttpClient(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + return new NvdAdvisoryClient(client); + } + + private static string? ReadNvdApiKey() + { + var value = Environment.GetEnvironmentVariable("PORTCVE_NVD_API_KEY"); + return value is { Length: 0 } ? null : value; + } + private static bool IsCheckFailure(ListenerChange change) => change.Kind switch { ListenerChangeKind.Added => true, @@ -815,7 +1273,7 @@ private static int WriteVersion(TextWriter output) private static int WriteHelp(TextWriter output) { - output.WriteLine("PortCVE explains local ports and locks the ones you expect."); + output.WriteLine("PortCVE explains local ports and audits authorized remote services with evidence-backed CVE correlation."); output.WriteLine(); output.WriteLine("USAGE"); output.WriteLine(" portcve List local TCP listeners and UDP binds"); @@ -826,6 +1284,11 @@ private static int WriteHelp(TextWriter output) output.WriteLine(" portcve check listeners.lock Fail on new, wider, or owner-changed binds"); output.WriteLine(" portcve scan tcp:8080 Check an exact listener's Docker image offline"); output.WriteLine(" portcve scan --all Check exact Docker images for all TCP listeners"); + output.WriteLine(" portcve db status Inspect local Trivy and database freshness offline"); + output.WriteLine(" portcve db update Explicitly download and validate the Trivy vulnerability database"); + output.WriteLine(" portcve scan-host --authorized Discover and fingerprint authorized TCP services"); + output.WriteLine(" portcve import nmap Normalize existing Nmap XML evidence"); + output.WriteLine(" portcve import nuclei Normalize existing Nuclei JSONL evidence"); output.WriteLine(" portcve watch --json Stream endpoint changes as JSONL"); output.WriteLine(" portcve doctor Check collection coverage and privacy mode"); output.WriteLine(); @@ -844,8 +1307,14 @@ private static int WriteHelp(TextWriter output) output.WriteLine(" --strict Exit 3 when core evidence is incomplete"); output.WriteLine(" --all Select every TCP listener for scan"); output.WriteLine(" --sbom Scan an explicitly supplied local SBOM"); - output.WriteLine(" --fail-on Exit 1 when that severity threshold is met"); - output.WriteLine(" -o, --output Write lock or snapshot output to a file"); + output.WriteLine(" --fail-on Gate complete advisory evidence; remote use requires --online-advisories"); + output.WriteLine(" --ports Select remote TCP ports for scan-host"); + output.WriteLine(" --authorized Assert authorization for the remote target scope"); + output.WriteLine(" --active Add bounded safe HTTP/TLS validation probes"); + output.WriteLine(" --online-advisories Explicitly query NVD for strong catalog-backed identities"); + output.WriteLine(" --concurrency <1-512> / --rate Bound remote parallelism and connections per second"); + output.WriteLine(" --max-hosts <1-65536> Bound CIDR expansion (default 256)"); + output.WriteLine(" -o, --output Write a lock, snapshot, remote, or import report"); output.WriteLine(" --force Replace an existing output file"); output.WriteLine(" --interval Watch interval, for example 500ms or 2s"); output.WriteLine(); @@ -853,8 +1322,10 @@ private static int WriteHelp(TextWriter output) output.WriteLine(" 0 success/pass; 1 no match or policy fail; 2 usage/schema;"); output.WriteLine(" 3 incomplete evidence; 4 collection/runtime failure; 130 interrupted."); output.WriteLine(); - output.WriteLine("PortCVE is read-only and does not prove reachability or exploitability."); + output.WriteLine("Remote work requires --authorized and never includes exploits, credentials, brute force, or DoS."); + output.WriteLine("A successful connection or candidate CVE match does not prove exploitability."); output.WriteLine("Vulnerability scans use a preinstalled Trivy database in offline mode; no update is automatic."); + output.WriteLine("Only the explicit 'portcve db update' command permits a Trivy database download."); return ExitCodes.Success; } diff --git a/src/PortCVE/Cli/CliOptions.cs b/src/PortCVE/Cli/CliOptions.cs index 7519f16..5ed308c 100644 --- a/src/PortCVE/Cli/CliOptions.cs +++ b/src/PortCVE/Cli/CliOptions.cs @@ -1,4 +1,5 @@ using PortCVE.Domain; +using PortCVE.Remote.Imports; using PortCVE.Vulnerabilities; namespace PortCVE.Cli; @@ -8,6 +9,10 @@ public enum CommandKind List, Inspect, Scan, + ScanHost, + Import, + DbStatus, + DbUpdate, Lock, Snapshot, Diff, @@ -39,7 +44,18 @@ public sealed record CliOptions( int? Iterations = null, bool All = false, string? SbomPath = null, - VulnerabilitySeverity? FailOn = null); + VulnerabilitySeverity? FailOn = null, + string? RemoteTarget = null, + string? RemotePorts = null, + bool Active = false, + bool Authorized = false, + bool OnlineAdvisories = false, + int? Concurrency = null, + int? Rate = null, + TimeSpan? ConnectTimeout = null, + TimeSpan? ReadTimeout = null, + int? MaximumHosts = null, + RemoteImportFormat? ImportFormat = null); public sealed class CliUsageException(string message) : Exception(message); diff --git a/src/PortCVE/Cli/CliParser.cs b/src/PortCVE/Cli/CliParser.cs index f8de9ad..dc2101f 100644 --- a/src/PortCVE/Cli/CliParser.cs +++ b/src/PortCVE/Cli/CliParser.cs @@ -1,5 +1,6 @@ using System.Globalization; using PortCVE.Domain; +using PortCVE.Remote.Imports; using PortCVE.Vulnerabilities; namespace PortCVE.Cli; @@ -33,6 +34,17 @@ public static CliOptions Parse(IReadOnlyList arguments) var all = false; string? sbomPath = null; VulnerabilitySeverity? failOn = null; + string? remoteTarget = null; + string? remotePorts = null; + var active = false; + var authorized = false; + var onlineAdvisories = false; + int? concurrency = null; + int? rate = null; + TimeSpan? connectTimeout = null; + TimeSpan? readTimeout = null; + int? maximumHosts = null; + RemoteImportFormat? importFormat = null; TimeSpan? interval = null; int? iterations = null; var index = 0; @@ -47,6 +59,22 @@ public static CliOptions Parse(IReadOnlyList arguments) port = queryPort; index++; } + else if (first.Equals("db", StringComparison.OrdinalIgnoreCase)) + { + if (arguments.Count < 2) + { + throw new CliUsageException("db requires exactly one action: db ."); + } + + command = arguments[1].ToLowerInvariant() switch + { + "status" => CommandKind.DbStatus, + "update" => CommandKind.DbUpdate, + "-h" or "--help" => CommandKind.Help, + _ => throw new CliUsageException("db action must be status or update."), + }; + index += 2; + } else { command = first.ToLowerInvariant() switch @@ -54,6 +82,8 @@ public static CliOptions Parse(IReadOnlyList arguments) "list" or "ls" => CommandKind.List, "inspect" or "explain" => CommandKind.Inspect, "scan" => CommandKind.Scan, + "scan-host" or "host" => CommandKind.ScanHost, + "import" => CommandKind.Import, "lock" => CommandKind.Lock, "snapshot" => CommandKind.Snapshot, "diff" => CommandKind.Diff, @@ -134,6 +164,33 @@ public static CliOptions Parse(IReadOnlyList arguments) case "--fail-on": failOn = ParseVulnerabilitySeverity(RequireValue(arguments, ref index, argument)); break; + case "--ports": + remotePorts = RequireValue(arguments, ref index, argument); + break; + case "--active": + active = true; + break; + case "--authorized": + authorized = true; + break; + case "--online-advisories" or "--nvd": + onlineAdvisories = true; + break; + case "--concurrency": + concurrency = ParseBoundedInt(RequireValue(arguments, ref index, argument), argument, 1, 512); + break; + case "--rate": + rate = ParseBoundedInt(RequireValue(arguments, ref index, argument), argument, 1, 10000); + break; + case "--connect-timeout": + connectTimeout = ParseProbeDuration(RequireValue(arguments, ref index, argument), argument); + break; + case "--read-timeout": + readTimeout = ParseProbeDuration(RequireValue(arguments, ref index, argument), argument); + break; + case "--max-hosts": + maximumHosts = ParseBoundedInt(RequireValue(arguments, ref index, argument), argument, 1, 65536); + break; case "-p" or "--port": port = ParsePort(RequireValue(arguments, ref index, argument)); break; @@ -190,6 +247,34 @@ public static CliOptions Parse(IReadOnlyList arguments) positionals.RemoveAt(0); } + if (command == CommandKind.ScanHost) + { + if (positionals.Count == 0) + { + throw new CliUsageException("scan-host requires an IP address, hostname, or IPv4 CIDR."); + } + + remoteTarget = positionals[0]; + positionals.RemoveAt(0); + } + + if (command == CommandKind.Import) + { + if (positionals.Count < 2) + { + throw new CliUsageException("import requires a format and local input path: import ."); + } + + importFormat = positionals[0].ToLowerInvariant() switch + { + "nmap" or "nmap-xml" => RemoteImportFormat.NmapXml, + "nuclei" or "nuclei-jsonl" => RemoteImportFormat.NucleiJsonl, + _ => throw new CliUsageException("import format must be nmap or nuclei."), + }; + input = positionals[1]; + positionals.RemoveRange(0, 2); + } + if (command is CommandKind.Diff or CommandKind.Check) { if (positionals.Count == 0) @@ -242,11 +327,75 @@ public static CliOptions Parse(IReadOnlyList arguments) "scan accepts only its TCP selector, --all, --sbom, --fail-on, --json, --include-private, and --strict."); } } + else if (command == CommandKind.ScanHost) + { + if (port is not null || protocol is not null || process is not null || scope is not null + || firewall || firewallExplicitlyDisabled || evidence || includeUdp || resolveAccounts + || interval is not null || iterations is not null || force || allowIncomplete || all + || sbomPath is not null) + { + throw new CliUsageException( + "scan-host accepts its target, --ports, --active, --authorized, --online-advisories, --concurrency, --rate, " + + "--connect-timeout, --read-timeout, --max-hosts, --fail-on, --json, --output, " + + "--include-private, and --strict."); + } + + if (!authorized) + { + throw new CliUsageException( + "scan-host requires --authorized to record the operator's authorization assertion."); + } + + if (failOn is not null && !onlineAdvisories) + { + throw new CliUsageException( + "scan-host --fail-on requires --online-advisories; an offline remote scan has no advisory source to gate."); + } + } + else if (command == CommandKind.Import) + { + if (port is not null || protocol is not null || process is not null || scope is not null + || firewall || firewallExplicitlyDisabled || evidence || includeUdp || includePrivate || resolveAccounts + || interval is not null || iterations is not null || allowIncomplete || all || sbomPath is not null + || failOn is not null) + { + throw new CliUsageException( + "import accepts only its format and local input path, --json, --output, --force, and --strict."); + } + } + else if (command is CommandKind.DbStatus or CommandKind.DbUpdate) + { + if (port is not null || protocol is not null || process is not null || scope is not null + || input is not null || output is not null || firewall || firewallExplicitlyDisabled || evidence + || strict || force || allowIncomplete || includeUdp || resolveAccounts + || interval is not null || iterations is not null || all || sbomPath is not null || failOn is not null + || remoteTarget is not null || remotePorts is not null || active || authorized || onlineAdvisories + || concurrency is not null || rate is not null || connectTimeout is not null || readTimeout is not null + || maximumHosts is not null || importFormat is not null) + { + throw new CliUsageException( + "db status/update accept only --json, --format , and --include-private."); + } + + if (includePrivate && !json) + { + throw new CliUsageException("db status/update --include-private requires JSON output."); + } + } else if (all || sbomPath is not null || failOn is not null) { throw new CliUsageException("--all, --sbom, and --fail-on are available only with scan."); } + if (command != CommandKind.ScanHost + && (remotePorts is not null || active || authorized || onlineAdvisories || concurrency is not null || rate is not null + || connectTimeout is not null || readTimeout is not null || maximumHosts is not null)) + { + throw new CliUsageException( + "--ports, --active, --authorized, --online-advisories, --concurrency, --rate, --connect-timeout, " + + "--read-timeout, and --max-hosts are available only with scan-host."); + } + if (command == CommandKind.Lock) { if (process is not null || scope is not null) @@ -288,7 +437,18 @@ public static CliOptions Parse(IReadOnlyList arguments) iterations, all, sbomPath, - failOn); + failOn, + remoteTarget, + remotePorts, + active, + authorized, + onlineAdvisories, + concurrency, + rate, + connectTimeout, + readTimeout, + maximumHosts, + importFormat); } private static string RequireValue(IReadOnlyList arguments, ref int index, string option) @@ -387,6 +547,40 @@ private static int ParsePositiveInt(string value, string option) return result; } + private static int ParseBoundedInt(string value, string option, int minimum, int maximum) + { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var result) + || result < minimum || result > maximum) + { + throw new CliUsageException($"{option} must be from {minimum} to {maximum}."); + } + + return result; + } + + private static TimeSpan ParseProbeDuration(string value, string option) + { + var factor = value.EndsWith("ms", StringComparison.OrdinalIgnoreCase) ? 1d + : value.EndsWith('s') ? 1000d + : 1000d; + var number = value.EndsWith("ms", StringComparison.OrdinalIgnoreCase) ? value[..^2] + : value.EndsWith('s') ? value[..^1] + : value; + if (!double.TryParse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var amount) + || amount <= 0) + { + throw new CliUsageException($"{option} duration '{value}' is invalid. Examples: 250ms, 2s."); + } + + var duration = TimeSpan.FromMilliseconds(amount * factor); + if (duration < TimeSpan.FromMilliseconds(50) || duration > TimeSpan.FromSeconds(30)) + { + throw new CliUsageException($"{option} must be from 50ms to 30s."); + } + + return duration; + } + private static VulnerabilitySeverity ParseVulnerabilitySeverity(string value) => value.ToLowerInvariant() switch { "high" => VulnerabilitySeverity.High, diff --git a/src/PortCVE/PortCVE.csproj b/src/PortCVE/PortCVE.csproj index 143391e..7a35e0b 100644 --- a/src/PortCVE/PortCVE.csproj +++ b/src/PortCVE/PortCVE.csproj @@ -13,9 +13,9 @@ win-x64 10.0.10 false - 0.1.0-alpha.1 + 0.2.0-alpha.1 PortCVE - Explain local Windows ports, their owners, bind scope, host-firewall evidence, and baseline drift. + Audit local Windows listeners and authorized remote TCP services with evidence-backed CVE correlation. Labeeb MIT diff --git a/src/PortCVE/Remote/Advisories/IRemoteAdvisoryClient.cs b/src/PortCVE/Remote/Advisories/IRemoteAdvisoryClient.cs new file mode 100644 index 0000000..efd066e --- /dev/null +++ b/src/PortCVE/Remote/Advisories/IRemoteAdvisoryClient.cs @@ -0,0 +1,8 @@ +namespace PortCVE.Remote.Advisories; + +internal interface IRemoteAdvisoryClient +{ + Task EnrichAsync( + RemoteAdvisoryRequest request, + CancellationToken cancellationToken); +} diff --git a/src/PortCVE/Remote/Advisories/NvdAdvisoryClient.cs b/src/PortCVE/Remote/Advisories/NvdAdvisoryClient.cs new file mode 100644 index 0000000..e0681bd --- /dev/null +++ b/src/PortCVE/Remote/Advisories/NvdAdvisoryClient.cs @@ -0,0 +1,1407 @@ +using System.Buffers; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace PortCVE.Remote.Advisories; + +internal sealed partial class NvdAdvisoryClient : IRemoteAdvisoryClient +{ + private static readonly Uri Endpoint = + new("https://services.nvd.nist.gov/rest/json/cves/2.0"); + + private readonly HttpClient _httpClient; + private readonly IRemoteAdvisoryClock _clock; + private readonly INvdRequestRateLimiter _rateLimiter; + private readonly NvdAdvisoryClientOptions _options; + + internal NvdAdvisoryClient( + HttpClient httpClient, + IRemoteAdvisoryClock? clock = null, + IRemoteAdvisoryDelay? delay = null, + NvdAdvisoryClientOptions? options = null, + INvdRequestRateLimiter? rateLimiter = null) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _clock = clock ?? SystemRemoteAdvisoryClock.Instance; + var resolvedDelay = delay ?? SystemRemoteAdvisoryDelay.Instance; + _rateLimiter = rateLimiter ?? + (clock is null && delay is null + ? NvdProcessRateLimiter.Shared + : new NvdProcessRateLimiter(_clock, resolvedDelay)); + _options = options ?? NvdAdvisoryClientOptions.Default; + _options.Validate(); + } + + public async Task EnrichAsync( + RemoteAdvisoryRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.ExplicitOnline) + { + return Result( + RemoteAdvisoryStatus.NotRequested, + RemoteAdvisoryResult.OfflineNetworkMode, + "online_not_explicit", + "NVD enrichment was not requested explicitly."); + } + + var validation = ValidateRequest(request); + if (validation is not null) + { + return Result( + validation.Status, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + validation.Code, + validation.Message); + } + + return await FetchAllAsync(request, cancellationToken).ConfigureAwait(false); + } + + private async Task FetchAllAsync( + RemoteAdvisoryRequest request, + CancellationToken cancellationToken) + { + var cpe23Uri = request.Identity.CpeResolution!.Cpe23Uri!; + var parsed = new List(); + var timestamps = new List(); + int? expectedTotal = null; + var startIndex = 0; + + for (var requestNumber = 0; requestNumber < _options.MaxRequests; requestNumber++) + { + await _rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false); + + ParsedPage page; + try + { + page = await FetchPageAsync( + cpe23Uri, + request.NvdApiKey, + startIndex, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Result( + RemoteAdvisoryStatus.Unavailable, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_timeout", + "The NVD request exceeded the configured timeout."); + } + catch (NvdResponseException exception) + { + return Result( + exception.Status, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + exception.Code, + exception.Message); + } + catch (HttpRequestException) + { + return Result( + RemoteAdvisoryStatus.Unavailable, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_transport_failed", + "The NVD API request failed before a complete response was received."); + } + catch (IOException) + { + return Result( + RemoteAdvisoryStatus.Failed, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_response_incomplete", + "The NVD API response ended before it could be validated."); + } + catch (JsonException) + { + return Result( + RemoteAdvisoryStatus.Failed, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_schema_invalid", + "The NVD API response was not valid CVE API 2.0 JSON."); + } + + expectedTotal ??= page.TotalResults; + if (page.TotalResults != expectedTotal.Value) + { + return Result( + RemoteAdvisoryStatus.Failed, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_pagination_changed", + "The NVD result set changed during pagination; no partial matches were retained."); + } + + if (page.TotalResults > _options.MaxCandidates) + { + return Result( + RemoteAdvisoryStatus.Failed, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_result_cap_exceeded", + $"The NVD result set exceeded the {_options.MaxCandidates.ToString(CultureInfo.InvariantCulture)}-record safety cap."); + } + + parsed.AddRange(page.Advisories); + timestamps.Add(page.Timestamp); + startIndex += page.ResultCount; + + if (parsed + .GroupBy(static advisory => advisory.AdvisoryId, StringComparer.OrdinalIgnoreCase) + .Any(static group => group.Skip(1).Any())) + { + return Result( + RemoteAdvisoryStatus.Failed, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_duplicate_cve", + "The NVD response contained a duplicate CVE identifier; no matches were retained."); + } + + if (startIndex == page.TotalResults) + { + var matches = CreateMatches( + parsed.Where(static advisory => advisory.EmitMatch).ToArray(), + request.Identity, + cpe23Uri); + var incomplete = parsed + .Where(static advisory => !string.Equals( + advisory.NvdStatus, + "Analyzed", + StringComparison.Ordinal)) + .OrderBy(static advisory => advisory.AdvisoryId, StringComparer.Ordinal) + .ToArray(); + var diagnostics = incomplete + .Select(static advisory => new RemoteAdvisoryDiagnostic( + advisory.EmitMatch + ? "nvd_enrichment_modified" + : "nvd_enrichment_incomplete", + $"{advisory.AdvisoryId} has NVD status {advisory.NvdStatus}; enrichment is not complete.")) + .ToArray(); + return new( + incomplete.Length == 0 + ? RemoteAdvisoryStatus.Complete + : RemoteAdvisoryStatus.Partial, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + timestamps.Count == 0 ? null : timestamps.Max(), + matches, + diagnostics); + } + + if (page.ResultCount == 0 || startIndex > page.TotalResults) + { + return Result( + RemoteAdvisoryStatus.Failed, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_pagination_invalid", + "The NVD pagination metadata was inconsistent; no partial matches were retained."); + } + } + + return Result( + RemoteAdvisoryStatus.Failed, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + "nvd_request_cap_exceeded", + $"The NVD query required more than {_options.MaxRequests.ToString(CultureInfo.InvariantCulture)} requests; no partial matches were retained."); + } + + private async Task FetchPageAsync( + string cpe23Uri, + string? apiKey, + int startIndex, + CancellationToken cancellationToken) + { + var uri = new Uri( + $"{Endpoint}?cpeName={Uri.EscapeDataString(cpe23Uri)}" + + $"&isVulnerable&noRejected&resultsPerPage={_options.ResultsPerPage.ToString(CultureInfo.InvariantCulture)}" + + $"&startIndex={startIndex.ToString(CultureInfo.InvariantCulture)}"); + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.ParseAdd("PortCVE/remote-advisory-enrichment"); + if (!string.IsNullOrEmpty(apiKey)) + { + _ = request.Headers.TryAddWithoutValidation("apiKey", apiKey); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_options.RequestTimeout); + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + timeout.Token).ConfigureAwait(false); + + if (response.RequestMessage?.RequestUri is { } responseUri && + !IsExpectedNvdEndpoint(responseUri)) + { + throw new NvdResponseException( + RemoteAdvisoryStatus.Failed, + "nvd_endpoint_mismatch", + "The HTTP response did not originate from the configured NVD CVE API 2.0 endpoint."); + } + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.TooManyRequests || + response.Headers.RetryAfter is not null) + { + await _rateLimiter.ApplyRetryAfterAsync( + GetRetryAfter(response), + CancellationToken.None).ConfigureAwait(false); + } + + var unavailable = response.StatusCode is HttpStatusCode.TooManyRequests or + HttpStatusCode.ServiceUnavailable or + HttpStatusCode.BadGateway or + HttpStatusCode.GatewayTimeout || + (int)response.StatusCode >= 500; + throw new NvdResponseException( + unavailable ? RemoteAdvisoryStatus.Unavailable : RemoteAdvisoryStatus.Failed, + response.StatusCode == HttpStatusCode.TooManyRequests + ? "nvd_rate_limited" + : "nvd_http_error", + unavailable + ? "The NVD API is temporarily unavailable or rate-limited." + : "The NVD API rejected the request."); + } + + var bytes = await ReadBoundedAsync(response.Content, timeout.Token).ConfigureAwait(false); + return ParsePage(bytes, startIndex, cpe23Uri); + } + + private static bool IsExpectedNvdEndpoint(Uri? uri) => + uri is not null && + string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(uri.Host, Endpoint.Host, StringComparison.OrdinalIgnoreCase) && + uri.IsDefaultPort && + string.Equals(uri.AbsolutePath, Endpoint.AbsolutePath, StringComparison.Ordinal); + + private TimeSpan GetRetryAfter(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter?.Delta; + if (retryAfter is null && response.Headers.RetryAfter?.Date is { } retryDate) + { + retryAfter = retryDate - _clock.UtcNow; + } + + return retryAfter.HasValue && retryAfter.Value > TimeSpan.Zero + ? retryAfter.Value + : TimeSpan.FromSeconds(30); + } + + private async Task ReadBoundedAsync( + HttpContent content, + CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > 0 && + content.Headers.ContentLength > _options.MaxResponseBytes) + { + throw new NvdResponseException( + RemoteAdvisoryStatus.Failed, + "nvd_response_too_large", + "The NVD API response exceeded the configured byte cap."); + } + + await using var stream = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = ArrayPool.Shared.Rent(16 * 1024); + try + { + while (true) + { + var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return output.ToArray(); + } + + if (output.Length + read > _options.MaxResponseBytes) + { + throw new NvdResponseException( + RemoteAdvisoryStatus.Failed, + "nvd_response_too_large", + "The NVD API response exceeded the configured byte cap."); + } + + output.Write(buffer, 0, read); + } + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + + private ParsedPage ParsePage( + ReadOnlyMemory bytes, + int expectedStartIndex, + string queriedCpe23Uri) + { + using var document = JsonDocument.Parse( + bytes, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 64, + }); + var root = RequireObject(document.RootElement, "root"); + + var format = RequireString(root, "format"); + var version = RequireString(root, "version"); + if (!string.Equals(format, "NVD_CVE", StringComparison.Ordinal) || + !string.Equals(version, "2.0", StringComparison.Ordinal)) + { + throw SchemaInvalid("The response did not identify itself as NVD CVE API 2.0 data."); + } + + var startIndex = RequireNonNegativeInt(root, "startIndex"); + var resultsPerPage = RequireNonNegativeInt(root, "resultsPerPage"); + var totalResults = RequireNonNegativeInt(root, "totalResults"); + if (startIndex != expectedStartIndex) + { + throw SchemaInvalid("The response startIndex did not match the requested page."); + } + + var timestampText = RequireString(root, "timestamp"); + if (!DateTimeOffset.TryParse( + timestampText, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var timestamp)) + { + throw SchemaInvalid("The response timestamp was invalid."); + } + + var vulnerabilities = RequireArray(root, "vulnerabilities"); + if (vulnerabilities.GetArrayLength() != resultsPerPage || + startIndex + resultsPerPage > totalResults) + { + throw SchemaInvalid("The response pagination counts were inconsistent."); + } + + var advisories = new List(resultsPerPage); + foreach (var wrapperElement in vulnerabilities.EnumerateArray()) + { + var wrapper = RequireObject(wrapperElement, "vulnerability"); + if (!wrapper.TryGetProperty("cve", out var cveElement)) + { + throw SchemaInvalid("A vulnerability record did not contain a cve object."); + } + + advisories.Add(ParseAdvisory( + RequireObject(cveElement, "cve"), + queriedCpe23Uri)); + } + + return new(startIndex, resultsPerPage, totalResults, timestamp, advisories); + } + + private ParsedAdvisory ParseAdvisory( + JsonElement cve, + string queriedCpe23Uri) + { + var id = RequireString(cve, "id").ToUpperInvariant(); + if (!CveIdRegex().IsMatch(id)) + { + throw SchemaInvalid("A vulnerability record contained an invalid CVE identifier."); + } + + _ = RequireString(cve, "sourceIdentifier"); + _ = RequireDate(cve, "published"); + var lastModified = RequireDate(cve, "lastModified"); + var status = NormalizeNvdStatus(RequireString(cve, "vulnStatus")); + + var descriptions = RequireArray(cve, "descriptions"); + if (descriptions.GetArrayLength() == 0) + { + throw SchemaInvalid("A vulnerability record had no descriptions."); + } + + var englishDescriptions = new List(); + foreach (var descriptionElement in descriptions.EnumerateArray()) + { + var description = RequireObject(descriptionElement, "description"); + var language = RequireString(description, "lang"); + var value = RequireString(description, "value"); + if (value.Length > 8192) + { + throw SchemaInvalid("A vulnerability description exceeded the safety cap."); + } + + if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)) + { + englishDescriptions.Add(value); + } + } + + var referencesElement = RequireArray(cve, "references"); + var allReferences = new SortedSet(StringComparer.Ordinal); + foreach (var referenceElement in referencesElement.EnumerateArray()) + { + var reference = RequireObject(referenceElement, "reference"); + var value = RequireString(reference, "url"); + if (value.Length > 4096 || + !Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + { + throw SchemaInvalid("A vulnerability reference URL was invalid."); + } + + allReferences.Add(uri.AbsoluteUri); + } + + var severity = ParseSeverity(cve); + var references = allReferences.Take(_options.MaxReferencesPerAdvisory).ToArray(); + var emitMatch = status is "Analyzed" or "Modified"; + var applicability = emitMatch + ? ParseApplicability(cve, queriedCpe23Uri) + : new RemoteAdvisoryApplicability( + RemoteAdvisoryApplicabilityDisposition.Inconclusive, + false, + false, + [], + ["NVD enrichment is incomplete, so no applicability claim was emitted."]); + return new( + id, + status, + lastModified, + emitMatch, + applicability, + severity.Severity, + severity.Source, + englishDescriptions.Order(StringComparer.Ordinal).FirstOrDefault(), + references, + allReferences.Count > references.Length); + } + + private static string NormalizeNvdStatus(string value) => value switch + { + "Analyzed" => "Analyzed", + "Modified" => "Modified", + "Received" => "Received", + "Awaiting Analysis" or "AwaitingAnalysis" => "Awaiting Analysis", + "Undergoing Analysis" or "UndergoingAnalysis" => "Undergoing Analysis", + "Deferred" => "Deferred", + "Rejected" => throw SchemaInvalid( + "A rejected CVE was returned despite the noRejected filter."), + _ => throw SchemaInvalid("A vulnerability record contained an unknown NVD status."), + }; + + private static RemoteAdvisoryApplicability ParseApplicability( + JsonElement cve, + string queriedCpe23Uri) + { + var configurationsElement = RequireArray(cve, "configurations"); + if (configurationsElement.GetArrayLength() == 0) + { + throw SchemaInvalid("An enriched vulnerability had no applicability configurations."); + } + + var configurations = new List(); + var directBranchFound = false; + var conditionalBranchFound = false; + var negatedBranchFound = false; + var inconclusiveBranchFound = false; + var inconclusiveConstraintFound = false; + var queriedLeafFound = false; + + foreach (var configurationElement in configurationsElement.EnumerateArray()) + { + var configuration = RequireObject(configurationElement, "configuration"); + var configurationOperator = OptionalOperator(configuration, "operator"); + var configurationNegate = OptionalBoolean(configuration, "negate"); + var nodesElement = RequireArray(configuration, "nodes"); + if (nodesElement.GetArrayLength() == 0) + { + throw SchemaInvalid("An applicability configuration had no nodes."); + } + + var nodes = new List(); + foreach (var nodeElement in nodesElement.EnumerateArray()) + { + var node = RequireObject(nodeElement, "applicability node"); + var nodeOperator = RequireOperator(node, "operator"); + var nodeNegate = OptionalBoolean(node, "negate"); + var cpeMatchesElement = RequireArray(node, "cpeMatch"); + if (cpeMatchesElement.GetArrayLength() == 0) + { + throw SchemaInvalid("An applicability node had no CPE match criteria."); + } + + var cpeMatches = new List(); + foreach (var cpeMatchElement in cpeMatchesElement.EnumerateArray()) + { + var cpeMatch = RequireObject(cpeMatchElement, "CPE match criterion"); + var vulnerable = RequireBoolean(cpeMatch, "vulnerable"); + var criteria = RequireString(cpeMatch, "criteria"); + if (!TryValidateMatchCriteria(criteria)) + { + throw SchemaInvalid("An applicability criterion was not a valid CPE 2.3 match string."); + } + + var matchCriteriaId = RequireString(cpeMatch, "matchCriteriaId"); + if (!Guid.TryParse(matchCriteriaId, out _)) + { + throw SchemaInvalid("An applicability criterion identifier was not a UUID."); + } + + var versionStartExcluding = OptionalBound(cpeMatch, "versionStartExcluding"); + var versionStartIncluding = OptionalBound(cpeMatch, "versionStartIncluding"); + var versionEndExcluding = OptionalBound(cpeMatch, "versionEndExcluding"); + var versionEndIncluding = OptionalBound(cpeMatch, "versionEndIncluding"); + var alignment = AlignQueriedIdentity( + criteria, + queriedCpe23Uri, + versionStartExcluding, + versionStartIncluding, + versionEndExcluding, + versionEndIncluding); + var identityAlignment = vulnerable + ? alignment + : RemoteAdvisoryCpeAlignment.NoMatch; + var matchesIdentity = + identityAlignment == RemoteAdvisoryCpeAlignment.Proven; + inconclusiveConstraintFound |= identityAlignment == + RemoteAdvisoryCpeAlignment.InconclusiveConstraint; + queriedLeafFound |= matchesIdentity; + cpeMatches.Add(new( + vulnerable, + criteria, + matchCriteriaId, + versionStartExcluding, + versionStartIncluding, + versionEndExcluding, + versionEndIncluding, + identityAlignment, + matchesIdentity, + identityAlignment == + RemoteAdvisoryCpeAlignment.ConditionalOnUnobservedQualifier)); + } + + ValidateRangePairs(cpeMatches); + var parsedNode = new RemoteAdvisoryApplicabilityNode( + nodeOperator, + nodeNegate, + cpeMatches); + nodes.Add(parsedNode); + } + + var parsedConfiguration = new RemoteAdvisoryConfiguration( + configurationOperator, + configurationNegate, + nodes); + configurations.Add(parsedConfiguration); + + var nodeDispositions = nodes + .Select(EvaluateNode) + .ToArray(); + if (!nodeDispositions.Any(static disposition => + disposition != ApplicabilityBranchDisposition.NoMatch)) + { + continue; + } + + if (configurationNegate) + { + negatedBranchFound = true; + continue; + } + + var configurationDisposition = EvaluateConfiguration( + configurationOperator, + nodes, + nodeDispositions); + if (configurationDisposition == ApplicabilityBranchDisposition.Direct) + { + directBranchFound = true; + } + else if (configurationDisposition == ApplicabilityBranchDisposition.Conditional) + { + conditionalBranchFound = true; + } + else + { + inconclusiveBranchFound = true; + negatedBranchFound |= + (string.Equals(configurationOperator, "AND", StringComparison.Ordinal) && + nodes.Any(static node => node.Negate)) || + nodes.Any(static node => + node.Negate && node.CpeMatches.Any(static match => + match.IdentityAlignment != RemoteAdvisoryCpeAlignment.NoMatch)); + } + } + + RemoteAdvisoryApplicabilityDisposition disposition; + IReadOnlyList limitations; + if (directBranchFound) + { + disposition = RemoteAdvisoryApplicabilityDisposition.DirectCandidate; + limitations = + [ + "The NVD CPE association is a candidate match; affected code and exploitability were not assessed.", + ]; + } + else if (conditionalBranchFound) + { + disposition = RemoteAdvisoryApplicabilityDisposition.ConditionalCandidate; + limitations = + [ + "The matching NVD applicability branch contains required cofactors that were not observed remotely.", + "Affected code and exploitability were not assessed.", + ]; + } + else + { + disposition = RemoteAdvisoryApplicabilityDisposition.Inconclusive; + limitations = negatedBranchFound + ? + [ + "The matching NVD applicability branch uses negation and cannot be safely reduced to a target claim.", + "Affected code and exploitability were not assessed.", + ] + : inconclusiveConstraintFound && inconclusiveBranchFound + ? + [ + "The matching product branch uses an NVD range or pattern that cannot be attributed to one leaf from the CVE response alone.", + "Affected code and exploitability were not assessed.", + ] + : + [ + "The API returned the CVE, but no vulnerable criterion could be conservatively aligned to the queried identity.", + "Affected code and exploitability were not assessed.", + ]; + } + + return new( + disposition, + queriedLeafFound, + disposition == RemoteAdvisoryApplicabilityDisposition.ConditionalCandidate, + configurations, + limitations); + } + + private static ApplicabilityBranchDisposition EvaluateNode( + RemoteAdvisoryApplicabilityNode node) + { + var matchingLeaves = node.CpeMatches + .Where(static match => + match.IdentityAlignment != RemoteAdvisoryCpeAlignment.NoMatch) + .ToArray(); + if (matchingLeaves.Length == 0) + { + return ApplicabilityBranchDisposition.NoMatch; + } + + if (node.Negate) + { + return ApplicabilityBranchDisposition.Inconclusive; + } + + if (string.Equals(node.Operator, "OR", StringComparison.Ordinal)) + { + if (matchingLeaves.Any(static match => + match.IdentityAlignment == RemoteAdvisoryCpeAlignment.Proven)) + { + return ApplicabilityBranchDisposition.Direct; + } + + return matchingLeaves.Any(static match => + match.IdentityAlignment == + RemoteAdvisoryCpeAlignment.ConditionalOnUnobservedQualifier) + ? ApplicabilityBranchDisposition.Conditional + : ApplicabilityBranchDisposition.Inconclusive; + } + + if (matchingLeaves.Any(static match => + match.IdentityAlignment == + RemoteAdvisoryCpeAlignment.InconclusiveConstraint)) + { + return ApplicabilityBranchDisposition.Inconclusive; + } + + return node.CpeMatches.All(static match => + match.Vulnerable && + match.MatchesQueriedIdentity && + !match.HasUnobservedQualifiers) + ? ApplicabilityBranchDisposition.Direct + : ApplicabilityBranchDisposition.Conditional; + } + + private static ApplicabilityBranchDisposition EvaluateConfiguration( + string? configurationOperator, + IReadOnlyList nodes, + IReadOnlyList nodeDispositions) + { + if (string.Equals(configurationOperator, "OR", StringComparison.Ordinal)) + { + if (nodeDispositions.Contains(ApplicabilityBranchDisposition.Direct)) + { + return ApplicabilityBranchDisposition.Direct; + } + + if (nodeDispositions.Contains(ApplicabilityBranchDisposition.Conditional)) + { + return ApplicabilityBranchDisposition.Conditional; + } + + return ApplicabilityBranchDisposition.Inconclusive; + } + + if (string.Equals(configurationOperator, "AND", StringComparison.Ordinal)) + { + if (nodes.Any(static node => node.Negate) || + nodeDispositions.Contains(ApplicabilityBranchDisposition.Inconclusive)) + { + return ApplicabilityBranchDisposition.Inconclusive; + } + + return nodeDispositions.All(static disposition => + disposition == ApplicabilityBranchDisposition.Direct) + ? ApplicabilityBranchDisposition.Direct + : ApplicabilityBranchDisposition.Conditional; + } + + if (nodes.Count == 1) + { + return nodeDispositions[0]; + } + + return nodes.Any(static node => node.Negate) || + nodeDispositions.Contains(ApplicabilityBranchDisposition.Inconclusive) + ? ApplicabilityBranchDisposition.Inconclusive + : ApplicabilityBranchDisposition.Conditional; + } + + private static string RequireOperator(JsonElement parent, string propertyName) => + OptionalOperator(parent, propertyName) ?? + throw SchemaInvalid($"The required {propertyName} operator was missing."); + + private static string? OptionalOperator(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value)) + { + return null; + } + + var result = RequireStringValue(value, propertyName); + return result is "AND" or "OR" + ? result + : throw SchemaInvalid($"The {propertyName} operator was invalid."); + } + + private static bool OptionalBoolean(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value)) + { + return false; + } + + return RequireBooleanValue(value, propertyName); + } + + private static bool RequireBoolean(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value)) + { + throw SchemaInvalid($"The required {propertyName} boolean was missing."); + } + + return RequireBooleanValue(value, propertyName); + } + + private static bool RequireBooleanValue(JsonElement value, string propertyName) + { + if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + throw SchemaInvalid($"The {propertyName} boolean was invalid."); + } + + return value.GetBoolean(); + } + + private static string? OptionalBound(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value)) + { + return null; + } + + var result = RequireStringValue(value, propertyName); + if (result.Length > 128) + { + throw SchemaInvalid($"The {propertyName} value exceeded the safety cap."); + } + + return result; + } + + private static void ValidateRangePairs(IEnumerable matches) + { + foreach (var match in matches) + { + if (match.VersionStartExcluding is not null && match.VersionStartIncluding is not null || + match.VersionEndExcluding is not null && match.VersionEndIncluding is not null) + { + throw SchemaInvalid("An applicability criterion contained conflicting version bounds."); + } + } + } + + private static bool TryValidateMatchCriteria(string criteria) + { + if (criteria.Length > 512 || criteria.Any(static character => + char.IsControl(character) || char.IsWhiteSpace(character))) + { + return false; + } + + var components = SplitCpeComponents(criteria); + return components is { Count: 13 } && + string.Equals(components[0], "cpe", StringComparison.Ordinal) && + string.Equals(components[1], "2.3", StringComparison.Ordinal) && + components.Skip(2).All(static component => component.Length > 0); + } + + private static RemoteAdvisoryCpeAlignment AlignQueriedIdentity( + string criteria, + string queriedCpe23Uri, + string? versionStartExcluding, + string? versionStartIncluding, + string? versionEndExcluding, + string? versionEndIncluding) + { + var criterionComponents = SplitCpeComponents(criteria); + var queryComponents = SplitCpeComponents(queriedCpe23Uri); + if (criterionComponents is not { Count: 13 } || queryComponents is not { Count: 13 }) + { + return RemoteAdvisoryCpeAlignment.NoMatch; + } + + if (!Enumerable.Range(2, 3).All(index => + !string.Equals(criterionComponents[index], "*", StringComparison.Ordinal) && + string.Equals( + criterionComponents[index], + queryComponents[index], + StringComparison.OrdinalIgnoreCase))) + { + return RemoteAdvisoryCpeAlignment.NoMatch; + } + + var queryVersion = queryComponents[5]; + var criterionVersion = criterionComponents[5]; + if (queryVersion is "*" or "-" || criterionVersion == "-") + { + return RemoteAdvisoryCpeAlignment.NoMatch; + } + + var unsupportedConstraint = false; + if (criterionVersion != "*") + { + if (criterionVersion.Contains('*') || criterionVersion.Contains('?')) + { + unsupportedConstraint = true; + } + else if (!string.Equals( + criterionVersion, + queryVersion, + StringComparison.OrdinalIgnoreCase)) + { + return RemoteAdvisoryCpeAlignment.NoMatch; + } + } + + var hasUnobservedQualifiers = false; + for (var index = 6; index < criterionComponents.Count; index++) + { + var criterion = criterionComponents[index]; + var query = queryComponents[index]; + if (criterion == "*") + { + continue; + } + + if (criterion.Contains('*') || criterion.Contains('?')) + { + unsupportedConstraint = true; + continue; + } + + if (string.Equals(criterion, query, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (query == "*") + { + hasUnobservedQualifiers = true; + continue; + } + + return RemoteAdvisoryCpeAlignment.NoMatch; + } + + if (unsupportedConstraint || + versionStartExcluding is not null || + versionStartIncluding is not null || + versionEndExcluding is not null || + versionEndIncluding is not null) + { + // The CVE endpoint tells us that some match criterion admitted the + // requested CPE, but it does not identify which leaf or expand the + // Match Criteria dictionary set. Never attribute a range or pattern + // to this leaf without a second data source. + return RemoteAdvisoryCpeAlignment.InconclusiveConstraint; + } + + return hasUnobservedQualifiers + ? RemoteAdvisoryCpeAlignment.ConditionalOnUnobservedQualifier + : RemoteAdvisoryCpeAlignment.Proven; + } + + private static ParsedSeverity ParseSeverity(JsonElement cve) + { + if (!cve.TryGetProperty("metrics", out var metricsElement)) + { + return new(RemoteAdvisorySeverity.Unknown, null, 0, false); + } + + var metrics = RequireObject(metricsElement, "metrics"); + var candidates = new List(); + ParseMetricArray(metrics, "cvssMetricV40", "4.0", 4, candidates); + ParseMetricArray(metrics, "cvssMetricV31", "3.1", 3, candidates); + ParseMetricArray(metrics, "cvssMetricV30", "3.0", 2, candidates); + ParseMetricArray(metrics, "cvssMetricV2", "2.0", 1, candidates); + + return candidates + .OrderByDescending(static candidate => candidate.VersionPriority) + .ThenByDescending(static candidate => candidate.IsPrimary) + .ThenBy(static candidate => candidate.Source, StringComparer.Ordinal) + .ThenByDescending(static candidate => candidate.Severity) + .FirstOrDefault() ?? new(RemoteAdvisorySeverity.Unknown, null, 0, false); + } + + private static void ParseMetricArray( + JsonElement metrics, + string propertyName, + string expectedVersion, + int versionPriority, + ICollection output) + { + if (!metrics.TryGetProperty(propertyName, out var arrayElement)) + { + return; + } + + if (arrayElement.ValueKind != JsonValueKind.Array || arrayElement.GetArrayLength() == 0) + { + throw SchemaInvalid($"The {propertyName} metric collection was invalid."); + } + + foreach (var metricElement in arrayElement.EnumerateArray()) + { + var metric = RequireObject(metricElement, propertyName); + var source = RequireString(metric, "source"); + var type = RequireString(metric, "type"); + if (!metric.TryGetProperty("cvssData", out var cvssDataElement)) + { + throw SchemaInvalid($"A {propertyName} metric did not contain cvssData."); + } + + var cvssData = RequireObject(cvssDataElement, "cvssData"); + var version = RequireString(cvssData, "version"); + _ = RequireString(cvssData, "vectorString"); + var baseScore = RequireNumber(cvssData, "baseScore"); + if (!string.Equals(version, expectedVersion, StringComparison.Ordinal) || + baseScore is < 0 or > 10) + { + throw SchemaInvalid($"A {propertyName} metric contained invalid CVSS data."); + } + + string severityText; + if (cvssData.TryGetProperty("baseSeverity", out var severityElement)) + { + severityText = RequireStringValue(severityElement, "baseSeverity"); + } + else + { + severityText = RequireString(metric, "baseSeverity"); + } + + var severity = severityText.ToUpperInvariant() switch + { + "LOW" => RemoteAdvisorySeverity.Low, + "MEDIUM" => RemoteAdvisorySeverity.Medium, + "HIGH" => RemoteAdvisorySeverity.High, + "CRITICAL" => RemoteAdvisorySeverity.Critical, + "NONE" => RemoteAdvisorySeverity.Unknown, + _ => throw SchemaInvalid($"A {propertyName} metric contained an unknown severity."), + }; + output.Add(new( + severity, + $"{source}/CVSS:{version}", + versionPriority, + string.Equals(type, "Primary", StringComparison.OrdinalIgnoreCase))); + } + } + + private IReadOnlyList CreateMatches( + IReadOnlyList advisories, + RemoteAdvisoryIdentity identity, + string cpe23Uri) => + advisories + .OrderBy(static advisory => advisory.AdvisoryId, StringComparer.Ordinal) + .Select(advisory => CreateMatch(advisory, identity, cpe23Uri)) + .ToArray(); + + private static RemoteAdvisoryMatch CreateMatch( + ParsedAdvisory advisory, + RemoteAdvisoryIdentity identity, + string cpe23Uri) + => new( + advisory.AdvisoryId, + advisory.Applicability.Disposition switch + { + RemoteAdvisoryApplicabilityDisposition.DirectCandidate => "candidate", + RemoteAdvisoryApplicabilityDisposition.ConditionalCandidate => "conditional_candidate", + _ => "inconclusive", + }, + "remote_banner_match", + identity.Product, + identity.Version, + cpe23Uri, + identity.Evidence, + identity.Confidence, + advisory.NvdStatus, + advisory.NvdLastModified, + advisory.Applicability, + advisory.Severity, + advisory.SeveritySource, + advisory.Description, + advisory.References, + advisory.ReferencesTruncated, + "not_assessed"); + + private static RequestValidationFailure? ValidateRequest(RemoteAdvisoryRequest request) + { + if (request.Identity is null) + { + return new(RemoteAdvisoryStatus.Unresolved, "identity_missing", "A remote product identity is required."); + } + + var identity = request.Identity; + if (identity.CpeResolution is null) + { + return new( + RemoteAdvisoryStatus.Unresolved, + "cpe_unresolved", + "A verified banner-catalog CPE resolution is required; no network request was made."); + } + + if (!identity.CpeResolution.IsResolved) + { + return new( + RemoteAdvisoryStatus.Unresolved, + identity.CpeResolution.Diagnostic?.Code ?? "cpe_unresolved", + identity.CpeResolution.Diagnostic?.Message ?? + "The banner identity did not resolve to a verified CPE."); + } + + if (identity.Confidence is not (RemoteAdvisoryConfidence.Exact or RemoteAdvisoryConfidence.Strong)) + { + return new( + RemoteAdvisoryStatus.Unresolved, + "identity_confidence_insufficient", + "Heuristic or unresolved identities are not sent to the NVD API."); + } + + if (string.IsNullOrWhiteSpace(identity.Product) || identity.Product.Length > 128 || + string.IsNullOrWhiteSpace(identity.Version) || identity.Version.Length > 64 || + string.IsNullOrWhiteSpace(identity.Evidence) || identity.Evidence.Length > 1024) + { + return new( + RemoteAdvisoryStatus.Unresolved, + "identity_invalid", + "Product, version, or evidence was missing or exceeded its safety cap."); + } + + if (!string.Equals( + identity.CpeResolution.Provenance, + RemoteBannerCpeCatalog.Resolution.VerifiedCatalogProvenance, + StringComparison.Ordinal) || + !identity.CpeResolution.MatchesIdentity(identity)) + { + return new( + RemoteAdvisoryStatus.Unresolved, + "cpe_identity_binding_mismatch", + "The catalog CPE resolution was not bound to the supplied banner identity."); + } + + if (string.IsNullOrWhiteSpace(identity.CpeResolution.Cpe23Uri) || + !TryValidateExactCpe(identity.CpeResolution.Cpe23Uri)) + { + return new( + RemoteAdvisoryStatus.Unresolved, + "cpe_invalid", + "The supplied CPE was not an exact CPE 2.3 URI."); + } + + if (request.NvdApiKey is not null && + (request.NvdApiKey.Length is < 1 or > 256 || + request.NvdApiKey.Any(char.IsControl) || + request.NvdApiKey.Any(char.IsWhiteSpace))) + { + return new( + RemoteAdvisoryStatus.Failed, + "nvd_api_key_invalid", + "The caller-supplied NVD API key was not a valid header value."); + } + + return null; + } + + private static bool TryValidateExactCpe(string cpe23Uri) + { + if (cpe23Uri.Length > 512 || cpe23Uri.Any(static character => + char.IsControl(character) || char.IsWhiteSpace(character))) + { + return false; + } + + var components = SplitCpeComponents(cpe23Uri); + if (components is null || components.Count != 13 || + !string.Equals(components[0], "cpe", StringComparison.Ordinal) || + !string.Equals(components[1], "2.3", StringComparison.Ordinal) || + components[2] is not ("a" or "o" or "h") || + components.Skip(2).Any(string.IsNullOrEmpty) || + components[3] is "*" or "-" || + components[4] is "*" or "-" || + components[5] is "*" or "-") + { + return false; + } + + return true; + } + + private static IReadOnlyList? SplitCpeComponents(string value) + { + var components = new List(); + var start = 0; + var escaped = false; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + if (escaped) + { + escaped = false; + continue; + } + + if (character == '\\') + { + escaped = true; + continue; + } + + if (character == ':') + { + components.Add(value[start..index]); + start = index + 1; + } + } + + if (escaped) + { + return null; + } + + components.Add(value[start..]); + return components; + } + + private static JsonElement RequireObject(JsonElement element, string name) + { + if (element.ValueKind != JsonValueKind.Object) + { + throw SchemaInvalid($"The {name} value was not an object."); + } + + return element; + } + + private static JsonElement RequireArray(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Array) + { + throw SchemaInvalid($"The required {propertyName} array was missing or invalid."); + } + + return value; + } + + private static string RequireString(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value)) + { + throw SchemaInvalid($"The required {propertyName} value was missing."); + } + + return RequireStringValue(value, propertyName); + } + + private static string RequireStringValue(JsonElement value, string propertyName) + { + if (value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(value.GetString())) + { + throw SchemaInvalid($"The required {propertyName} string was invalid."); + } + + return value.GetString()!; + } + + private static int RequireNonNegativeInt(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt32(out var result) || + result < 0) + { + throw SchemaInvalid($"The required {propertyName} integer was invalid."); + } + + return result; + } + + private static double RequireNumber(JsonElement parent, string propertyName) + { + if (!parent.TryGetProperty(propertyName, out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetDouble(out var result) || + !double.IsFinite(result)) + { + throw SchemaInvalid($"The required {propertyName} number was invalid."); + } + + return result; + } + + private static DateTimeOffset RequireDate(JsonElement parent, string propertyName) + { + var text = RequireString(parent, propertyName); + if (!DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var result)) + { + throw SchemaInvalid($"The required {propertyName} timestamp was invalid."); + } + + return result; + } + + private static NvdResponseException SchemaInvalid(string message) => + new(RemoteAdvisoryStatus.Failed, "nvd_schema_invalid", message); + + private static RemoteAdvisoryResult Result( + RemoteAdvisoryStatus status, + string networkMode, + string code, + string message) => + new( + status, + RemoteAdvisoryResult.ProviderName, + networkMode, + null, + [], + [new(code, message)]); + + [GeneratedRegex("^CVE-[0-9]{4}-[0-9]{4,}$", RegexOptions.CultureInvariant)] + private static partial Regex CveIdRegex(); + + private enum ApplicabilityBranchDisposition + { + NoMatch, + Direct, + Conditional, + Inconclusive, + } + + private sealed record RequestValidationFailure( + RemoteAdvisoryStatus Status, + string Code, + string Message); + + private sealed record ParsedPage( + int StartIndex, + int ResultCount, + int TotalResults, + DateTimeOffset Timestamp, + IReadOnlyList Advisories); + + private sealed record ParsedAdvisory( + string AdvisoryId, + string NvdStatus, + DateTimeOffset NvdLastModified, + bool EmitMatch, + RemoteAdvisoryApplicability Applicability, + RemoteAdvisorySeverity Severity, + string? SeveritySource, + string? Description, + IReadOnlyList References, + bool ReferencesTruncated); + + private sealed record ParsedSeverity( + RemoteAdvisorySeverity Severity, + string? Source, + int VersionPriority, + bool IsPrimary); + + private sealed class NvdResponseException( + RemoteAdvisoryStatus status, + string code, + string message) : Exception(message) + { + internal RemoteAdvisoryStatus Status { get; } = status; + internal string Code { get; } = code; + } +} + +internal sealed record NvdAdvisoryClientOptions( + int ResultsPerPage, + int MaxRequests, + int MaxCandidates, + int MaxResponseBytes, + int MaxReferencesPerAdvisory, + TimeSpan RequestTimeout) +{ + internal static NvdAdvisoryClientOptions Default { get; } = new( + ResultsPerPage: 100, + MaxRequests: 3, + MaxCandidates: 250, + MaxResponseBytes: 4 * 1024 * 1024, + MaxReferencesPerAdvisory: 20, + RequestTimeout: TimeSpan.FromSeconds(30)); + + internal void Validate() + { + if (ResultsPerPage is < 1 or > 2000 || + MaxRequests is < 1 or > 10 || + MaxCandidates < ResultsPerPage || + MaxCandidates > ResultsPerPage * MaxRequests || + MaxResponseBytes is < 1024 or > 16 * 1024 * 1024 || + MaxReferencesPerAdvisory is < 1 or > 100 || + RequestTimeout <= TimeSpan.Zero || RequestTimeout > TimeSpan.FromMinutes(2)) + { + throw new ArgumentOutOfRangeException( + nameof(NvdAdvisoryClientOptions), + "NVD client limits were outside the supported safety bounds."); + } + } +} diff --git a/src/PortCVE/Remote/Advisories/NvdProcessRateLimiter.cs b/src/PortCVE/Remote/Advisories/NvdProcessRateLimiter.cs new file mode 100644 index 0000000..fe58355 --- /dev/null +++ b/src/PortCVE/Remote/Advisories/NvdProcessRateLimiter.cs @@ -0,0 +1,89 @@ +namespace PortCVE.Remote.Advisories; + +internal interface INvdRequestRateLimiter +{ + Task WaitAsync(CancellationToken cancellationToken); + Task ApplyRetryAfterAsync(TimeSpan retryAfter, CancellationToken cancellationToken); +} + +internal sealed class NvdProcessRateLimiter : INvdRequestRateLimiter +{ + internal static readonly TimeSpan ProductionMinimumSpacing = TimeSpan.FromSeconds(6); + internal static readonly TimeSpan MaximumRetryAfter = TimeSpan.FromMinutes(5); + + internal static NvdProcessRateLimiter Shared { get; } = new( + SystemRemoteAdvisoryClock.Instance, + SystemRemoteAdvisoryDelay.Instance); + + private readonly IRemoteAdvisoryClock _clock; + private readonly IRemoteAdvisoryDelay _delay; + private readonly SemaphoreSlim _gate = new(1, 1); + private TimeSpan? _lastRequestAt; + private TimeSpan _notBefore; + + internal NvdProcessRateLimiter( + IRemoteAdvisoryClock clock, + IRemoteAdvisoryDelay delay) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _delay = delay ?? throw new ArgumentNullException(nameof(delay)); + } + + public async Task WaitAsync(CancellationToken cancellationToken) + { + while (true) + { + TimeSpan delay; + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var now = _clock.MonotonicNow; + var spacingDeadline = _lastRequestAt is null + ? TimeSpan.Zero + : _lastRequestAt.Value + ProductionMinimumSpacing; + var deadline = spacingDeadline > _notBefore ? spacingDeadline : _notBefore; + if (now >= deadline) + { + _lastRequestAt = now; + return; + } + + delay = deadline - now; + } + finally + { + _gate.Release(); + } + + // Do not hold the state gate while sleeping. A Retry-After response + // from another request must be able to extend this deadline before + // the pending request is released. + await _delay.DelayAsync(delay, cancellationToken).ConfigureAwait(false); + } + } + + public async Task ApplyRetryAfterAsync( + TimeSpan retryAfter, + CancellationToken cancellationToken) + { + var bounded = retryAfter < ProductionMinimumSpacing + ? ProductionMinimumSpacing + : retryAfter > MaximumRetryAfter + ? MaximumRetryAfter + : retryAfter; + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var deadline = _clock.MonotonicNow + bounded; + if (deadline > _notBefore) + { + _notBefore = deadline; + } + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/PortCVE/Remote/Advisories/RemoteAdvisoryModels.cs b/src/PortCVE/Remote/Advisories/RemoteAdvisoryModels.cs new file mode 100644 index 0000000..004fb56 --- /dev/null +++ b/src/PortCVE/Remote/Advisories/RemoteAdvisoryModels.cs @@ -0,0 +1,120 @@ +namespace PortCVE.Remote.Advisories; + +internal enum RemoteAdvisoryConfidence +{ + Exact, + Strong, + Heuristic, + Unresolved, +} + +internal enum RemoteAdvisoryStatus +{ + NotRequested, + Unresolved, + Complete, + Partial, + Unavailable, + Failed, +} + +internal enum RemoteAdvisorySeverity +{ + Unknown, + Low, + Medium, + High, + Critical, +} + +internal sealed record RemoteAdvisoryIdentity( + string Product, + string Version, + string Evidence, + RemoteAdvisoryConfidence Confidence, + RemoteBannerCpeCatalog.Resolution? CpeResolution); + +internal sealed record RemoteAdvisoryRequest( + RemoteAdvisoryIdentity Identity, + bool ExplicitOnline, + string? NvdApiKey = null); + +internal sealed record RemoteAdvisoryDiagnostic( + string Code, + string Message); + +internal enum RemoteAdvisoryApplicabilityDisposition +{ + DirectCandidate, + ConditionalCandidate, + Inconclusive, +} + +internal enum RemoteAdvisoryCpeAlignment +{ + NoMatch, + Proven, + ConditionalOnUnobservedQualifier, + InconclusiveConstraint, +} + +internal sealed record RemoteAdvisoryCpeMatch( + bool Vulnerable, + string Criteria, + string MatchCriteriaId, + string? VersionStartExcluding, + string? VersionStartIncluding, + string? VersionEndExcluding, + string? VersionEndIncluding, + RemoteAdvisoryCpeAlignment IdentityAlignment, + bool MatchesQueriedIdentity, + bool HasUnobservedQualifiers); + +internal sealed record RemoteAdvisoryApplicabilityNode( + string Operator, + bool Negate, + IReadOnlyList CpeMatches); + +internal sealed record RemoteAdvisoryConfiguration( + string? Operator, + bool Negate, + IReadOnlyList Nodes); + +internal sealed record RemoteAdvisoryApplicability( + RemoteAdvisoryApplicabilityDisposition Disposition, + bool QueriedCpeVulnerableLeafFound, + bool HasRequiredCofactors, + IReadOnlyList Configurations, + IReadOnlyList Limitations); + +internal sealed record RemoteAdvisoryMatch( + string AdvisoryId, + string Classification, + string MatchMethod, + string Product, + string Version, + string Cpe23Uri, + string Evidence, + RemoteAdvisoryConfidence Confidence, + string NvdStatus, + DateTimeOffset NvdLastModified, + RemoteAdvisoryApplicability Applicability, + RemoteAdvisorySeverity Severity, + string? SeveritySource, + string? Description, + IReadOnlyList References, + bool ReferencesTruncated, + string Exploitability); + +internal sealed record RemoteAdvisoryResult( + RemoteAdvisoryStatus Status, + string Provider, + string NetworkMode, + DateTimeOffset? SourceTimestamp, + IReadOnlyList Matches, + IReadOnlyList Diagnostics) +{ + internal const string ProviderName = "nvd_cve_api_2.0"; + internal const string ExplicitOnlineNetworkMode = "online_explicit"; + internal const string OfflineNetworkMode = "offline"; +} diff --git a/src/PortCVE/Remote/Advisories/RemoteAdvisoryTime.cs b/src/PortCVE/Remote/Advisories/RemoteAdvisoryTime.cs new file mode 100644 index 0000000..6b3acdb --- /dev/null +++ b/src/PortCVE/Remote/Advisories/RemoteAdvisoryTime.cs @@ -0,0 +1,31 @@ +using System.Diagnostics; + +namespace PortCVE.Remote.Advisories; + +internal interface IRemoteAdvisoryClock +{ + DateTimeOffset UtcNow { get; } + TimeSpan MonotonicNow { get; } +} + +internal interface IRemoteAdvisoryDelay +{ + Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken); +} + +internal sealed class SystemRemoteAdvisoryClock : IRemoteAdvisoryClock +{ + internal static SystemRemoteAdvisoryClock Instance { get; } = new(); + + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + + public TimeSpan MonotonicNow => Stopwatch.GetElapsedTime(0, Stopwatch.GetTimestamp()); +} + +internal sealed class SystemRemoteAdvisoryDelay : IRemoteAdvisoryDelay +{ + internal static SystemRemoteAdvisoryDelay Instance { get; } = new(); + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) => + Task.Delay(delay, cancellationToken); +} diff --git a/src/PortCVE/Remote/Advisories/RemoteBannerCpeCatalog.cs b/src/PortCVE/Remote/Advisories/RemoteBannerCpeCatalog.cs new file mode 100644 index 0000000..5e625b1 --- /dev/null +++ b/src/PortCVE/Remote/Advisories/RemoteBannerCpeCatalog.cs @@ -0,0 +1,251 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace PortCVE.Remote.Advisories; + +internal sealed partial class RemoteBannerCpeCatalog +{ + private const string OfficialDictionarySource = + "NVD Official CPE Dictionary (CPE API 2.0 vendor/product mapping)"; + + private static readonly IReadOnlyDictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // The vendor/product pairs below were checked against the NVD CPE API 2.0. + // Deliberately do not add broad names such as "Apache" or ambiguous nginx + // vendor mappings. An absent entry is safer than an invented CPE identity. + ["openssh"] = new("openbsd", "openssh", CpeVersionStyle.OpenSshPortable), + ["dropbear ssh"] = new( + "dropbear_ssh_project", + "dropbear_ssh", + CpeVersionStyle.Plain), + ["proftpd"] = new("proftpd", "proftpd", CpeVersionStyle.ProFtpdStable), + ["vsftpd"] = new("vsftpd_project", "vsftpd", CpeVersionStyle.Plain), + ["exim"] = new("exim", "exim", CpeVersionStyle.Plain), + ["apache httpd"] = new("apache", "http_server", CpeVersionStyle.Plain), + ["apache http server"] = new("apache", "http_server", CpeVersionStyle.Plain), + }; + + internal Resolution Resolve( + string? product, + string? version, + string? evidence, + RemoteAdvisoryConfidence confidence) + { + var normalizedProduct = product?.Trim(); + var normalizedVersion = version?.Trim(); + if (string.IsNullOrEmpty(normalizedProduct) || + string.IsNullOrEmpty(normalizedVersion) || + string.IsNullOrWhiteSpace(evidence)) + { + return Resolution.Unresolved( + normalizedProduct, + normalizedVersion, + evidence, + confidence, + "identity_incomplete", + "Product, version, and banner evidence are required."); + } + + if (confidence is not (RemoteAdvisoryConfidence.Exact or RemoteAdvisoryConfidence.Strong)) + { + return Resolution.Unresolved( + normalizedProduct, + normalizedVersion, + evidence, + confidence, + "identity_confidence_insufficient", + "Only exact or strong banner identities can be mapped to a CPE."); + } + + if (!Entries.TryGetValue(normalizedProduct, out var entry)) + { + return Resolution.Unresolved( + normalizedProduct, + normalizedVersion, + evidence, + confidence, + "cpe_mapping_unverified", + "No verified vendor/product CPE mapping exists for this banner identity."); + } + + if (normalizedVersion.Length > 64) + { + return Resolution.Unresolved( + normalizedProduct, + normalizedVersion, + evidence, + confidence, + "version_not_cpe_safe", + "The observed version is not an exact, safely representable CPE version component."); + } + + string cpeVersion; + string cpeUpdate; + if (entry.VersionStyle == CpeVersionStyle.OpenSshPortable) + { + var match = OpenSshPortableVersionRegex().Match(normalizedVersion); + if (!match.Success) + { + return Resolution.Unresolved( + normalizedProduct, + normalizedVersion, + evidence, + confidence, + "version_not_cpe_safe", + "The OpenSSH version could not be bound to the NVD dictionary's version/update components."); + } + + cpeVersion = match.Groups["version"].Value; + cpeUpdate = $"p{match.Groups["patch"].Value}"; + } + else if (entry.VersionStyle == CpeVersionStyle.ProFtpdStable) + { + if (!ProFtpdStableVersionRegex().IsMatch(normalizedVersion)) + { + return Resolution.Unresolved( + normalizedProduct, + normalizedVersion, + evidence, + confidence, + "version_not_cpe_safe", + "The ProFTPD version was not a dotted stable release supported by this catalog mapping."); + } + + // The Official CPE Dictionary represents stable patch-letter + // releases such as 1.3.8a in the version component itself. + cpeVersion = normalizedVersion.ToLowerInvariant(); + cpeUpdate = "*"; + } + else + { + if (!DottedNumericVersionRegex().IsMatch(normalizedVersion)) + { + return Resolution.Unresolved( + normalizedProduct, + normalizedVersion, + evidence, + confidence, + "version_not_cpe_safe", + "The observed version was not a dotted numeric version supported by this catalog mapping."); + } + + cpeVersion = normalizedVersion.ToLowerInvariant(); + cpeUpdate = "*"; + } + + var cpe = $"cpe:2.3:a:{entry.Vendor}:{entry.Product}:{cpeVersion}:{cpeUpdate}:*:*:*:*:*:*"; + return Resolution.Resolved( + normalizedProduct, + normalizedVersion, + evidence!, + confidence, + cpe, + OfficialDictionarySource); + } + + [GeneratedRegex("^[0-9]+(?:\\.[0-9]+)+$", RegexOptions.CultureInvariant)] + private static partial Regex DottedNumericVersionRegex(); + + [GeneratedRegex("^(?[0-9]+(?:\\.[0-9]+)+)p(?[0-9]+)$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex OpenSshPortableVersionRegex(); + + [GeneratedRegex("^[0-9]+(?:\\.[0-9]+)+[a-z]?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex ProFtpdStableVersionRegex(); + + private sealed record CatalogEntry( + string Vendor, + string Product, + CpeVersionStyle VersionStyle); + + private enum CpeVersionStyle + { + Plain, + OpenSshPortable, + ProFtpdStable, + } + + internal sealed class Resolution + { + internal const string VerifiedCatalogProvenance = "verified_banner_cpe_catalog_v1"; + + private Resolution( + bool isResolved, + string? observedProduct, + string? observedVersion, + string? evidenceSha256, + RemoteAdvisoryConfidence confidence, + string? cpe23Uri, + string mappingSource, + RemoteAdvisoryDiagnostic? diagnostic) + { + IsResolved = isResolved; + ObservedProduct = observedProduct; + ObservedVersion = observedVersion; + EvidenceSha256 = evidenceSha256; + Confidence = confidence; + Cpe23Uri = cpe23Uri; + MappingSource = mappingSource; + Diagnostic = diagnostic; + } + + internal bool IsResolved { get; } + internal string? ObservedProduct { get; } + internal string? ObservedVersion { get; } + internal string? EvidenceSha256 { get; } + internal RemoteAdvisoryConfidence Confidence { get; } + internal string? Cpe23Uri { get; } + internal string MappingSource { get; } + internal string Provenance => VerifiedCatalogProvenance; + internal RemoteAdvisoryDiagnostic? Diagnostic { get; } + + internal bool MatchesIdentity(RemoteAdvisoryIdentity identity) => + IsResolved && + string.Equals(ObservedProduct, identity.Product.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(ObservedVersion, identity.Version.Trim(), StringComparison.OrdinalIgnoreCase) && + Confidence == identity.Confidence && + string.Equals( + EvidenceSha256, + HashEvidence(identity.Evidence), + StringComparison.Ordinal); + + internal static Resolution Resolved( + string observedProduct, + string observedVersion, + string evidence, + RemoteAdvisoryConfidence confidence, + string cpe23Uri, + string mappingSource) => + new( + true, + observedProduct, + observedVersion, + HashEvidence(evidence), + confidence, + cpe23Uri, + mappingSource, + null); + + internal static Resolution Unresolved( + string? observedProduct, + string? observedVersion, + string? evidence, + RemoteAdvisoryConfidence confidence, + string code, + string message) => + new( + false, + observedProduct, + observedVersion, + string.IsNullOrWhiteSpace(evidence) ? null : HashEvidence(evidence), + confidence, + null, + OfficialDictionarySource, + new(code, message)); + + private static string HashEvidence(string evidence) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(evidence.Trim()))) + .ToLowerInvariant(); + } +} diff --git a/src/PortCVE/Remote/IRemoteHostScanner.cs b/src/PortCVE/Remote/IRemoteHostScanner.cs new file mode 100644 index 0000000..024b457 --- /dev/null +++ b/src/PortCVE/Remote/IRemoteHostScanner.cs @@ -0,0 +1,8 @@ +namespace PortCVE.Remote; + +internal interface IRemoteHostScanner +{ + Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken); +} diff --git a/src/PortCVE/Remote/Imports/ImportRetentionBudget.cs b/src/PortCVE/Remote/Imports/ImportRetentionBudget.cs new file mode 100644 index 0000000..b102111 --- /dev/null +++ b/src/PortCVE/Remote/Imports/ImportRetentionBudget.cs @@ -0,0 +1,28 @@ +namespace PortCVE.Remote.Imports; + +internal sealed class ImportRetentionBudget(long maximumCharacters) +{ + private long retainedCharacters; + + public void Reserve(long characters) + { + if (characters < 0) + { + throw new ArgumentOutOfRangeException(nameof(characters)); + } + + if (characters > maximumCharacters - retainedCharacters) + { + throw new InvalidDataException( + $"Normalized import output exceeds the {maximumCharacters / (1024 * 1024)} MiB retained-character limit."); + } + + retainedCharacters += characters; + } + + public static long Characters(params string?[] values) => + values.Sum(static value => (long)(value?.Length ?? 0)); + + public static long Characters(IEnumerable values) => + values.Sum(static value => (long)value.Length); +} diff --git a/src/PortCVE/Remote/Imports/ImportText.cs b/src/PortCVE/Remote/Imports/ImportText.cs new file mode 100644 index 0000000..bb03782 --- /dev/null +++ b/src/PortCVE/Remote/Imports/ImportText.cs @@ -0,0 +1,249 @@ +using System.Security.Cryptography; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace PortCVE.Remote.Imports; + +internal static class ImportText +{ + private static readonly string[] SensitiveMarkers = + [ + "authorization:", + "proxy-authorization:", + "cookie:", + "set-cookie:", + "bearer ", + "api_key", + "api-key", + "apikey", + "access_token", + "refresh_token", + "password=", + "passwd=", + "secret=", + "token=", + "private key", + ]; + + public static string? Sanitize(string? value, int maximumCharacters = 2048) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var builder = new StringBuilder(Math.Min(value.Length, maximumCharacters)); + foreach (var character in value) + { + if (builder.Length >= maximumCharacters) + { + break; + } + + builder.Append(char.IsControl(character) ? '\uFFFD' : character); + } + + return builder.ToString().Trim(); + } + + public static string Sha256(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + public static string Sha256(ReadOnlySpan value) => + Convert.ToHexStringLower(SHA256.HashData(value)); + + public static string? SanitizeIdentifier(string? value, int maximumCharacters = 256) + { + var sanitized = Sanitize(value, maximumCharacters); + if (sanitized is null || LooksSensitive(sanitized)) + { + return null; + } + + foreach (var character in sanitized) + { + var isAllowed = character is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '.' + or '_' + or ':' + or '-'; + if (isAllowed) + { + continue; + } + + return null; + } + + var result = sanitized.Trim('-', '.', '_', ':'); + return result.Length == 0 ? null : result; + } + + public static string? SanitizePublicLabel(string? value, int maximumCharacters = 512) + { + var sanitized = Sanitize(value, maximumCharacters); + if (sanitized is null || LooksSensitive(sanitized)) + { + return null; + } + + return Uri.TryCreate(sanitized, UriKind.Absolute, out var uri) + && (!string.IsNullOrWhiteSpace(uri.Host) || uri.Scheme is "data" or "file" or "javascript" or "vbscript") + ? null + : sanitized; + } + + public static string? SanitizeTarget(string? value) + { + var sanitized = Sanitize(value, 2048); + if (sanitized is null) + { + return null; + } + + if (Uri.TryCreate(sanitized, UriKind.Absolute, out var absolute) + && !string.IsNullOrWhiteSpace(absolute.Host) + && IsSafeEndpointScheme(absolute.Scheme)) + { + return BuildOrigin(absolute); + } + + var delimiter = sanitized.IndexOfAny(['?', '#', '/', '\\']); + var endpoint = delimiter >= 0 ? sanitized[..delimiter] : sanitized; + var userInfo = endpoint.LastIndexOf('@'); + if (userInfo >= 0) + { + endpoint = endpoint[(userInfo + 1)..]; + } + + endpoint = endpoint.Trim(); + if (IPAddress.TryParse(endpoint.Trim('[', ']'), out var ipAddress)) + { + return ipAddress.AddressFamily == AddressFamily.InterNetworkV6 + ? $"[{ipAddress}]" + : ipAddress.ToString(); + } + + if (endpoint.Length == 0 || LooksSensitive(endpoint) + || !Uri.TryCreate($"tcp://{endpoint}", UriKind.Absolute, out var parsed) + || string.IsNullOrWhiteSpace(parsed.Host)) + { + return null; + } + + var host = FormatHost(parsed); + return parsed.Port is >= 1 and <= 65535 ? $"{host}:{parsed.Port}" : host; + } + + public static string? SanitizeReference(string? value) + { + var sanitized = Sanitize(value, 2048); + if (sanitized is null + || !Uri.TryCreate(sanitized, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https") + || string.IsNullOrWhiteSpace(uri.Host)) + { + return null; + } + + var origin = BuildOrigin(uri); + if (origin is null) + { + return null; + } + + var escapedPath = uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped); + if (escapedPath.Length == 0) + { + return origin; + } + + var safeSegments = new List(); + var previousSegmentNamesSecret = false; + foreach (var segment in escapedPath.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + string decoded; + try + { + decoded = Uri.UnescapeDataString(segment); + } + catch (UriFormatException) + { + break; + } + + if (previousSegmentNamesSecret || LooksSensitive(decoded) || LooksLikeOpaqueToken(decoded)) + { + break; + } + + safeSegments.Add(segment); + previousSegmentNamesSecret = IsSecretPathMarker(decoded); + } + + return safeSegments.Count == 0 ? origin : $"{origin}/{string.Join('/', safeSegments)}"; + } + + private static bool LooksSensitive(string value) => + SensitiveMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase)); + + private static bool LooksLikeOpaqueToken(string value) + { + if (value.Length < 24) + { + return false; + } + + var tokenCharacters = 0; + foreach (var character in value) + { + if (character is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '-' + or '_' + or '=' + or '.') + { + tokenCharacters++; + } + } + + return tokenCharacters == value.Length; + } + + private static bool IsSecretPathMarker(string value) => + value.Equals("token", StringComparison.OrdinalIgnoreCase) + || value.Equals("secret", StringComparison.OrdinalIgnoreCase) + || value.Equals("password", StringComparison.OrdinalIgnoreCase) + || value.Equals("reset", StringComparison.OrdinalIgnoreCase) + || value.Equals("session", StringComparison.OrdinalIgnoreCase) + || value.Equals("apikey", StringComparison.OrdinalIgnoreCase) + || value.Equals("api-key", StringComparison.OrdinalIgnoreCase); + + private static bool IsSafeEndpointScheme(string scheme) => + scheme is not ("data" or "file" or "javascript" or "vbscript"); + + private static string? BuildOrigin(Uri uri) + { + if (!IsSafeEndpointScheme(uri.Scheme)) + { + return null; + } + + var host = FormatHost(uri); + if (host.Length == 0) + { + return null; + } + + var port = uri.IsDefaultPort || uri.Port is < 1 or > 65535 ? string.Empty : $":{uri.Port}"; + return $"{uri.Scheme.ToLowerInvariant()}://{host}{port}"; + } + + private static string FormatHost(Uri uri) => + uri.HostNameType == UriHostNameType.IPv6 ? $"[{uri.IdnHost}]" : uri.IdnHost.ToLowerInvariant(); +} diff --git a/src/PortCVE/Remote/Imports/NmapXmlImporter.cs b/src/PortCVE/Remote/Imports/NmapXmlImporter.cs new file mode 100644 index 0000000..1b308f8 --- /dev/null +++ b/src/PortCVE/Remote/Imports/NmapXmlImporter.cs @@ -0,0 +1,665 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Xml; + +namespace PortCVE.Remote.Imports; + +internal static class NmapXmlImporter +{ + private const long MaximumXmlCharacters = 64L * 1024 * 1024; + private const int MaximumDepth = 32; + private const int MaximumElements = 1000000; + private const int MaximumAttributesPerElement = 64; + private const int MaximumHosts = 4096; + private const int MaximumEndpoints = 200000; + private const int MaximumScriptObservations = 50000; + private const long MaximumRetainedCharacters = 16L * 1024 * 1024; + + public static PentestImportReport Import(Stream stream, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = MaximumXmlCharacters, + MaxCharactersFromEntities = 0, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + IgnoreWhitespace = true, + CloseInput = false, + }; + + var diagnostics = new List(); + var endpoints = new List(); + var findings = new List(); + var retentionBudget = new ImportRetentionBudget(MaximumRetainedCharacters); + var sawRoot = false; + var rootDepth = -1; + var sourceVersion = (string?)null; + var finishState = (string?)null; + var elementCount = 0; + var hostCount = 0; + var endpointCount = 0; + var scriptCount = 0; + HostAccumulator? host = null; + PortAccumulator? port = null; + var hostDepth = -1; + var hostnamesDepth = -1; + var portsDepth = -1; + var portDepth = -1; + var serviceDepth = -1; + var runstatsDepth = -1; + + using var reader = XmlReader.Create(stream, settings); + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reader.Depth > MaximumDepth) + { + throw new InvalidDataException($"Nmap XML exceeds the maximum element depth of {MaximumDepth}."); + } + + if (reader.NodeType == XmlNodeType.Element) + { + elementCount++; + if (elementCount > MaximumElements) + { + throw new InvalidDataException($"Nmap XML exceeds the {MaximumElements} element import limit."); + } + + if (reader.AttributeCount > MaximumAttributesPerElement) + { + throw new InvalidDataException( + $"Nmap XML element exceeds the {MaximumAttributesPerElement} attribute limit."); + } + + var elementName = reader.LocalName; + var elementDepth = reader.Depth; + var isEmpty = reader.IsEmptyElement; + if (!sawRoot) + { + if (elementDepth != 0 || elementName != "nmaprun" || reader.NamespaceURI.Length != 0) + { + throw new InvalidDataException("Nmap XML must have an nmaprun document element."); + } + + sawRoot = true; + rootDepth = elementDepth; + sourceVersion = ImportText.SanitizeIdentifier(ReadAttribute(reader, "version", 64), 64); + continue; + } + + var isUnqualified = reader.NamespaceURI.Length == 0; + if (isUnqualified && elementName == "runstats" && elementDepth == rootDepth + 1) + { + runstatsDepth = isEmpty ? -1 : elementDepth; + } + else if (isUnqualified && elementName == "host" && elementDepth == rootDepth + 1) + { + if (host is not null) + { + throw new InvalidDataException("Nmap XML contains nested host records."); + } + + hostCount++; + if (hostCount > MaximumHosts) + { + throw new InvalidDataException($"Nmap XML exceeds the {MaximumHosts} host import limit."); + } + + host = new(); + hostDepth = elementDepth; + hostnamesDepth = -1; + portsDepth = -1; + } + else if (isUnqualified && host is not null && elementName == "address" && elementDepth == hostDepth + 1) + { + ReadAddress(reader, host); + } + else if (isUnqualified + && host is not null + && elementName == "hostnames" + && elementDepth == hostDepth + 1) + { + hostnamesDepth = isEmpty ? -1 : elementDepth; + } + else if (isUnqualified + && host is not null + && hostnamesDepth >= 0 + && elementName == "hostname" + && elementDepth == hostnamesDepth + 1 + && host.Hostname is null) + { + host.Hostname = ImportText.SanitizePublicLabel(ReadAttribute(reader, "name", 253), 253); + } + else if (isUnqualified + && host is not null + && elementName == "ports" + && elementDepth == hostDepth + 1) + { + portsDepth = isEmpty ? -1 : elementDepth; + } + else if (isUnqualified + && host is not null + && portsDepth >= 0 + && port is null + && elementName == "port" + && elementDepth == portsDepth + 1) + { + endpointCount++; + if (endpointCount > MaximumEndpoints) + { + throw new InvalidDataException($"Nmap XML exceeds the {MaximumEndpoints} endpoint import limit."); + } + + port = new( + ReadAttribute(reader, "protocol", 8)?.ToLowerInvariant(), + ReadAttribute(reader, "portid", 5)); + portDepth = elementDepth; + } + else if (isUnqualified && port is not null && elementName == "state" && elementDepth == portDepth + 1) + { + port.State = NormalizePortState(ReadAttribute(reader, "state", 32)); + port.StateReason = ImportText.SanitizeIdentifier(ReadAttribute(reader, "reason", 128), 128); + } + else if (isUnqualified && port is not null && elementName == "service" && elementDepth == portDepth + 1) + { + port.Service = ReadService(reader); + serviceDepth = isEmpty ? -1 : elementDepth; + } + else if (port is not null + && port.Service is not null + && serviceDepth >= 0 + && isUnqualified + && elementName == "cpe" + && elementDepth == serviceDepth + 1) + { + var cpe = ImportText.SanitizePublicLabel( + ReadElementText(reader, 512, cancellationToken), + 512); + if (cpe is not null + && port.Service.Cpes.Count < 8 + && (cpe.StartsWith("cpe:/", StringComparison.Ordinal) + || cpe.StartsWith("cpe:2.3:", StringComparison.Ordinal))) + { + port.Service.Cpes.Add(cpe); + } + + continue; + } + else if (isUnqualified && port is not null && elementName == "script" && elementDepth == portDepth + 1) + { + var id = ImportText.SanitizeIdentifier(ReadAttribute(reader, "id", 256), 256); + if (id is not null) + { + scriptCount++; + if (scriptCount > MaximumScriptObservations) + { + throw new InvalidDataException( + $"Nmap XML exceeds the {MaximumScriptObservations} script-observation import limit."); + } + + port.ScriptIds.Add(id); + } + } + else if (isUnqualified + && finishState is null + && runstatsDepth >= 0 + && elementName == "finished" + && elementDepth == runstatsDepth + 1) + { + finishState = NormalizeFinishState(ReadAttribute(reader, "exit", 16)); + } + + if (isEmpty) + { + if (port is not null && elementDepth == portDepth && elementName == "port") + { + FinalizePort(host!, port, diagnostics, retentionBudget); + port = null; + portDepth = -1; + serviceDepth = -1; + } + + if (host is not null && elementDepth == hostDepth && elementName == "host") + { + FinalizeHost(host, endpoints, findings, diagnostics, retentionBudget); + host = null; + hostDepth = -1; + hostnamesDepth = -1; + portsDepth = -1; + } + } + } + else if (reader.NodeType == XmlNodeType.EndElement) + { + if (serviceDepth >= 0 && reader.Depth == serviceDepth && reader.LocalName == "service") + { + serviceDepth = -1; + } + + if (runstatsDepth >= 0 && reader.Depth == runstatsDepth && reader.LocalName == "runstats") + { + runstatsDepth = -1; + } + + if (hostnamesDepth >= 0 + && reader.Depth == hostnamesDepth + && reader.LocalName == "hostnames" + && reader.NamespaceURI.Length == 0) + { + hostnamesDepth = -1; + } + + if (portsDepth >= 0 + && reader.Depth == portsDepth + && reader.LocalName == "ports" + && reader.NamespaceURI.Length == 0) + { + portsDepth = -1; + } + + if (port is not null && reader.Depth == portDepth && reader.LocalName == "port") + { + FinalizePort(host!, port, diagnostics, retentionBudget); + port = null; + portDepth = -1; + serviceDepth = -1; + } + + if (host is not null && reader.Depth == hostDepth && reader.LocalName == "host") + { + FinalizeHost(host, endpoints, findings, diagnostics, retentionBudget); + host = null; + hostDepth = -1; + hostnamesDepth = -1; + portsDepth = -1; + } + } + } + + if (!sawRoot) + { + throw new InvalidDataException("Nmap XML must have an nmaprun document element."); + } + + var finishedSuccessfully = string.Equals( + finishState, + "success", + StringComparison.OrdinalIgnoreCase); + if (!finishedSuccessfully) + { + AddDiagnostic( + diagnostics, + retentionBudget, + "nmap_scan_incomplete", + "Nmap did not record a successful finished state; imported evidence may be incomplete."); + } + + var evidenceWasDropped = diagnostics.Any(static diagnostic => + diagnostic.Code is "nmap_protocol_ignored" or "nmap_host_without_address"); + var complete = finishedSuccessfully && !evidenceWasDropped; + + return new( + "nmap_xml", + sourceVersion, + complete, + endpoints.OrderBy(static item => item.Target, StringComparer.Ordinal) + .ThenBy(static item => item.Protocol, StringComparer.Ordinal) + .ThenBy(static item => item.Port) + .ToArray(), + findings.OrderBy(static item => item.Target, StringComparer.Ordinal) + .ThenBy(static item => item.Port) + .ThenBy(static item => item.FindingId, StringComparer.Ordinal) + .ToArray(), + diagnostics); + } + + private static void ReadAddress(XmlReader reader, HostAccumulator host) + { + var type = ReadAttribute(reader, "addrtype", 8); + var rawAddress = ReadAttribute(reader, "addr", 64); + if (rawAddress is null || !IPAddress.TryParse(rawAddress, out var parsed)) + { + return; + } + + if (type == "ipv4" && parsed.AddressFamily == AddressFamily.InterNetwork && host.Ipv4 is null) + { + host.Ipv4 = parsed.ToString(); + } + else if (type == "ipv6" && parsed.AddressFamily == AddressFamily.InterNetworkV6 && host.Ipv6 is null) + { + host.Ipv6 = parsed.ToString(); + } + } + + private static ServiceAccumulator ReadService(XmlReader reader) + { + var method = ReadAttribute(reader, "method", 16); + _ = int.TryParse( + ReadAttribute(reader, "conf", 3), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var confidence); + var strength = method == "probed" && confidence >= 8 ? ImportedEvidenceStrength.Strong + : method == "probed" && confidence >= 5 ? ImportedEvidenceStrength.Moderate + : ImportedEvidenceStrength.Weak; + return new( + ImportText.SanitizePublicLabel(ReadAttribute(reader, "name", 128), 128), + ImportText.SanitizePublicLabel(ReadAttribute(reader, "product", 256), 256), + ImportText.SanitizePublicLabel(ReadAttribute(reader, "version", 128), 128), + ImportText.SanitizePublicLabel(ReadAttribute(reader, "extrainfo", 256), 256), + strength, + method == "probed" ? "nmap_service_probe" : "nmap_port_table"); + } + + private static string NormalizePortState(string? value) + { + var candidate = value?.ToLowerInvariant(); + return candidate is "open" + or "closed" + or "filtered" + or "unfiltered" + or "open|filtered" + or "closed|filtered" + ? candidate + : "unknown"; + } + + private static string? NormalizeFinishState(string? value) + { + var candidate = value?.ToLowerInvariant(); + return candidate is "success" or "error" ? candidate : null; + } + + private static void FinalizePort( + HostAccumulator host, + PortAccumulator port, + ICollection diagnostics, + ImportRetentionBudget retentionBudget) + { + if (port.Protocol is not "tcp" and not "udp") + { + AddDiagnostic( + diagnostics, + retentionBudget, + "nmap_protocol_ignored", + "An endpoint used an unsupported transport protocol."); + return; + } + + if (!int.TryParse(port.PortText, NumberStyles.None, CultureInfo.InvariantCulture, out var portNumber) + || portNumber is < 1 or > 65535) + { + throw new InvalidDataException("Nmap XML contained an invalid port number."); + } + + var service = port.Service is null + ? null + : new ImportedServiceIdentity( + port.Service.Name, + port.Service.Product, + port.Service.Version, + port.Service.ExtraInfo, + port.Service.Cpes.Distinct(StringComparer.Ordinal).ToArray(), + port.Service.EvidenceStrength, + port.Service.EvidenceSource); + var pending = new PendingEndpoint( + port.Protocol, + portNumber, + port.State, + port.StateReason, + service, + port.ScriptIds); + retentionBudget.Reserve(PendingEndpointCharacters(pending)); + host.Endpoints.Add(pending); + } + + private static void FinalizeHost( + HostAccumulator host, + ICollection endpoints, + ICollection findings, + ICollection diagnostics, + ImportRetentionBudget retentionBudget) + { + var address = host.Ipv4 ?? host.Ipv6; + if (address is null) + { + AddDiagnostic( + diagnostics, + retentionBudget, + "nmap_host_without_address", + "An Nmap host record had no valid IPv4 or IPv6 address."); + return; + } + + foreach (var pending in host.Endpoints) + { + retentionBudget.Reserve(ImportRetentionBudget.Characters(address, host.Hostname)); + endpoints.Add(new( + address, + host.Hostname, + pending.Protocol, + pending.Port, + pending.State, + pending.StateReason, + pending.Service)); + foreach (var id in pending.ScriptIds) + { + var sourceRecord = $"{address}|{pending.Protocol}|{pending.Port}|{id}"; + var finding = new ImportedFinding( + "nmap_nse", + id, + $"Imported Nmap NSE observation: {id}", + "unknown", + address, + pending.Port, + pending.Protocol, + ImportedClaimStatus.ImportedMatch, + ImportedEvidenceStrength.Unresolved, + [], + [], + ImportText.Sha256(sourceRecord), + id, + null); + retentionBudget.Reserve(FindingCharacters(finding)); + findings.Add(finding); + } + } + } + + private static long PendingEndpointCharacters(PendingEndpoint endpoint) + { + var characters = ImportRetentionBudget.Characters( + endpoint.Protocol, + endpoint.State, + endpoint.StateReason) + + ImportRetentionBudget.Characters(endpoint.ScriptIds); + if (endpoint.Service is not null) + { + characters += ImportRetentionBudget.Characters( + endpoint.Service.Name, + endpoint.Service.Product, + endpoint.Service.Version, + endpoint.Service.ExtraInfo, + endpoint.Service.EvidenceSource) + + ImportRetentionBudget.Characters(endpoint.Service.Cpes); + } + + return characters; + } + + private static long FindingCharacters(ImportedFinding finding) => + ImportRetentionBudget.Characters( + finding.Source, + finding.FindingId, + finding.Title, + finding.Severity, + finding.Target, + finding.Protocol, + finding.SourceRecordSha256, + finding.Matcher, + finding.Summary) + + ImportRetentionBudget.Characters(finding.AdvisoryIds) + + ImportRetentionBudget.Characters(finding.References); + + private static void AddDiagnostic( + ICollection diagnostics, + ImportRetentionBudget retentionBudget, + string code, + string message) + { + retentionBudget.Reserve(ImportRetentionBudget.Characters(code, message)); + diagnostics.Add(new(code, message)); + } + + private static string? ReadAttribute(XmlReader reader, string name, int maximumCharacters) + { + if (!reader.MoveToAttribute(name)) + { + return null; + } + + try + { + var buffer = new char[Math.Min(maximumCharacters + 1, 512)]; + var builder = new StringBuilder(Math.Min(maximumCharacters, 256)); + while (true) + { + var count = reader.ReadValueChunk(buffer, 0, buffer.Length); + if (count == 0) + { + break; + } + + if (builder.Length + count > maximumCharacters) + { + throw new InvalidDataException( + $"Nmap XML attribute '{name}' exceeds the {maximumCharacters} character limit."); + } + + builder.Append(buffer, 0, count); + } + + return builder.Length == 0 ? null : builder.ToString(); + } + finally + { + reader.MoveToElement(); + } + } + + private static string? ReadElementText( + XmlReader reader, + int maximumCharacters, + CancellationToken cancellationToken) + { + if (reader.IsEmptyElement) + { + return null; + } + + var elementDepth = reader.Depth; + var buffer = new char[Math.Min(maximumCharacters + 1, 512)]; + var builder = new StringBuilder(Math.Min(maximumCharacters, 256)); + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reader.Depth > MaximumDepth) + { + throw new InvalidDataException($"Nmap XML exceeds the maximum element depth of {MaximumDepth}."); + } + + if (reader.NodeType is XmlNodeType.Text or XmlNodeType.CDATA or XmlNodeType.SignificantWhitespace) + { + while (true) + { + var count = reader.ReadValueChunk(buffer, 0, buffer.Length); + if (count == 0) + { + break; + } + + if (builder.Length + count > maximumCharacters) + { + throw new InvalidDataException( + $"Nmap XML text value exceeds the {maximumCharacters} character limit."); + } + + builder.Append(buffer, 0, count); + } + } + else if (reader.NodeType == XmlNodeType.Element) + { + throw new InvalidDataException("Nmap XML CPE values cannot contain nested elements."); + } + else if (reader.NodeType == XmlNodeType.EndElement && reader.Depth == elementDepth) + { + return ImportText.Sanitize(builder.ToString(), maximumCharacters); + } + } + + throw new InvalidDataException("Nmap XML ended inside a CPE value."); + } + + private sealed class HostAccumulator + { + public string? Ipv4 { get; set; } + + public string? Ipv6 { get; set; } + + public string? Hostname { get; set; } + + public List Endpoints { get; } = []; + } + + private sealed class PortAccumulator(string? protocol, string? portText) + { + public string? Protocol { get; } = protocol; + + public string? PortText { get; } = portText; + + public string State { get; set; } = "unknown"; + + public string? StateReason { get; set; } + + public ServiceAccumulator? Service { get; set; } + + public List ScriptIds { get; } = []; + } + + private sealed class ServiceAccumulator( + string? name, + string? product, + string? version, + string? extraInfo, + ImportedEvidenceStrength evidenceStrength, + string evidenceSource) + { + public string? Name { get; } = name; + + public string? Product { get; } = product; + + public string? Version { get; } = version; + + public string? ExtraInfo { get; } = extraInfo; + + public ImportedEvidenceStrength EvidenceStrength { get; } = evidenceStrength; + + public string EvidenceSource { get; } = evidenceSource; + + public List Cpes { get; } = []; + } + + private sealed record PendingEndpoint( + string Protocol, + int Port, + string State, + string? StateReason, + ImportedServiceIdentity? Service, + IReadOnlyList ScriptIds); +} diff --git a/src/PortCVE/Remote/Imports/NucleiJsonlImporter.cs b/src/PortCVE/Remote/Imports/NucleiJsonlImporter.cs new file mode 100644 index 0000000..7b1948f --- /dev/null +++ b/src/PortCVE/Remote/Imports/NucleiJsonlImporter.cs @@ -0,0 +1,447 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace PortCVE.Remote.Imports; + +internal static class NucleiJsonlImporter +{ + private const int MaximumRecords = 100000; + private const int MaximumPhysicalLines = 200000; + private const int MaximumRecordBytes = 1024 * 1024; + private const int ReadBufferBytes = 64 * 1024; + private const long MaximumRetainedCharacters = 16L * 1024 * 1024; + + private static readonly UTF8Encoding StrictUtf8 = + new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + public static PentestImportReport Import( + Stream stream, + bool strict = true, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanRead) + { + throw new ArgumentException("Nuclei JSONL input must be readable.", nameof(stream)); + } + + var findings = new List(); + var diagnostics = new List(); + var retentionBudget = new ImportRetentionBudget(MaximumRetainedCharacters); + var readBuffer = new byte[ReadBufferBytes]; + using var recordBuffer = new MemoryStream(capacity: 64 * 1024); + var physicalLines = 0; + var recordCount = 0; + var recordTooLarge = false; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var bytesRead = stream.Read(readBuffer, 0, readBuffer.Length); + if (bytesRead == 0) + { + break; + } + + var remaining = readBuffer.AsSpan(0, bytesRead); + while (!remaining.IsEmpty) + { + cancellationToken.ThrowIfCancellationRequested(); + var newline = remaining.IndexOf((byte)'\n'); + var segment = newline >= 0 ? remaining[..newline] : remaining; + AppendSegment( + segment, + recordBuffer, + ref recordTooLarge, + strict, + diagnostics, + physicalLines + 1, + retentionBudget); + + if (newline < 0) + { + break; + } + + physicalLines++; + EnforcePhysicalLineLimit(physicalLines); + ProcessRecord( + recordBuffer, + recordTooLarge, + strict, + physicalLines, + ref recordCount, + findings, + diagnostics, + retentionBudget, + cancellationToken); + recordBuffer.SetLength(0); + recordTooLarge = false; + remaining = remaining[(newline + 1)..]; + } + } + + if (recordBuffer.Length > 0 || recordTooLarge) + { + physicalLines++; + EnforcePhysicalLineLimit(physicalLines); + ProcessRecord( + recordBuffer, + recordTooLarge, + strict, + physicalLines, + ref recordCount, + findings, + diagnostics, + retentionBudget, + cancellationToken); + } + + return new( + "nuclei_jsonl", + null, + diagnostics.Count == 0, + [], + findings.OrderBy(static item => item.Target, StringComparer.Ordinal) + .ThenBy(static item => item.Port) + .ThenBy(static item => item.FindingId, StringComparer.Ordinal) + .ToArray(), + diagnostics); + } + + private static void AppendSegment( + ReadOnlySpan segment, + MemoryStream recordBuffer, + ref bool recordTooLarge, + bool strict, + ICollection diagnostics, + int lineNumber, + ImportRetentionBudget retentionBudget) + { + if (recordTooLarge || segment.IsEmpty) + { + return; + } + + if (recordBuffer.Length + segment.Length > MaximumRecordBytes) + { + recordBuffer.SetLength(0); + recordTooLarge = true; + if (strict) + { + HandleInvalid( + strict, + diagnostics, + lineNumber, + "Nuclei JSONL record exceeds the 1 MiB UTF-8 byte limit.", + retentionBudget); + } + + return; + } + + recordBuffer.Write(segment); + } + + private static void ProcessRecord( + MemoryStream recordBuffer, + bool recordTooLarge, + bool strict, + int lineNumber, + ref int recordCount, + ICollection findings, + ICollection diagnostics, + ImportRetentionBudget retentionBudget, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (recordTooLarge) + { + recordCount++; + EnforceRecordLimit(recordCount); + HandleInvalid( + strict, + diagnostics, + lineNumber, + "Nuclei JSONL record exceeds the 1 MiB UTF-8 byte limit.", + retentionBudget); + return; + } + + var length = checked((int)recordBuffer.Length); + var bytes = recordBuffer.GetBuffer().AsSpan(0, length); + if (bytes.EndsWith("\r"u8)) + { + bytes = bytes[..^1]; + } + + if (lineNumber == 1 && bytes.StartsWith(Encoding.UTF8.Preamble)) + { + bytes = bytes[Encoding.UTF8.Preamble.Length..]; + } + + var line = StrictUtf8.GetString(bytes); + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + recordCount++; + EnforceRecordLimit(recordCount); + try + { + var finding = ParseRecord(line, ImportText.Sha256(bytes)); + if (finding is not null) + { + retentionBudget.Reserve(FindingCharacters(finding)); + findings.Add(finding); + } + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + HandleInvalid(strict, diagnostics, lineNumber, exception.Message, retentionBudget); + } + } + + private static ImportedFinding? ParseRecord(string line, string sourceRecordSha256) + { + using var document = JsonDocument.Parse(line, new() { MaxDepth = 64 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Nuclei JSONL record must be an object."); + } + + if (root.TryGetProperty("matcher-status", out var matcherStatus)) + { + if (matcherStatus.ValueKind == JsonValueKind.False) + { + return null; + } + + if (matcherStatus.ValueKind != JsonValueKind.True) + { + throw new InvalidDataException("Nuclei JSONL matcher-status must be a boolean when present."); + } + } + + var templateId = RequiredIdentifier(root, "template-id", 256); + if (!root.TryGetProperty("info", out var info) || info.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Nuclei JSONL record is missing its info object."); + } + + var title = ImportText.SanitizePublicLabel(OptionalString(info, "name", 512), 512) ?? templateId; + var severity = (OptionalString(info, "severity", 32) ?? "unknown").ToLowerInvariant(); + if (severity is not "info" and not "low" and not "medium" and not "high" and not "critical" and not "unknown") + { + severity = "unknown"; + } + + var rawTarget = OptionalString(root, "matched-at", 2048) + ?? OptionalString(root, "host", 2048) + ?? throw new InvalidDataException("Nuclei JSONL record has no matched-at or host target."); + var target = ImportText.SanitizeTarget(rawTarget) + ?? throw new InvalidDataException("Nuclei JSONL target could not be reduced to safe endpoint metadata."); + var protocol = ImportText.SanitizeIdentifier( + OptionalString(root, "scheme", 32) ?? OptionalString(root, "type", 64), + 64)?.ToLowerInvariant(); + int? port = null; + if (root.TryGetProperty("port", out var portElement)) + { + if (portElement.ValueKind == JsonValueKind.Number && portElement.TryGetInt32(out var numericPort)) + { + port = numericPort is >= 1 and <= 65535 ? numericPort : null; + } + else if (portElement.ValueKind == JsonValueKind.String + && int.TryParse(portElement.GetString(), NumberStyles.None, CultureInfo.InvariantCulture, out numericPort)) + { + port = numericPort is >= 1 and <= 65535 ? numericPort : null; + } + } + + var advisoryIds = new SortedSet(StringComparer.Ordinal); + var references = new SortedSet(StringComparer.Ordinal); + if (info.TryGetProperty("classification", out var classification) && classification.ValueKind == JsonValueKind.Object) + { + AddAdvisoryIds(classification, "cve-id", advisoryIds, 64); + } + + AddReferences(info, "reference", references, 32); + var matcher = ImportText.SanitizeIdentifier(OptionalString(root, "matcher-name", 256), 256); + return new( + "nuclei_jsonl", + templateId, + title, + severity, + target, + port, + protocol, + ImportedClaimStatus.ImportedMatch, + ImportedEvidenceStrength.Unresolved, + advisoryIds.ToArray(), + references.ToArray(), + sourceRecordSha256, + matcher, + null); + } + + private static void AddAdvisoryIds( + JsonElement parent, + string property, + ISet destination, + int maximumItems) + { + AddSanitizedStrings(parent, property, destination, maximumItems, static value => + { + var candidate = ImportText.SanitizeIdentifier(value, 64)?.ToUpperInvariant(); + if (candidate is null || !candidate.StartsWith("CVE-", StringComparison.Ordinal) + || candidate.Length < 13 + || !ContainsOnlyAsciiDigits(candidate.AsSpan(4, 4)) + || candidate[8] != '-' + || !ContainsOnlyAsciiDigits(candidate.AsSpan(9))) + { + return null; + } + + return candidate; + }); + } + + private static void AddReferences( + JsonElement parent, + string property, + ISet destination, + int maximumItems) => + AddSanitizedStrings(parent, property, destination, maximumItems, ImportText.SanitizeReference); + + private static void AddSanitizedStrings( + JsonElement parent, + string property, + ISet destination, + int maximumItems, + Func sanitizer) + { + if (!parent.TryGetProperty(property, out var element)) + { + return; + } + + if (element.ValueKind == JsonValueKind.String) + { + var value = sanitizer(element.GetString()); + if (value is not null) + { + destination.Add(value); + } + + return; + } + + if (element.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var item in element.EnumerateArray()) + { + if (destination.Count >= maximumItems) + { + return; + } + + if (item.ValueKind == JsonValueKind.String) + { + var value = sanitizer(item.GetString()); + if (value is not null) + { + destination.Add(value); + } + } + } + } + + private static string RequiredIdentifier(JsonElement parent, string property, int maximumCharacters) + { + var raw = OptionalString(parent, property, maximumCharacters); + return ImportText.SanitizeIdentifier(raw, maximumCharacters) + ?? throw new InvalidDataException($"Nuclei JSONL record has no safe '{property}' identifier."); + } + + private static bool ContainsOnlyAsciiDigits(ReadOnlySpan value) + { + if (value.IsEmpty) + { + return false; + } + + foreach (var character in value) + { + if (character is < '0' or > '9') + { + return false; + } + } + + return true; + } + + private static long FindingCharacters(ImportedFinding finding) => + ImportRetentionBudget.Characters( + finding.Source, + finding.FindingId, + finding.Title, + finding.Severity, + finding.Target, + finding.Protocol, + finding.SourceRecordSha256, + finding.Matcher, + finding.Summary) + + ImportRetentionBudget.Characters(finding.AdvisoryIds) + + ImportRetentionBudget.Characters(finding.References); + + private static string? OptionalString(JsonElement parent, string property, int maximumCharacters) + { + if (!parent.TryGetProperty(property, out var element) || element.ValueKind != JsonValueKind.String) + { + return null; + } + + return ImportText.Sanitize(element.GetString(), maximumCharacters); + } + + private static void EnforcePhysicalLineLimit(int physicalLines) + { + if (physicalLines > MaximumPhysicalLines) + { + throw new InvalidDataException($"Nuclei JSONL exceeds the {MaximumPhysicalLines} physical-line import limit."); + } + } + + private static void EnforceRecordLimit(int recordCount) + { + if (recordCount > MaximumRecords) + { + throw new InvalidDataException($"Nuclei JSONL exceeds the {MaximumRecords} record import limit."); + } + } + + private static void HandleInvalid( + bool strict, + ICollection diagnostics, + int lineNumber, + string message, + ImportRetentionBudget retentionBudget) + { + if (strict) + { + throw new InvalidDataException($"Invalid Nuclei JSONL at line {lineNumber}: {message}"); + } + + var safeMessage = ImportText.SanitizePublicLabel(message, 512) ?? "The record was invalid."; + var diagnostic = new PentestImportDiagnostic("nuclei_record_invalid", $"Line {lineNumber}: {safeMessage}"); + retentionBudget.Reserve(ImportRetentionBudget.Characters(diagnostic.Code, diagnostic.Message)); + diagnostics.Add(diagnostic); + } +} diff --git a/src/PortCVE/Remote/Imports/PentestImportModels.cs b/src/PortCVE/Remote/Imports/PentestImportModels.cs new file mode 100644 index 0000000..da3b277 --- /dev/null +++ b/src/PortCVE/Remote/Imports/PentestImportModels.cs @@ -0,0 +1,91 @@ +namespace PortCVE.Remote.Imports; + +public enum RemoteImportFormat +{ + NmapXml, + NucleiJsonl, +} + +public enum ImportedEvidenceStrength +{ + Direct, + Strong, + Moderate, + Weak, + Conflicting, + Unresolved, +} + +public enum ImportedClaimStatus +{ + Observed, + Candidate, + ImportedMatch, + Inconclusive, +} + +public sealed record ImportedServiceIdentity( + string? Name, + string? Product, + string? Version, + string? ExtraInfo, + IReadOnlyList Cpes, + ImportedEvidenceStrength EvidenceStrength, + string EvidenceSource); + +public sealed record ImportedEndpoint( + string Target, + string? Hostname, + string Protocol, + int Port, + string State, + string? StateReason, + ImportedServiceIdentity? Service); + +public sealed record ImportedFinding( + string Source, + string FindingId, + string Title, + string Severity, + string Target, + int? Port, + string? Protocol, + ImportedClaimStatus ClaimStatus, + ImportedEvidenceStrength EvidenceStrength, + IReadOnlyList AdvisoryIds, + IReadOnlyList References, + string SourceRecordSha256, + string? Matcher, + string? Summary); + +public sealed record PentestImportDiagnostic( + string Code, + string Message); + +public sealed record PentestImportReport( + string Source, + string? SourceVersion, + bool IsComplete, + IReadOnlyList Endpoints, + IReadOnlyList Findings, + IReadOnlyList Diagnostics); + +public sealed record PentestImportInput( + string FileName, + long SizeBytes, + string Sha256); + +public sealed record PentestImportDocument( + int SchemaVersion, + string ToolVersion, + DateTimeOffset GeneratedAt, + PentestImportInput Input, + string Source, + string? SourceVersion, + bool IsComplete, + IReadOnlyList Endpoints, + IReadOnlyList Findings, + IReadOnlyList Diagnostics) +{ + public const int CurrentSchemaVersion = 1; +} diff --git a/src/PortCVE/Remote/Imports/PentestImportService.cs b/src/PortCVE/Remote/Imports/PentestImportService.cs new file mode 100644 index 0000000..c8b7ad1 --- /dev/null +++ b/src/PortCVE/Remote/Imports/PentestImportService.cs @@ -0,0 +1,135 @@ +using System.Security.Cryptography; +using System.Text; +using PortCVE.Vulnerabilities; + +namespace PortCVE.Remote.Imports; + +internal sealed class PentestImportService +{ + private const long MaximumNmapXmlBytes = 64L * 1024 * 1024; + private const long MaximumNucleiJsonlBytes = 256L * 1024 * 1024; + + private readonly Func inputPathValidator; + + public PentestImportService() + : this(LocalPathPolicy.ValidateExistingImportFile) + { + } + + internal PentestImportService(Func inputPathValidator) + { + this.inputPathValidator = inputPathValidator; + } + + public PentestImportDocument Import( + RemoteImportFormat format, + string inputPath, + string toolVersion, + bool strict, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputPath); + ArgumentException.ThrowIfNullOrWhiteSpace(toolVersion); + + var validation = inputPathValidator(inputPath); + if (!validation.IsValid) + { + throw new ImportPathException(validation.Code, validation.Message); + } + + var fullPath = validation.FullPath!; + var maximumBytes = format switch + { + RemoteImportFormat.NmapXml => MaximumNmapXmlBytes, + RemoteImportFormat.NucleiJsonl => MaximumNucleiJsonlBytes, + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + + using var stream = new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + FileOptions.SequentialScan); + if (stream.Length > maximumBytes) + { + throw new InvalidDataException( + $"Import input exceeds the {maximumBytes / (1024 * 1024)} MiB {FormatName(format)} file limit."); + } + + var originalLength = stream.Length; + var sha256 = HashStream(stream, cancellationToken); + stream.Position = 0; + + PentestImportReport report; + try + { + report = format switch + { + RemoteImportFormat.NmapXml => NmapXmlImporter.Import(stream, cancellationToken), + RemoteImportFormat.NucleiJsonl => NucleiJsonlImporter.Import(stream, strict, cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + } + catch (DecoderFallbackException exception) + { + throw new InvalidDataException("Nuclei JSONL must be valid UTF-8 text.", exception); + } + + if (stream.Length != originalLength) + { + throw new InvalidDataException("Import input changed while it was being read; the imported evidence was discarded."); + } + + stream.Position = 0; + var sha256AfterImport = HashStream(stream, cancellationToken); + if (!string.Equals(sha256, sha256AfterImport, StringComparison.Ordinal)) + { + throw new InvalidDataException("Import input changed while it was being read; the imported evidence was discarded."); + } + + return new( + PentestImportDocument.CurrentSchemaVersion, + toolVersion, + DateTimeOffset.UtcNow, + new(Path.GetFileName(fullPath), originalLength, sha256), + report.Source, + report.SourceVersion, + report.IsComplete, + report.Endpoints, + report.Findings, + report.Diagnostics); + } + + private static string FormatName(RemoteImportFormat format) => format switch + { + RemoteImportFormat.NmapXml => "Nmap XML", + RemoteImportFormat.NucleiJsonl => "Nuclei JSONL", + _ => "import", + }; + + private static string HashStream(Stream stream, CancellationToken cancellationToken) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[64 * 1024]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var bytesRead = stream.Read(buffer, 0, buffer.Length); + if (bytesRead == 0) + { + break; + } + + hash.AppendData(buffer, 0, bytesRead); + } + + return Convert.ToHexStringLower(hash.GetHashAndReset()); + } +} + +internal sealed class ImportPathException(string code, string message) : IOException(message) +{ + public string Code { get; } = code; +} diff --git a/src/PortCVE/Remote/RemoteAuditModels.cs b/src/PortCVE/Remote/RemoteAuditModels.cs new file mode 100644 index 0000000..823ee13 --- /dev/null +++ b/src/PortCVE/Remote/RemoteAuditModels.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Serialization; +using PortCVE.Remote.Advisories; + +namespace PortCVE.Remote; + +internal enum RemoteIdentityDisposition +{ + Resolved, + Unresolved, + NotEligible, +} + +internal sealed record RemoteAdvisoryAssessment( + string SubjectId, + string Target, + string Address, + int Port, + string Product, + string? Version, + RemoteProductConfidence EvidenceConfidence, + string Evidence, + RemoteIdentityDisposition IdentityDisposition, + string? Cpe23Uri, + string? MappingSource, + string? AdvisoryResultId, + IReadOnlyList Diagnostics); + +internal sealed record RemoteAdvisoryProviderResult( + string ResultId, + string Product, + string Version, + string Cpe23Uri, + string MappingSource, + RemoteAdvisoryStatus Status, + string Provider, + string NetworkMode, + DateTimeOffset? SourceTimestamp, + IReadOnlyList Matches, + IReadOnlyList Diagnostics); + +internal sealed record RemoteAuditSummary( + int TargetCount, + int ResolvedTargetCount, + int EndpointCount, + int OpenPortCount, + int ProductCandidateCount, + int AdvisoryAssessmentCount, + int AdvisoryResultCount, + int AdvisoryMatchCount, + int ConditionalCount, + int InconclusiveCount, + int CriticalCount, + int HighCount, + bool IsComplete); + +internal sealed record RemoteAuditReport( + int SchemaVersion, + string ToolVersion, + DateTimeOffset GeneratedAt, + string Selector, + string Transport, + string ProbeProfile, + bool AuthorizationAsserted, + bool OnlineAdvisoriesRequested, + RemoteAdvisoryStatus AdvisoryStatus, + int AdvisoryIdentityLimit, + IReadOnlyList RequestedPorts, + IReadOnlyList Hosts, + IReadOnlyList AdvisoryAssessments, + IReadOnlyList AdvisoryResults, + RemoteAuditSummary Summary, + IReadOnlyList Diagnostics, + string ClaimBoundary, + string? NvdNotice) +{ + internal const int CurrentSchemaVersion = 1; + + [JsonIgnore] + internal bool AdvisoryProviderFailed => OnlineAdvisoriesRequested + && AdvisoryStatus is RemoteAdvisoryStatus.Unavailable or RemoteAdvisoryStatus.Failed; +} diff --git a/src/PortCVE/Remote/RemoteAuditRedactor.cs b/src/PortCVE/Remote/RemoteAuditRedactor.cs new file mode 100644 index 0000000..b06e4b8 --- /dev/null +++ b/src/PortCVE/Remote/RemoteAuditRedactor.cs @@ -0,0 +1,103 @@ +using PortCVE.Remote.Advisories; + +namespace PortCVE.Remote; + +internal static class RemoteAuditRedactor +{ + private static readonly HashSet SafeFingerprintAttributes = new(StringComparer.Ordinal) + { + "httpVersion", + "protocolVersion", + "statusCode", + "tlsProtocol", + }; + + internal static RemoteAuditReport Redact(RemoteAuditReport report) + { + ArgumentNullException.ThrowIfNull(report); + + var targetAliases = report.Hosts + .Select(static host => host.Target) + .Distinct(StringComparer.Ordinal) + .Select((target, index) => (target, alias: $"target-{index + 1:000}")) + .ToDictionary(static item => item.target, static item => item.alias, StringComparer.Ordinal); + var addressAliases = report.Hosts + .SelectMany(static host => host.ResolvedAddresses) + .Concat(report.Hosts.SelectMany(static host => host.Ports.Select(static port => port.Address))) + .Distinct(StringComparer.Ordinal) + .Select((address, index) => (address, alias: $"address-{index + 1:000}")) + .ToDictionary(static item => item.address, static item => item.alias, StringComparer.Ordinal); + + var hosts = report.Hosts.Select(host => new RemoteHostReport( + Alias(targetAliases, host.Target, "target-redacted"), + host.ResolvedAddresses.Select(address => Alias(addressAliases, address, "address-redacted")).ToArray(), + host.Ports.Select(port => RedactPort(port, addressAliases)).ToArray(), + RedactDiagnostics( + host.Diagnostics, + "Remote target details were redacted; use the diagnostic code for classification."))).ToArray(); + + var assessments = report.AdvisoryAssessments.Select(item => item with + { + Target = Alias(targetAliases, item.Target, "target-redacted"), + Address = Alias(addressAliases, item.Address, "address-redacted"), + Evidence = "[redacted]", + Diagnostics = RedactAdvisoryDiagnostics( + item.Diagnostics, + "Remote advisory assessment details were redacted; use the diagnostic code for classification."), + }).ToArray(); + var advisoryResults = report.AdvisoryResults.Select(result => result with + { + Matches = result.Matches.Select(match => match with { Evidence = "[redacted]" }).ToArray(), + Diagnostics = RedactAdvisoryDiagnostics( + result.Diagnostics, + "Advisory provider details were redacted; use the diagnostic code for classification."), + }).ToArray(); + + return report with + { + Selector = "redacted", + Hosts = hosts, + AdvisoryAssessments = assessments, + AdvisoryResults = advisoryResults, + Diagnostics = RedactDiagnostics( + report.Diagnostics, + "Remote report details were redacted; use the diagnostic code for classification."), + }; + } + + private static RemotePortResult RedactPort( + RemotePortResult port, + IReadOnlyDictionary addressAliases) => + port with + { + Address = Alias(addressAliases, port.Address, "address-redacted"), + Fingerprints = port.Fingerprints.Select(static fingerprint => fingerprint with + { + Evidence = "[redacted]", + Attributes = RemoteFingerprint.ReadOnlyAttributes( + fingerprint.Attributes + .Where(attribute => SafeFingerprintAttributes.Contains(attribute.Key)) + .ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal)), + }).ToArray(), + ProductCandidates = port.ProductCandidates.Select(static candidate => candidate with + { + Evidence = "[redacted]", + }).ToArray(), + Diagnostics = RedactDiagnostics( + port.Diagnostics, + "Remote endpoint details were redacted; use the diagnostic code for classification."), + }; + + private static IReadOnlyList RedactDiagnostics( + IReadOnlyList diagnostics, + string message) => diagnostics.Select(diagnostic => diagnostic with { Message = message }).ToArray(); + + private static IReadOnlyList RedactAdvisoryDiagnostics( + IReadOnlyList diagnostics, + string message) => diagnostics.Select(diagnostic => diagnostic with { Message = message }).ToArray(); + + private static string Alias( + IReadOnlyDictionary aliases, + string value, + string fallback) => aliases.TryGetValue(value, out var alias) ? alias : fallback; +} diff --git a/src/PortCVE/Remote/RemoteAuditService.cs b/src/PortCVE/Remote/RemoteAuditService.cs new file mode 100644 index 0000000..d78d5f7 --- /dev/null +++ b/src/PortCVE/Remote/RemoteAuditService.cs @@ -0,0 +1,495 @@ +using System.Globalization; +using PortCVE.Remote.Advisories; + +namespace PortCVE.Remote; + +internal sealed record RemoteAuditOptions( + string ToolVersion, + RemoteTargetPlan TargetPlan, + IReadOnlyList Ports, + ProbeDepth ProbeDepth, + bool AuthorizationAsserted, + bool OnlineAdvisories, + int Concurrency, + int Rate, + TimeSpan ConnectTimeout, + TimeSpan ReadTimeout, + string? NvdApiKey); + +internal sealed class RemoteAuditService +{ + internal const int MaximumPlannedEndpoints = 1_000_000; + internal const int MaximumUniqueAdvisoryIdentities = 64; + internal const string ClaimBoundary = + "Remote fingerprints and CVE correlations are evidence-backed candidates, not proof of vulnerable or exploitable code."; + internal const string NvdNotice = + "This product uses data from the NVD API but is not endorsed or certified by the NVD."; + + private readonly IRemoteHostScanner hostScanner; + private readonly IRemoteAdvisoryClient advisoryClient; + private readonly RemoteBannerCpeCatalog cpeCatalog; + + internal RemoteAuditService( + IRemoteHostScanner hostScanner, + IRemoteAdvisoryClient advisoryClient, + RemoteBannerCpeCatalog? cpeCatalog = null) + { + this.hostScanner = hostScanner ?? throw new ArgumentNullException(nameof(hostScanner)); + this.advisoryClient = advisoryClient ?? throw new ArgumentNullException(nameof(advisoryClient)); + this.cpeCatalog = cpeCatalog ?? new RemoteBannerCpeCatalog(); + } + + internal async Task AssessAsync( + RemoteAuditOptions options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options.TargetPlan); + ArgumentNullException.ThrowIfNull(options.Ports); + cancellationToken.ThrowIfCancellationRequested(); + if (options.TargetPlan.Targets.Count == 0) + { + throw new ArgumentException("At least one planned target is required.", nameof(options)); + } + + if (!options.AuthorizationAsserted) + { + throw new ArgumentException("Remote assessment requires an authorization assertion.", nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.ToolVersion) + || string.IsNullOrWhiteSpace(options.TargetPlan.Selector)) + { + throw new ArgumentException("Tool version and target selector are required.", nameof(options)); + } + + var normalizedPorts = options.Ports.Distinct().Order().ToArray(); + if (normalizedPorts.Length == 0 + || normalizedPorts.Any(static port => port is < 1 or > 65_535)) + { + throw new ArgumentException("At least one valid TCP port from 1 to 65535 is required.", nameof(options)); + } + + options = options with { Ports = normalizedPorts }; + var plannedEndpoints = (long)options.TargetPlan.Targets.Count * options.Ports.Count; + if (plannedEndpoints > MaximumPlannedEndpoints) + { + throw new ArgumentOutOfRangeException( + nameof(options), + $"The target/port plan contains {plannedEndpoints.ToString("N0", CultureInfo.InvariantCulture)} TCP endpoints; " + + $"the in-memory report limit is {MaximumPlannedEndpoints.ToString("N0", CultureInfo.InvariantCulture)}. " + + "Split the assessment into smaller runs."); + } + + var hosts = await ScanTargetsAsync(options, cancellationToken).ConfigureAwait(false); + var advisoryBatch = await AssessProductsAsync(hosts, options, cancellationToken).ConfigureAwait(false); + var assessments = advisoryBatch.Assessments; + var advisoryResults = advisoryBatch.Results; + var advisoryStatus = ComputeAdvisoryStatus( + hosts, + assessments, + advisoryResults, + options.OnlineAdvisories, + advisoryBatch.IdentityLimitExceeded); + var diagnostics = BuildReportDiagnostics( + hosts, + assessments, + options.OnlineAdvisories, + advisoryStatus, + advisoryBatch.IdentityLimitExceeded); + var openPorts = hosts.SelectMany(static host => host.Ports) + .Count(static port => port.State == RemotePortState.Open); + var productCandidates = hosts.SelectMany(static host => host.Ports) + .Sum(static port => port.ProductCandidates.Count); + var allMatches = advisoryResults.SelectMany(static item => item.Matches) + .ToArray(); + var matches = allMatches + .Where(IsCandidateClaim) + .ToArray(); + var directMatches = matches + .Where(static match => string.Equals(match.Classification, "candidate", StringComparison.Ordinal)) + .ToArray(); + var complete = IsComplete( + hosts, + advisoryStatus, + options.OnlineAdvisories); + + return new( + RemoteAuditReport.CurrentSchemaVersion, + options.ToolVersion, + DateTimeOffset.UtcNow, + options.TargetPlan.Selector, + "tcp", + options.ProbeDepth == ProbeDepth.Active ? "safe_active" : "discovery", + options.AuthorizationAsserted, + options.OnlineAdvisories, + advisoryStatus, + MaximumUniqueAdvisoryIdentities, + options.Ports, + hosts, + assessments, + advisoryResults, + new( + options.TargetPlan.Targets.Count, + hosts.Count(static host => host.ResolvedAddresses.Count > 0), + hosts.Sum(static host => host.Ports.Count), + openPorts, + productCandidates, + assessments.Count, + advisoryResults.Count, + matches.Length, + matches.Count(static match => string.Equals( + match.Classification, + "conditional_candidate", + StringComparison.Ordinal)), + allMatches.Count(static match => string.Equals( + match.Classification, + "inconclusive", + StringComparison.Ordinal)), + directMatches.Count(static match => match.Severity == RemoteAdvisorySeverity.Critical), + directMatches.Count(static match => match.Severity == RemoteAdvisorySeverity.High), + complete), + diagnostics, + ClaimBoundary, + options.OnlineAdvisories ? NvdNotice : null); + } + + private async Task> ScanTargetsAsync( + RemoteAuditOptions options, + CancellationToken cancellationToken) + { + var targets = options.TargetPlan.Targets; + var reports = new RemoteHostReport[targets.Count]; + var hostConcurrency = Math.Min(Math.Min(16, options.Concurrency), targets.Count); + var portConcurrency = Math.Max(1, options.Concurrency / Math.Max(1, hostConcurrency)); + var parallel = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Max(1, hostConcurrency), + }; + + await Parallel.ForEachAsync( + Enumerable.Range(0, targets.Count), + parallel, + async (index, token) => + { + reports[index] = await hostScanner.ScanAsync( + new( + targets[index], + options.Ports, + options.ConnectTimeout, + options.ReadTimeout, + portConcurrency, + options.ProbeDepth, + maxConnectionsPerSecond: options.Rate), + token).ConfigureAwait(false); + }).ConfigureAwait(false); + + return reports; + } + + private async Task AssessProductsAsync( + IReadOnlyList hosts, + RemoteAuditOptions options, + CancellationToken cancellationToken) + { + var candidates = hosts + .SelectMany(host => host.Ports + .Where(static port => port.State == RemotePortState.Open) + .SelectMany(port => port.ProductCandidates.Select(candidate => new CandidateContext( + host.Target, + port.Address, + port.Port, + candidate)))) + .OrderBy(static item => item.Target, StringComparer.Ordinal) + .ThenBy(static item => item.Address, StringComparer.Ordinal) + .ThenBy(static item => item.Port) + .ThenBy(static item => item.Candidate.Product, StringComparer.Ordinal) + .ThenBy(static item => item.Candidate.Version, StringComparer.Ordinal) + .ToArray(); + + var assessments = new List(candidates.Length); + var results = new List(); + var resultsByIdentity = new Dictionary( + StringComparer.OrdinalIgnoreCase); + var identityLimitExceeded = false; + var subjectIndex = 0; + foreach (var context in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + subjectIndex++; + var candidate = context.Candidate; + if (candidate.Confidence != RemoteProductConfidence.BannerPattern) + { + assessments.Add(CreateUnresolvedAssessment( + subjectIndex, + context, + RemoteIdentityDisposition.NotEligible, + "identity_evidence_insufficient", + "A self-reported HTTP header is retained for review but is not strong enough for CVE correlation.")); + continue; + } + + var resolution = cpeCatalog.Resolve( + candidate.Product, + candidate.Version, + candidate.Evidence, + RemoteAdvisoryConfidence.Strong); + if (!resolution.IsResolved) + { + assessments.Add(CreateUnresolvedAssessment( + subjectIndex, + context, + RemoteIdentityDisposition.Unresolved, + resolution.Diagnostic?.Code ?? "cpe_unresolved", + resolution.Diagnostic?.Message ?? "No verified CPE mapping was available.")); + continue; + } + + if (!options.OnlineAdvisories) + { + assessments.Add(CreateResolvedAssessment( + subjectIndex, + context, + resolution, + advisoryResultId: null, + diagnostics: [])); + continue; + } + + var key = resolution.Cpe23Uri!; + if (!resultsByIdentity.TryGetValue(key, out var providerResult)) + { + if (results.Count >= MaximumUniqueAdvisoryIdentities) + { + identityLimitExceeded = true; + assessments.Add(CreateResolvedAssessment( + subjectIndex, + context, + resolution, + advisoryResultId: null, + diagnostics: + [ + new( + "nvd_identity_cap_exceeded", + $"The run-wide limit of {MaximumUniqueAdvisoryIdentities.ToString(CultureInfo.InvariantCulture)} " + + "unique catalog-backed identities was reached; this identity was not sent to NVD."), + ])); + continue; + } + + var result = await advisoryClient.EnrichAsync( + new( + new( + candidate.Product, + candidate.Version!, + candidate.Evidence, + RemoteAdvisoryConfidence.Strong, + resolution), + ExplicitOnline: true, + options.NvdApiKey), + cancellationToken).ConfigureAwait(false); + providerResult = new( + $"remote-advisory-result-{results.Count + 1:0000}", + candidate.Product, + candidate.Version!, + resolution.Cpe23Uri!, + resolution.MappingSource, + result.Status, + result.Provider, + result.NetworkMode, + result.SourceTimestamp, + result.Matches, + result.Diagnostics); + results.Add(providerResult); + resultsByIdentity.Add(key, providerResult); + } + + assessments.Add(CreateResolvedAssessment( + subjectIndex, + context, + resolution, + providerResult.ResultId, + diagnostics: [])); + } + + return new(assessments, results, identityLimitExceeded); + } + + private static RemoteAdvisoryAssessment CreateResolvedAssessment( + int subjectIndex, + CandidateContext context, + RemoteBannerCpeCatalog.Resolution resolution, + string? advisoryResultId, + IReadOnlyList diagnostics) => + new( + $"remote-product-{subjectIndex:0000}", + context.Target, + context.Address, + context.Port, + context.Candidate.Product, + context.Candidate.Version, + context.Candidate.Confidence, + context.Candidate.Evidence, + RemoteIdentityDisposition.Resolved, + resolution.Cpe23Uri, + resolution.MappingSource, + advisoryResultId, + diagnostics); + + private static RemoteAdvisoryAssessment CreateUnresolvedAssessment( + int subjectIndex, + CandidateContext context, + RemoteIdentityDisposition disposition, + string code, + string message) => + new( + $"remote-product-{subjectIndex:0000}", + context.Target, + context.Address, + context.Port, + context.Candidate.Product, + context.Candidate.Version, + context.Candidate.Confidence, + context.Candidate.Evidence, + disposition, + null, + null, + null, + [new(code, message)]); + + private static IReadOnlyList BuildReportDiagnostics( + IReadOnlyList hosts, + IReadOnlyList assessments, + bool onlineAdvisories, + RemoteAdvisoryStatus advisoryStatus, + bool identityLimitExceeded) + { + var diagnostics = new List(); + if (hosts.Any(static host => host.Diagnostics.Count > 0)) + { + diagnostics.Add(new( + "remote_targets_incomplete", + "One or more targets could not be resolved or scanned completely.")); + } + + if (hosts.SelectMany(static host => host.Ports) + .Any(static port => !IsConclusivePortState(port.State))) + { + diagnostics.Add(new( + "remote_endpoints_incomplete", + "One or more TCP endpoint probes failed before a conclusive state was observed.")); + } + + if (hosts.SelectMany(static host => host.Ports) + .Any(static port => port.State == RemotePortState.Open && port.Diagnostics.Count > 0)) + { + diagnostics.Add(new( + "remote_fingerprint_incomplete", + "At least one open service could not complete every selected identification or safe-active probe.")); + } + + if (onlineAdvisories && advisoryStatus != RemoteAdvisoryStatus.Complete) + { + diagnostics.Add(new( + "remote_advisories_incomplete", + "NVD enrichment was incomplete for one or more remote product identities.")); + } + + if (identityLimitExceeded) + { + diagnostics.Add(new( + "remote_advisory_identity_limit_exceeded", + $"The run contained more than {MaximumUniqueAdvisoryIdentities.ToString(CultureInfo.InvariantCulture)} " + + "unique strong catalog-backed identities; additional identities were not sent to NVD.")); + } + + if (onlineAdvisories + && (assessments.Any(static item => item.IdentityDisposition != RemoteIdentityDisposition.Resolved) + || hosts.SelectMany(static host => host.Ports) + .Any(static port => port.State == RemotePortState.Open && port.ProductCandidates.Count == 0))) + { + diagnostics.Add(new( + "remote_identity_unresolved", + "At least one open service lacked a strong, catalog-backed product/version identity for CVE correlation.")); + } + + return diagnostics; + } + + private static RemoteAdvisoryStatus ComputeAdvisoryStatus( + IReadOnlyList hosts, + IReadOnlyList assessments, + IReadOnlyList results, + bool onlineAdvisories, + bool identityLimitExceeded) + { + if (!onlineAdvisories) + { + return RemoteAdvisoryStatus.NotRequested; + } + + if (results.Any(static result => result.Status == RemoteAdvisoryStatus.Failed)) + { + return RemoteAdvisoryStatus.Failed; + } + + if (results.Any(static result => result.Status == RemoteAdvisoryStatus.Unavailable)) + { + return RemoteAdvisoryStatus.Unavailable; + } + + var openPortWithoutIdentity = hosts.SelectMany(static host => host.Ports) + .Any(static port => port.State == RemotePortState.Open && port.ProductCandidates.Count == 0); + if (identityLimitExceeded || + openPortWithoutIdentity || + assessments.Any(static assessment => + assessment.IdentityDisposition != RemoteIdentityDisposition.Resolved || + assessment.AdvisoryResultId is null) || + results.Any(static result => result.Status != RemoteAdvisoryStatus.Complete)) + { + return RemoteAdvisoryStatus.Partial; + } + + return RemoteAdvisoryStatus.Complete; + } + + private static bool IsComplete( + IReadOnlyList hosts, + RemoteAdvisoryStatus advisoryStatus, + bool onlineAdvisories) + { + if (hosts.Any(static host => host.Diagnostics.Count > 0) + || hosts.SelectMany(static host => host.Ports).Any(static port => + !IsConclusivePortState(port.State) + || (port.State == RemotePortState.Open && port.Diagnostics.Count > 0))) + { + return false; + } + + if (!onlineAdvisories) + { + return true; + } + + return advisoryStatus == RemoteAdvisoryStatus.Complete; + } + + private static bool IsConclusivePortState(RemotePortState state) => + state is RemotePortState.Open or RemotePortState.Closed; + + internal static bool IsCandidateClaim(RemoteAdvisoryMatch match) => + string.Equals(match.Classification, "candidate", StringComparison.Ordinal) + || string.Equals(match.Classification, "conditional_candidate", StringComparison.Ordinal); + + private sealed record CandidateContext( + string Target, + string Address, + int Port, + RemoteProductCandidate Candidate); + + private sealed record AdvisoryAssessmentBatch( + IReadOnlyList Assessments, + IReadOnlyList Results, + bool IdentityLimitExceeded); +} diff --git a/src/PortCVE/Remote/RemoteAuditTextRenderer.cs b/src/PortCVE/Remote/RemoteAuditTextRenderer.cs new file mode 100644 index 0000000..7774905 --- /dev/null +++ b/src/PortCVE/Remote/RemoteAuditTextRenderer.cs @@ -0,0 +1,231 @@ +using System.Globalization; + +namespace PortCVE.Remote; + +internal static class RemoteAuditTextRenderer +{ + private const int MaximumRenderedAdvisoryMatches = 100; + private const int MaximumEndpointSamplesPerResult = 5; + private const int MaximumRenderedAssessmentDiagnostics = 50; + private const int MaximumRenderedProviderDiagnostics = 50; + + internal static void Render(RemoteAuditReport report, TextWriter output, TextWriter error) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + output.WriteLine($"PortCVE remote assessment: {report.Selector}"); + output.WriteLine($"Profile: {report.ProbeProfile}; TCP ports: {FormatPorts(report.RequestedPorts)}"); + output.WriteLine(); + + foreach (var host in report.Hosts) + { + output.WriteLine(host.ResolvedAddresses.Count == 0 + ? $"{host.Target} unresolved" + : $"{host.Target} {string.Join(", ", host.ResolvedAddresses)}"); + + foreach (var port in host.Ports.Where(static item => item.State == RemotePortState.Open)) + { + var services = port.Fingerprints + .Select(static item => item.Service) + .Where(static item => !string.Equals(item, "unknown", StringComparison.Ordinal)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var products = port.ProductCandidates + .Select(static item => item.Version is null ? item.Product : $"{item.Product} {item.Version}") + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var identity = products.Length > 0 + ? string.Join(", ", products) + : services.Length > 0 + ? string.Join(", ", services) + : "service not identified"; + output.WriteLine($" {port.Address}:{port.Port,-5} open {identity}"); + } + + foreach (var diagnostic in host.Diagnostics) + { + error.WriteLine($"{diagnostic.Code}: {diagnostic.Message}"); + } + + foreach (var port in host.Ports.Where(static item => + item.State == RemotePortState.Open && item.Diagnostics.Count > 0)) + { + foreach (var diagnostic in port.Diagnostics) + { + error.WriteLine($"{host.Target}:{port.Port} {diagnostic.Code}: {diagnostic.Message}"); + } + } + } + + if (report.AdvisoryAssessments.Count > 0) + { + output.WriteLine(); + output.WriteLine("ADVISORY CORRELATION"); + var renderedAnyMatch = false; + var renderedMatchCount = 0; + var associationSummaries = BuildEndpointAssociationSummaries(report.AdvisoryAssessments); + foreach (var result in report.AdvisoryResults) + { + if (renderedMatchCount >= MaximumRenderedAdvisoryMatches) + { + break; + } + + var matches = result.Matches + .Take(MaximumRenderedAdvisoryMatches - renderedMatchCount) + .ToArray(); + if (matches.Length == 0) + { + continue; + } + + _ = associationSummaries.TryGetValue(result.ResultId, out var associations); + output.WriteLine( + $" {result.ResultId} {(associations?.Count ?? 0).ToString(CultureInfo.InvariantCulture)} endpoint association(s): " + + FormatEndpointSample(associations)); + foreach (var match in matches) + { + renderedAnyMatch = true; + renderedMatchCount++; + output.WriteLine( + $" {match.AdvisoryId,-18} {match.Severity.ToString().ToLowerInvariant(),-8} " + + $"{result.Product} {result.Version} " + + $"[{match.Classification}; NVD {match.NvdStatus}]"); + foreach (var limitation in match.Applicability.Limitations.Take(2)) + { + output.WriteLine($" - {limitation}"); + } + } + } + + var totalMatchCount = report.AdvisoryResults.Sum(static result => result.Matches.Count); + if (totalMatchCount > renderedMatchCount) + { + output.WriteLine( + $" ... {(totalMatchCount - renderedMatchCount).ToString(CultureInfo.InvariantCulture)} " + + "additional shared advisory match(es) omitted from text; use JSON for the bounded full report."); + } + + if (!renderedAnyMatch) + { + output.WriteLine(" No candidate advisory matches were returned for resolved strong identities."); + } + + WriteBoundedAdvisoryDiagnostics( + report.AdvisoryAssessments.SelectMany(static assessment => assessment.Diagnostics), + MaximumRenderedAssessmentDiagnostics, + "remote_assessment_diagnostics_truncated", + "endpoint advisory diagnostic(s)", + error); + WriteBoundedAdvisoryDiagnostics( + report.AdvisoryResults.SelectMany(static result => result.Diagnostics), + MaximumRenderedProviderDiagnostics, + "remote_provider_diagnostics_truncated", + "provider diagnostic(s)", + error); + } + + foreach (var diagnostic in report.Diagnostics) + { + error.WriteLine($"{diagnostic.Code}: {diagnostic.Message}"); + } + + output.WriteLine(); + output.WriteLine( + $"Summary: {report.Summary.OpenPortCount.ToString(CultureInfo.InvariantCulture)} open TCP endpoints; " + + $"{report.Summary.AdvisoryMatchCount.ToString(CultureInfo.InvariantCulture)} unique candidate advisory matches " + + $"({report.Summary.ConditionalCount.ToString(CultureInfo.InvariantCulture)} conditional, " + + $"{report.Summary.InconclusiveCount.ToString(CultureInfo.InvariantCulture)} inconclusive); " + + $"evidence {(report.Summary.IsComplete ? "complete" : "incomplete")}."); + output.WriteLine(report.ClaimBoundary); + if (report.NvdNotice is not null) + { + output.WriteLine(report.NvdNotice); + } + } + + private static string FormatPorts(IReadOnlyList ports) + { + if (ports.Count <= 12) + { + return string.Join(",", ports); + } + + return $"{ports.Count.ToString(CultureInfo.InvariantCulture)} selected"; + } + + private static void WriteBoundedAdvisoryDiagnostics( + IEnumerable diagnostics, + int maximum, + string truncationCode, + string description, + TextWriter error) + { + var count = 0; + foreach (var diagnostic in diagnostics) + { + if (count < maximum) + { + error.WriteLine($"{diagnostic.Code}: {diagnostic.Message}"); + } + + count++; + } + + if (count > maximum) + { + error.WriteLine( + $"{truncationCode}: {(count - maximum).ToString(CultureInfo.InvariantCulture)} " + + $"additional {description} omitted; use JSON for the bounded full report."); + } + } + + private static IReadOnlyDictionary BuildEndpointAssociationSummaries( + IReadOnlyList assessments) + { + var summaries = new Dictionary(StringComparer.Ordinal); + foreach (var assessment in assessments) + { + if (assessment.AdvisoryResultId is null) + { + continue; + } + + if (!summaries.TryGetValue(assessment.AdvisoryResultId, out var summary)) + { + summary = new(); + summaries.Add(assessment.AdvisoryResultId, summary); + } + + summary.Count++; + if (summary.Samples.Count < MaximumEndpointSamplesPerResult) + { + summary.Samples.Add( + $"{assessment.Target} [{assessment.Address}]:{assessment.Port.ToString(CultureInfo.InvariantCulture)}"); + } + } + + return summaries; + } + + private static string FormatEndpointSample(EndpointAssociationSummary? summary) + { + if (summary is null || summary.Count == 0) + { + return "none"; + } + + var remaining = summary.Count - summary.Samples.Count; + return remaining == 0 + ? string.Join(", ", summary.Samples) + : $"{string.Join(", ", summary.Samples)} (+{remaining.ToString(CultureInfo.InvariantCulture)} more)"; + } + + private sealed class EndpointAssociationSummary + { + internal int Count { get; set; } + internal List Samples { get; } = []; + } +} diff --git a/src/PortCVE/Remote/RemoteConnectionRateLimiter.cs b/src/PortCVE/Remote/RemoteConnectionRateLimiter.cs new file mode 100644 index 0000000..fe6463d --- /dev/null +++ b/src/PortCVE/Remote/RemoteConnectionRateLimiter.cs @@ -0,0 +1,54 @@ +using System.Diagnostics; + +namespace PortCVE.Remote; + +internal interface IRemoteConnectionRateLimiter +{ + ValueTask WaitAsync(CancellationToken cancellationToken); +} + +internal sealed class MonotonicConnectionRateLimiter : IRemoteConnectionRateLimiter +{ + private readonly object sync = new(); + private readonly long intervalTicks; + private long nextPermitTimestamp; + + public MonotonicConnectionRateLimiter(int maximumConnectionsPerSecond) + { + if (maximumConnectionsPerSecond is < 1 or > RemoteScanOptions.MaximumConnectionsPerSecondLimit) + { + throw new ArgumentOutOfRangeException(nameof(maximumConnectionsPerSecond)); + } + + intervalTicks = Math.Max( + 1, + (long)Math.Ceiling((double)Stopwatch.Frequency / maximumConnectionsPerSecond)); + } + + public async ValueTask WaitAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + long permitTimestamp; + lock (sync) + { + var now = Stopwatch.GetTimestamp(); + permitTimestamp = Math.Max(now, nextPermitTimestamp); + nextPermitTimestamp = permitTimestamp > long.MaxValue - intervalTicks + ? long.MaxValue + : permitTimestamp + intervalTicks; + } + + while (true) + { + var remainingTicks = permitTimestamp - Stopwatch.GetTimestamp(); + if (remainingTicks <= 0) + { + return; + } + + var delay = TimeSpan.FromSeconds((double)remainingTicks / Stopwatch.Frequency); + await Task.Delay(delay, cancellationToken); + } + } +} diff --git a/src/PortCVE/Remote/RemoteDnsResolver.cs b/src/PortCVE/Remote/RemoteDnsResolver.cs new file mode 100644 index 0000000..ef9971b --- /dev/null +++ b/src/PortCVE/Remote/RemoteDnsResolver.cs @@ -0,0 +1,22 @@ +using System.Net; +using System.Net.Sockets; + +namespace PortCVE.Remote; + +internal interface IRemoteDnsResolver +{ + Task ResolveAsync(string target, CancellationToken cancellationToken); +} + +internal sealed class SystemRemoteDnsResolver : IRemoteDnsResolver +{ + public Task ResolveAsync(string target, CancellationToken cancellationToken) + { + if (IPAddress.TryParse(target, out var address)) + { + return Task.FromResult([address]); + } + + return Dns.GetHostAddressesAsync(target, AddressFamily.Unspecified, cancellationToken); + } +} diff --git a/src/PortCVE/Remote/RemoteEvidenceSanitizer.cs b/src/PortCVE/Remote/RemoteEvidenceSanitizer.cs new file mode 100644 index 0000000..ffaa6ec --- /dev/null +++ b/src/PortCVE/Remote/RemoteEvidenceSanitizer.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Text; + +namespace PortCVE.Remote; + +internal static class RemoteEvidenceSanitizer +{ + public static string Sanitize(ReadOnlySpan bytes, int maximumUtf8Bytes) => + Sanitize(Encoding.UTF8.GetString(bytes), maximumUtf8Bytes); + + public static string Sanitize(string? value, int maximumUtf8Bytes) + { + if (string.IsNullOrEmpty(value) || maximumUtf8Bytes <= 0) + { + return string.Empty; + } + + var result = new StringBuilder(Math.Min(value.Length, maximumUtf8Bytes)); + var wasWhitespace = false; + foreach (var rune in value.EnumerateRunes()) + { + var category = Rune.GetUnicodeCategory(rune); + var isUnsafe = category is UnicodeCategory.Control + or UnicodeCategory.Format + or UnicodeCategory.LineSeparator + or UnicodeCategory.ParagraphSeparator + or UnicodeCategory.Surrogate; + var isLineWhitespace = rune.Value is '\r' or '\n' or '\t'; + if (isUnsafe && !isLineWhitespace) + { + result.Append('\uFFFD'); + wasWhitespace = false; + continue; + } + + var isWhitespace = isLineWhitespace || Rune.IsWhiteSpace(rune); + if (isWhitespace) + { + if (!wasWhitespace && result.Length > 0) + { + result.Append(' '); + } + + wasWhitespace = true; + continue; + } + + result.Append(rune); + wasWhitespace = false; + } + + return TruncateUtf8(result.ToString().Trim(), maximumUtf8Bytes); + } + + private static string TruncateUtf8(string value, int maximumUtf8Bytes) + { + if (Encoding.UTF8.GetByteCount(value) <= maximumUtf8Bytes) + { + return value; + } + + var result = new StringBuilder(value.Length); + var usedBytes = 0; + foreach (var rune in value.EnumerateRunes()) + { + if (usedBytes + rune.Utf8SequenceLength > maximumUtf8Bytes) + { + break; + } + + result.Append(rune); + usedBytes += rune.Utf8SequenceLength; + } + + return result.ToString().TrimEnd(); + } +} diff --git a/src/PortCVE/Remote/RemoteFingerprintParser.cs b/src/PortCVE/Remote/RemoteFingerprintParser.cs new file mode 100644 index 0000000..5978d5e --- /dev/null +++ b/src/PortCVE/Remote/RemoteFingerprintParser.cs @@ -0,0 +1,363 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace PortCVE.Remote; + +internal sealed record RemoteFingerprintAnalysis( + IReadOnlyList Fingerprints, + IReadOnlyList ProductCandidates); + +internal static class RemoteFingerprintParser +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); + private static readonly Regex SshBanner = CreateRegex( + @"^SSH-(?1\.99|2\.0)-(?[^\s]+)"); + private static readonly Regex HttpStatus = CreateRegex( + @"^HTTP/(?1\.[01])\s+(?\d{3})(?:\s+(?.*))?$"); + private static readonly Regex HeaderName = CreateRegex(@"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$"); + private static readonly IReadOnlySet NoProducts = new HashSet(StringComparer.Ordinal); + private static readonly IReadOnlySet SshProducts = ProductSet( + "OpenSSH", "Dropbear SSH", "libssh"); + private static readonly IReadOnlySet FtpProducts = ProductSet( + "ProFTPD", "vsftpd", "FileZilla Server"); + private static readonly IReadOnlySet SmtpProducts = ProductSet( + "Exim", "Sendmail"); + private static readonly IReadOnlySet MailboxProducts = ProductSet( + "Dovecot", "Courier"); + private static readonly IReadOnlySet HttpProducts = ProductSet( + "Apache HTTP Server", "nginx", "Microsoft IIS", "lighttpd", "OpenResty", + "LiteSpeed", "Jetty", "Caddy", "gunicorn", "uvicorn", "Werkzeug", "Kestrel", + "PHP", "ASP.NET"); + private static readonly ProductPattern[] ProductPatterns = [ + new("OpenSSH", CreateRegex( + @"\AOpenSSH_(?[0-9]+(?:\.[0-9]+)+p[0-9]+)\z")), + // These catalog-eligible patterns are anchored to the product's + // protocol field or canonical greeting. Do not loosen them into a + // contains-search: greeting text is remotely controlled and a nearby + // product name is not evidence that the named implementation spoke. + new("Dropbear SSH", CreateRegex(@"\Adropbear_(?[0-9]+(?:\.[0-9]+)+)\z")), + new("libssh", CreateRegex(@"\blibssh[_/ -](?[0-9][0-9A-Za-z._+~-]*)")), + new("ProFTPD", CreateRegex( + @"\A220 ProFTPD (?[0-9]+(?:\.[0-9]+)+[a-z]?) Server \(.+\) \[[^\[\]]+\]\z")), + new("vsftpd", CreateRegex( + @"\A220 \(vsFTPd (?[0-9]+(?:\.[0-9]+)+)\)\z")), + new("FileZilla Server", CreateRegex(@"\bFileZilla Server(?:\s+|/)(?[0-9][0-9A-Za-z._+~-]*)")), + new("Exim", CreateRegex( + @"\A220 [^\s]+ ESMTP Exim (?[0-9]+(?:\.[0-9]+)+)(?:\s+.+)?\z")), + new("Sendmail", CreateRegex(@"\bSendmail(?:\s+|/)(?[0-9][0-9A-Za-z._+~-]*)")), + new("Dovecot", CreateRegex(@"\bDovecot(?:\s+|/)(?[0-9][0-9A-Za-z._+~-]*)")), + new("Courier", CreateRegex(@"\bCourier(?:\s+|/)(?[0-9][0-9A-Za-z._+~-]*)")), + new("Apache HTTP Server", CreateRegex(@"\bApache(?:\s+HTTP(?:\s+Server)?)?/(?[0-9][0-9A-Za-z._+~-]*)")), + new("nginx", CreateRegex(@"\bnginx/(?[0-9][0-9A-Za-z._+~-]*)")), + new("Microsoft IIS", CreateRegex(@"\bMicrosoft-IIS/(?[0-9][0-9A-Za-z._+~-]*)")), + new("lighttpd", CreateRegex(@"\blighttpd/(?[0-9][0-9A-Za-z._+~-]*)")), + new("OpenResty", CreateRegex(@"\bopenresty/(?[0-9][0-9A-Za-z._+~-]*)")), + new("LiteSpeed", CreateRegex(@"\bLiteSpeed(?:\s+|/)(?[0-9][0-9A-Za-z._+~-]*)")), + new("Jetty", CreateRegex(@"\bJetty\((?[0-9][0-9A-Za-z._+~-]*)\)")), + new("Caddy", CreateRegex(@"\bCaddy(?:\s+|/)(?[0-9][0-9A-Za-z._+~-]*)")), + new("gunicorn", CreateRegex(@"\bgunicorn/(?[0-9][0-9A-Za-z._+~-]*)")), + new("uvicorn", CreateRegex(@"\buvicorn/(?[0-9][0-9A-Za-z._+~-]*)")), + new("Werkzeug", CreateRegex(@"\bWerkzeug/(?[0-9][0-9A-Za-z._+~-]*)")), + new("Kestrel", CreateRegex(@"\bKestrel/(?[0-9][0-9A-Za-z._+~-]*)")), + new("PHP", CreateRegex(@"\bPHP/(?[0-9][0-9A-Za-z._+~-]*)")), + new("ASP.NET", CreateRegex(@"\bASP\.NET(?:\s+|/)(?[0-9][0-9A-Za-z._+~-]*)")), + ]; + + public static RemoteFingerprintAnalysis AnalyzeGreeting( + string greeting, + int maximumEvidenceBytes, + bool isComplete = true) + { + var evidence = RemoteEvidenceSanitizer.Sanitize(greeting, maximumEvidenceBytes); + if (evidence.Length == 0) + { + return new([], []); + } + + if (!isComplete) + { + return new( + [CreateFingerprint( + RemoteFingerprintKind.Greeting, + "unknown", + RemoteFingerprintConfidence.Observed, + "passive-greeting", + evidence, + new Dictionary(StringComparer.Ordinal) + { + ["complete"] = "false", + })], + []); + } + + var fingerprints = new List(); + var candidates = new List(); + var ssh = SshBanner.Match(evidence); + var ftp = LooksLikeFtp(evidence); + var smtp = LooksLikeSmtp(evidence); + IReadOnlySet allowedProducts; + if (ssh.Success) + { + fingerprints.Add(CreateFingerprint( + RemoteFingerprintKind.Ssh, + "ssh", + RemoteFingerprintConfidence.ProtocolConfirmed, + "passive-greeting", + evidence, + new Dictionary(StringComparer.Ordinal) + { + ["protocolVersion"] = ssh.Groups["protocol"].Value, + ["software"] = ssh.Groups["software"].Value, + })); + allowedProducts = SshProducts; + } + else if (ftp && !smtp) + { + fingerprints.Add(CreateServiceFingerprint(RemoteFingerprintKind.Ftp, "ftp", evidence)); + allowedProducts = FtpProducts; + } + else if (smtp && !ftp) + { + fingerprints.Add(CreateServiceFingerprint(RemoteFingerprintKind.Smtp, "smtp", evidence)); + allowedProducts = SmtpProducts; + } + else if (LooksLikePop3(evidence)) + { + fingerprints.Add(CreateServiceFingerprint(RemoteFingerprintKind.Pop3, "pop3", evidence)); + allowedProducts = MailboxProducts; + } + else if (LooksLikeImap(evidence)) + { + fingerprints.Add(CreateServiceFingerprint(RemoteFingerprintKind.Imap, "imap", evidence)); + allowedProducts = MailboxProducts; + } + else + { + fingerprints.Add(CreateFingerprint( + RemoteFingerprintKind.Greeting, + "unknown", + RemoteFingerprintConfidence.Observed, + "passive-greeting", + evidence)); + allowedProducts = NoProducts; + } + + candidates.AddRange(ExtractProducts( + ssh.Success ? ssh.Groups["software"].Value : evidence, + "passive-greeting", + RemoteProductConfidence.BannerPattern, + allowedProducts)); + return new(fingerprints, DeduplicateCandidates(candidates)); + } + + public static RemoteFingerprintAnalysis AnalyzeHttpResponse( + string headerBlock, + RemoteFingerprintKind kind, + string source, + int maximumEvidenceBytes, + bool headersComplete = true) + { + var lines = headerBlock + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n'); + if (lines.Length == 0) + { + return new([], []); + } + + var statusLine = RemoteEvidenceSanitizer.Sanitize(lines[0], maximumEvidenceBytes); + var status = HttpStatus.Match(statusLine); + if (!status.Success) + { + return new([], []); + } + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var rawLine in lines.Skip(1).Take(100)) + { + if (rawLine.Length == 0) + { + break; + } + + var separator = rawLine.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + var name = rawLine[..separator].Trim(); + if (!HeaderName.IsMatch(name)) + { + continue; + } + + var value = RemoteEvidenceSanitizer.Sanitize(rawLine[(separator + 1)..], maximumEvidenceBytes); + if (value.Length > 0 && !headers.ContainsKey(name)) + { + headers.Add(name, value); + } + } + + var attributes = new Dictionary(StringComparer.Ordinal) + { + ["httpVersion"] = status.Groups["version"].Value, + ["statusCode"] = status.Groups["status"].Value, + ["headersComplete"] = headersComplete ? "true" : "false", + }; + CopyHeader(headers, attributes, "Server", "server"); + CopyHeader(headers, attributes, "X-Powered-By", "xPoweredBy"); + CopyHeader(headers, attributes, "Allow", "allow"); + CopyHeader(headers, attributes, "Location", "location"); + + var selectedEvidence = string.Join( + " | ", + new[] + { + statusLine, + HeaderEvidence(headers, "Server"), + HeaderEvidence(headers, "X-Powered-By"), + HeaderEvidence(headers, "Allow"), + HeaderEvidence(headers, "Location"), + }.Where(static item => !string.IsNullOrEmpty(item))); + selectedEvidence = RemoteEvidenceSanitizer.Sanitize(selectedEvidence, maximumEvidenceBytes); + + var candidates = new List(); + foreach (var headerName in new[] { "Server", "X-Powered-By" }) + { + if (headers.TryGetValue(headerName, out var value)) + { + candidates.AddRange(ExtractProducts( + value, + $"{source}:{headerName.ToLowerInvariant()}", + RemoteProductConfidence.HeaderReported, + HttpProducts)); + } + } + + return new( + [CreateFingerprint( + kind, + "http", + RemoteFingerprintConfidence.ProtocolConfirmed, + source, + selectedEvidence, + attributes)], + DeduplicateCandidates(candidates)); + } + + private static RemoteFingerprint CreateServiceFingerprint( + RemoteFingerprintKind kind, + string service, + string evidence) => + CreateFingerprint( + kind, + service, + RemoteFingerprintConfidence.StrongPattern, + "passive-greeting", + evidence); + + private static RemoteFingerprint CreateFingerprint( + RemoteFingerprintKind kind, + string service, + RemoteFingerprintConfidence confidence, + string source, + string evidence, + IDictionary? attributes = null) => + new( + kind, + service, + confidence, + source, + evidence, + RemoteFingerprint.ReadOnlyAttributes(attributes)); + + private static IEnumerable ExtractProducts( + string evidence, + string source, + RemoteProductConfidence confidence, + IReadOnlySet allowedProducts) + { + foreach (var pattern in ProductPatterns) + { + if (!allowedProducts.Contains(pattern.Product)) + { + continue; + } + + var match = pattern.Pattern.Match(evidence); + if (!match.Success) + { + continue; + } + + var version = match.Groups["version"].Success + ? match.Groups["version"].Value + : null; + yield return new( + pattern.Product, + version, + confidence, + source, + match.Value); + } + } + + private static IReadOnlyList DeduplicateCandidates( + IEnumerable candidates) => + candidates + .DistinctBy(static candidate => ( + candidate.Product.ToUpperInvariant(), + candidate.Version?.ToUpperInvariant(), + candidate.Source.ToUpperInvariant())) + .ToArray(); + + private static bool LooksLikeFtp(string evidence) => + evidence.StartsWith("220", StringComparison.Ordinal) + && ContainsAny(evidence, " FTP", "FTP server", "ProFTPD", "vsFTPd", "FileZilla Server"); + + private static bool LooksLikeSmtp(string evidence) => + evidence.StartsWith("220", StringComparison.Ordinal) + && ContainsAny(evidence, " ESMTP", " SMTP", "Postfix", "Exim", "Sendmail"); + + private static bool LooksLikePop3(string evidence) => + evidence.StartsWith("+OK", StringComparison.OrdinalIgnoreCase) + && ContainsAny(evidence, "POP3", "Dovecot", "Courier"); + + private static bool LooksLikeImap(string evidence) => + evidence.StartsWith("* OK", StringComparison.OrdinalIgnoreCase) + && ContainsAny(evidence, "IMAP", "Dovecot", "Courier"); + + private static bool ContainsAny(string value, params string[] patterns) => + patterns.Any(pattern => value.Contains(pattern, StringComparison.OrdinalIgnoreCase)); + + private static void CopyHeader( + IReadOnlyDictionary headers, + IDictionary attributes, + string headerName, + string attributeName) + { + if (headers.TryGetValue(headerName, out var value)) + { + attributes[attributeName] = value; + } + } + + private static string? HeaderEvidence( + IReadOnlyDictionary headers, + string headerName) => + headers.TryGetValue(headerName, out var value) + ? string.Create(CultureInfo.InvariantCulture, $"{headerName}: {value}") + : null; + + private static Regex CreateRegex(string pattern) => + new( + pattern, + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RegexTimeout); + + private static IReadOnlySet ProductSet(params string[] products) => + new HashSet(products, StringComparer.Ordinal); + + private sealed record ProductPattern(string Product, Regex Pattern); +} diff --git a/src/PortCVE/Remote/RemoteHostScanner.cs b/src/PortCVE/Remote/RemoteHostScanner.cs new file mode 100644 index 0000000..fad9322 --- /dev/null +++ b/src/PortCVE/Remote/RemoteHostScanner.cs @@ -0,0 +1,1289 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace PortCVE.Remote; + +internal sealed class RemoteHostScanner : IRemoteHostScanner +{ + private static readonly (string Method, string Path, RemoteFingerprintKind Kind, string Source)[] ActiveHttpProbes = [ + ("OPTIONS", "/", RemoteFingerprintKind.HttpOptions, "active-http-options"), + ("HEAD", "/robots.txt", RemoteFingerprintKind.HttpEndpoint, "active-http-head:robots.txt"), + ("HEAD", "/.well-known/security.txt", RemoteFingerprintKind.HttpEndpoint, "active-http-head:security.txt"), + ]; + + private static readonly (SslProtocols Protocol, string Label)[] ActiveTlsProtocols = [ + (SslProtocols.Tls12, "TLS 1.2"), + (SslProtocols.Tls13, "TLS 1.3"), + ]; + + private readonly IRemoteDnsResolver dnsResolver; + private readonly RemoteProbePolicy probePolicy; + private readonly Func rateLimiterFactory; + private readonly object rateLimiterSync = new(); + private IRemoteConnectionRateLimiter? sharedRateLimiter; + private int? sharedConnectionRate; + + public RemoteHostScanner() + : this( + new SystemRemoteDnsResolver(), + new RemoteProbePolicy(), + static maximumConnectionsPerSecond => + new MonotonicConnectionRateLimiter(maximumConnectionsPerSecond)) + { + } + + internal RemoteHostScanner( + IRemoteDnsResolver dnsResolver, + RemoteProbePolicy probePolicy, + Func? rateLimiterFactory = null) + { + this.dnsResolver = dnsResolver ?? throw new ArgumentNullException(nameof(dnsResolver)); + this.probePolicy = probePolicy ?? throw new ArgumentNullException(nameof(probePolicy)); + this.rateLimiterFactory = rateLimiterFactory + ?? (static maximumConnectionsPerSecond => + new MonotonicConnectionRateLimiter(maximumConnectionsPerSecond)); + } + + public async Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + cancellationToken.ThrowIfCancellationRequested(); + + IPAddress[] resolved; + try + { + resolved = await dnsResolver.ResolveAsync(options.Target, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (SocketException exception) + { + return EmptyReport( + options.Target, + new("dns_resolution_failed", SafeMessage(exception.Message))); + } + catch (ArgumentException exception) + { + return EmptyReport( + options.Target, + new("dns_resolution_failed", SafeMessage(exception.Message))); + } + + var addresses = resolved + .Where(static address => address.AddressFamily is + AddressFamily.InterNetwork or AddressFamily.InterNetworkV6) + .Distinct(IPAddressValueComparer.Instance) + .OrderBy(static address => address.AddressFamily == AddressFamily.InterNetwork ? 0 : 1) + .ThenBy(static address => Convert.ToHexString(address.GetAddressBytes()), StringComparer.Ordinal) + .ThenBy(static address => address.AddressFamily == AddressFamily.InterNetworkV6 + ? address.ScopeId + : 0) + .ToArray(); + if (addresses.Length == 0) + { + return EmptyReport( + options.Target, + new("dns_no_addresses", "The target did not resolve to an IPv4 or IPv6 address.")); + } + + // The resolver is never called after this point. Every connection uses one of these + // immutable numeric addresses, preventing mid-scan DNS changes from retargeting probes. + var resolvedAddressStrings = addresses + .Select(static address => address.ToString()) + .ToArray(); + var endpointCount = (long)addresses.Length * options.Ports.Count; + if (endpointCount > RemoteScanOptions.MaximumEndpointCount) + { + return new( + options.Target, + resolvedAddressStrings, + [], + [new( + "scan_endpoint_limit_exceeded", + $"The frozen address and port set contains {endpointCount.ToString(CultureInfo.InvariantCulture)} " + + $"endpoints; the safety limit is {RemoteScanOptions.MaximumEndpointCount.ToString(CultureInfo.InvariantCulture)}.")]); + } + + var endpoints = addresses + .SelectMany(address => options.Ports.Select(port => new IPEndPoint(address, port))) + .ToArray(); + var results = new RemotePortResult[endpoints.Length]; + var rateLimiter = GetSharedRateLimiter(options.MaxConnectionsPerSecond); + var parallelOptions = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = options.Concurrency, + }; + + await Parallel.ForEachAsync( + Enumerable.Range(0, endpoints.Length), + parallelOptions, + async (index, token) => + { + results[index] = await ScanEndpointAsync( + options, + endpoints[index], + rateLimiter, + token); + }); + + return new( + options.Target, + resolvedAddressStrings, + results, + []); + } + + private async Task ScanEndpointAsync( + RemoteScanOptions options, + IPEndPoint endpoint, + IRemoteConnectionRateLimiter rateLimiter, + CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var fingerprints = new List(); + var candidates = new List(); + var diagnostics = new List(); + var budget = new RemoteByteBudget(options.MaximumEvidenceBytes); + var connection = await ConnectAsync( + endpoint, + options.ConnectTimeout, + rateLimiter, + cancellationToken); + + if (connection.Client is null) + { + if (connection.Diagnostic is not null) + { + diagnostics.Add(connection.Diagnostic); + } + + return CreatePortResult( + endpoint, + connection.State, + stopwatch.ElapsedMilliseconds, + fingerprints, + candidates, + diagnostics); + } + + var runAdaptiveProtocolProbes = false; + using (connection.Client) + { + if (probePolicy.TlsPorts.Contains(endpoint.Port)) + { + var tlsProbe = await ProbeTlsAsync( + connection.Client, + options, + endpoint, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + if (tlsProbe.TlsConfirmed && options.ProbeDepth == ProbeDepth.Active) + { + await RunActiveTlsProbesAsync( + options, + endpoint, + tlsProbe.HttpConfirmed, + rateLimiter, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + } + else if (probePolicy.HttpPorts.Contains(endpoint.Port)) + { + var httpConfirmed = await ProbeHttpAsync( + connection.Client.GetStream(), + options, + endpoint, + "HEAD", + "/", + RemoteFingerprintKind.Http, + "passive-http-head", + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + if (httpConfirmed && options.ProbeDepth == ProbeDepth.Active) + { + await RunActiveHttpProbesAsync( + options, + endpoint, + useTls: false, + rateLimiter, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + } + else + { + var greeting = await ReadAndAnalyzeGreetingAsync( + connection.Client.GetStream(), + options, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + runAdaptiveProtocolProbes = options.ProbeDepth == ProbeDepth.Active + && !greeting.ReceivedBytes; + } + } + + // Unknown ports are deliberately passive-first. Sending an HTTP request or a TLS + // ClientHello to an arbitrary protocol can be surprising, so the adaptive fallback + // is available only in the explicitly authorized active profile. Each probe gets a + // fresh connection after the greeting socket is closed and therefore still passes + // through the shared connection-rate limiter and configured timeout controls. + if (runAdaptiveProtocolProbes) + { + await RunAdaptiveProtocolProbesAsync( + options, + endpoint, + rateLimiter, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + + return CreatePortResult( + endpoint, + RemotePortState.Open, + stopwatch.ElapsedMilliseconds, + fingerprints, + candidates, + diagnostics); + } + + private async Task ProbeTlsAsync( + TcpClient client, + RemoteScanOptions options, + IPEndPoint endpoint, + RemoteByteBudget budget, + ICollection fingerprints, + ICollection candidates, + ICollection diagnostics, + CancellationToken cancellationToken) + { + var handshake = await AuthenticateTlsAsync( + client, + options.Target, + options.ReadTimeout, + SslProtocols.None, + advertiseHttp11: probePolicy.HttpsPorts.Contains(endpoint.Port), + cancellationToken); + if (handshake.Stream is null) + { + diagnostics.Add(new( + handshake.TimedOut ? "tls_handshake_timeout" : "tls_handshake_failed", + handshake.Error ?? "The TLS handshake did not complete.")); + return new(false, false); + } + + var httpConfirmed = false; + using (handshake.Stream) + using (handshake.Certificate) + { + AddTlsFingerprint( + handshake, + budget, + fingerprints, + diagnostics); + if (probePolicy.HttpsPorts.Contains(endpoint.Port)) + { + httpConfirmed = await ProbeHttpAsync( + handshake.Stream, + options, + endpoint, + "HEAD", + "/", + RemoteFingerprintKind.Http, + "passive-https-head", + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + else + { + await ReadAndAnalyzeGreetingAsync( + handshake.Stream, + options, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + } + + return new(true, httpConfirmed); + } + + private async Task RunAdaptiveProtocolProbesAsync( + RemoteScanOptions options, + IPEndPoint endpoint, + IRemoteConnectionRateLimiter rateLimiter, + RemoteByteBudget budget, + ICollection fingerprints, + ICollection candidates, + ICollection diagnostics, + CancellationToken cancellationToken) + { + if (budget.Remaining == 0) + { + diagnostics.Add(new( + "evidence_budget_exhausted", + "The per-port evidence byte limit was reached before adaptive protocol probes ran.")); + return; + } + + var httpConnection = await ConnectAsync( + endpoint, + options.ConnectTimeout, + rateLimiter, + cancellationToken); + if (httpConnection.Client is null) + { + diagnostics.Add(new( + "adaptive_http_connect_failed", + httpConnection.Diagnostic?.Message ?? "The adaptive HTTP connection failed.")); + } + else + { + using (httpConnection.Client) + { + var httpConfirmed = await ProbeHttpAsync( + httpConnection.Client.GetStream(), + options, + endpoint, + "HEAD", + "/", + RemoteFingerprintKind.Http, + "active-adaptive-http-head", + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + if (httpConfirmed) + { + return; + } + } + } + + var tlsConnection = await ConnectAsync( + endpoint, + options.ConnectTimeout, + rateLimiter, + cancellationToken); + if (tlsConnection.Client is null) + { + diagnostics.Add(new( + "adaptive_tls_connect_failed", + tlsConnection.Diagnostic?.Message ?? "The adaptive TLS connection failed.")); + return; + } + + using (tlsConnection.Client) + { + var handshake = await AuthenticateTlsAsync( + tlsConnection.Client, + options.Target, + options.ReadTimeout, + SslProtocols.None, + advertiseHttp11: true, + cancellationToken); + using (handshake.Stream) + using (handshake.Certificate) + { + if (handshake.Stream is null) + { + diagnostics.Add(new( + handshake.TimedOut + ? "adaptive_tls_handshake_timeout" + : "adaptive_tls_handshake_failed", + handshake.Error ?? "The adaptive TLS handshake did not complete.")); + return; + } + + AddTlsFingerprint( + handshake, + budget, + fingerprints, + diagnostics, + "active-adaptive-tls-handshake"); + + // ALPN is strong protocol evidence. Reuse the authenticated stream for one + // safe HEAD only when the peer explicitly selected HTTP/1.1; a generic TLS + // service is never promoted to HTTPS from its port number or certificate. + if (handshake.Stream.NegotiatedApplicationProtocol == SslApplicationProtocol.Http11) + { + await ProbeHttpAsync( + handshake.Stream, + options, + endpoint, + "HEAD", + "/", + RemoteFingerprintKind.Http, + "active-adaptive-https-head", + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + } + } + } + + private async Task RunActiveTlsProbesAsync( + RemoteScanOptions options, + IPEndPoint endpoint, + bool runHttpProbes, + IRemoteConnectionRateLimiter rateLimiter, + RemoteByteBudget budget, + ICollection fingerprints, + ICollection candidates, + ICollection diagnostics, + CancellationToken cancellationToken) + { + foreach (var protocol in ActiveTlsProtocols) + { + cancellationToken.ThrowIfCancellationRequested(); + var connection = await ConnectAsync( + endpoint, + options.ConnectTimeout, + rateLimiter, + cancellationToken); + if (connection.Client is null) + { + diagnostics.Add(new( + "active_tls_connect_failed", + $"{protocol.Label} probe: {connection.Diagnostic?.Message ?? "connection failed"}")); + continue; + } + + using (connection.Client) + { + var handshake = await AuthenticateTlsAsync( + connection.Client, + options.Target, + options.ReadTimeout, + protocol.Protocol, + advertiseHttp11: false, + cancellationToken); + using (handshake.Stream) + using (handshake.Certificate) + { + var attributes = new Dictionary(StringComparer.Ordinal) + { + ["requestedProtocol"] = protocol.Label, + ["outcome"] = handshake.Stream is null ? "handshake_failed" : "accepted", + }; + string evidence; + RemoteFingerprintConfidence confidence; + if (handshake.Stream is not null) + { + attributes["negotiatedProtocol"] = handshake.Stream.SslProtocol.ToString(); + attributes["cipherSuite"] = handshake.Stream.NegotiatedCipherSuite.ToString(); + evidence = $"{protocol.Label} handshake completed; negotiated " + + $"{handshake.Stream.SslProtocol}; cipher {handshake.Stream.NegotiatedCipherSuite}."; + confidence = RemoteFingerprintConfidence.ProtocolConfirmed; + } + else + { + evidence = $"{protocol.Label} handshake did not complete; " + + "this does not prove that the server rejected the protocol. " + + (handshake.Error ?? string.Empty); + confidence = RemoteFingerprintConfidence.Observed; + } + + var fingerprint = CreateBudgetedFingerprint( + RemoteFingerprintKind.TlsProtocolProbe, + "tls-probe", + confidence, + $"active-tls:{protocol.Label.Replace(' ', '-').ToLowerInvariant()}", + evidence, + attributes, + budget, + out var evidenceTruncated); + if (fingerprint is not null) + { + fingerprints.Add(fingerprint); + if (evidenceTruncated) + { + diagnostics.Add(new( + "evidence_budget_truncated", + "TLS posture evidence was truncated at the per-port evidence byte limit.")); + } + } + else + { + diagnostics.Add(new( + "evidence_budget_exhausted", + "The per-port evidence byte limit was reached before TLS posture evidence could be retained.")); + } + } + } + } + + if (runHttpProbes && probePolicy.HttpsPorts.Contains(endpoint.Port)) + { + await RunActiveHttpProbesAsync( + options, + endpoint, + useTls: true, + rateLimiter, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + } + + private async Task RunActiveHttpProbesAsync( + RemoteScanOptions options, + IPEndPoint endpoint, + bool useTls, + IRemoteConnectionRateLimiter rateLimiter, + RemoteByteBudget budget, + ICollection fingerprints, + ICollection candidates, + ICollection diagnostics, + CancellationToken cancellationToken) + { + foreach (var probe in ActiveHttpProbes) + { + cancellationToken.ThrowIfCancellationRequested(); + if (budget.Remaining == 0) + { + diagnostics.Add(new( + "evidence_budget_exhausted", + "The per-port evidence byte limit was reached before every active HTTP probe ran.")); + return; + } + + var connection = await ConnectAsync( + endpoint, + options.ConnectTimeout, + rateLimiter, + cancellationToken); + if (connection.Client is null) + { + diagnostics.Add(new( + "active_http_connect_failed", + $"{probe.Method} {probe.Path}: {connection.Diagnostic?.Message ?? "connection failed"}")); + continue; + } + + using (connection.Client) + { + if (useTls) + { + var handshake = await AuthenticateTlsAsync( + connection.Client, + options.Target, + options.ReadTimeout, + SslProtocols.None, + advertiseHttp11: true, + cancellationToken); + using (handshake.Stream) + using (handshake.Certificate) + { + if (handshake.Stream is null) + { + diagnostics.Add(new( + "active_https_handshake_failed", + $"{probe.Method} {probe.Path}: " + + (handshake.Error ?? "TLS handshake failed"))); + continue; + } + + await ProbeHttpAsync( + handshake.Stream, + options, + endpoint, + probe.Method, + probe.Path, + probe.Kind, + probe.Source, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + } + else + { + await ProbeHttpAsync( + connection.Client.GetStream(), + options, + endpoint, + probe.Method, + probe.Path, + probe.Kind, + probe.Source, + budget, + fingerprints, + candidates, + diagnostics, + cancellationToken); + } + } + } + } + + private static async Task ProbeHttpAsync( + Stream stream, + RemoteScanOptions options, + IPEndPoint endpoint, + string method, + string path, + RemoteFingerprintKind kind, + string source, + RemoteByteBudget budget, + ICollection fingerprints, + ICollection candidates, + ICollection diagnostics, + CancellationToken cancellationToken) + { + if (budget.Remaining == 0) + { + diagnostics.Add(new( + "evidence_budget_exhausted", + "The per-port evidence byte limit was reached before the HTTP response was read.")); + return false; + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(options.ReadTimeout); + try + { + var request = BuildHttpRequest(method, path, options.Target, endpoint.Port); + await stream.WriteAsync(request, timeout.Token); + await stream.FlushAsync(timeout.Token); + var response = await ReadHeaderBlockAsync(stream, budget, timeout.Token); + var analysis = RemoteFingerprintParser.AnalyzeHttpResponse( + Encoding.UTF8.GetString(response.Bytes), + kind, + source, + options.MaximumEvidenceBytes, + response.Complete); + if (analysis.Fingerprints.Count == 0) + { + diagnostics.Add(new( + "http_response_unrecognized", + $"{method} {path} did not return a valid bounded HTTP/1.x status line.")); + return false; + } + + AddAnalysis(analysis, fingerprints, candidates); + if (!response.Complete) + { + diagnostics.Add(new( + response.LimitReached ? "http_header_limit_reached" : "http_headers_incomplete", + response.LimitReached + ? "The HTTP header block reached the remaining per-port evidence byte limit." + : "The connection ended before the HTTP header block terminator was observed.")); + } + + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + diagnostics.Add(new("http_probe_timeout", $"{method} {path} exceeded the read timeout.")); + return false; + } + catch (Exception exception) when (exception is IOException or AuthenticationException) + { + diagnostics.Add(new("http_probe_failed", SafeMessage(exception.Message))); + return false; + } + catch (SocketException exception) + { + diagnostics.Add(new("http_probe_failed", SafeMessage(exception.Message))); + return false; + } + } + + private static async Task ReadAndAnalyzeGreetingAsync( + Stream stream, + RemoteScanOptions options, + RemoteByteBudget budget, + ICollection fingerprints, + ICollection candidates, + ICollection diagnostics, + CancellationToken cancellationToken) + { + if (budget.Remaining == 0) + { + return new(false); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(options.ReadTimeout); + try + { + var greeting = await ReadGreetingAsync( + stream, + budget, + timeout.Token, + cancellationToken); + var analysis = RemoteFingerprintParser.AnalyzeGreeting( + RemoteEvidenceSanitizer.Sanitize(greeting.Bytes, options.MaximumEvidenceBytes), + options.MaximumEvidenceBytes, + greeting.Complete); + AddAnalysis(analysis, fingerprints, candidates); + if (greeting.Bytes.Length > 0 && !greeting.Complete) + { + diagnostics.Add(new( + greeting.LimitReached ? "greeting_limit_reached" : "greeting_incomplete", + greeting.LimitReached + ? "The greeting reached the remaining per-port evidence byte limit." + : "The connection ended before a complete greeting line was observed.")); + } + + return new(greeting.Bytes.Length > 0); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // An open service that does not speak first is a normal observation, not a failure. + return new(false); + } + catch (Exception exception) when (exception is IOException or AuthenticationException) + { + // A peer may close an otherwise successful TCP connection without a greeting. + return new(false); + } + catch (SocketException) + { + // A reset after connect does not change the observed open TCP state. + return new(false); + } + } + + private static async Task AuthenticateTlsAsync( + TcpClient client, + string target, + TimeSpan readTimeout, + SslProtocols protocols, + bool advertiseHttp11, + CancellationToken cancellationToken) + { + X509Certificate2? capturedCertificate = null; + var policyErrors = SslPolicyErrors.None; + var stream = new SslStream( + client.GetStream(), + leaveInnerStreamOpen: false, + (_, certificate, _, errors) => + { + capturedCertificate?.Dispose(); + capturedCertificate = certificate is null + ? null + : new X509Certificate2(certificate); + policyErrors = errors; + return true; + }); + var authenticationOptions = new SslClientAuthenticationOptions + { + TargetHost = target, + EnabledSslProtocols = protocols, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + CertificateChainPolicy = new X509ChainPolicy + { + DisableCertificateDownloads = true, + RevocationMode = X509RevocationMode.NoCheck, + }, + }; + if (advertiseHttp11) + { + authenticationOptions.ApplicationProtocols = [SslApplicationProtocol.Http11]; + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(readTimeout); + try + { + await stream.AuthenticateAsClientAsync(authenticationOptions, timeout.Token); + return new(stream, capturedCertificate, policyErrors, false, null); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + stream.Dispose(); + capturedCertificate?.Dispose(); + return new(null, null, policyErrors, true, "TLS authentication exceeded the read timeout."); + } + catch (OperationCanceledException) + { + stream.Dispose(); + capturedCertificate?.Dispose(); + throw; + } + catch (Exception exception) when (exception is + AuthenticationException or IOException or SocketException or PlatformNotSupportedException) + { + stream.Dispose(); + capturedCertificate?.Dispose(); + return new(null, null, policyErrors, false, SafeMessage(exception.Message)); + } + } + + private static void AddTlsFingerprint( + TlsHandshakeResult handshake, + RemoteByteBudget budget, + ICollection fingerprints, + ICollection diagnostics, + string source = "passive-tls-handshake") + { + var fingerprint = CreateTlsFingerprint(handshake, budget, source, out var evidenceTruncated); + if (fingerprint is not null) + { + fingerprints.Add(fingerprint); + if (evidenceTruncated) + { + diagnostics.Add(new( + "evidence_budget_truncated", + "TLS evidence was truncated at the per-port evidence byte limit.")); + } + + return; + } + + diagnostics.Add(new( + "evidence_budget_exhausted", + "The per-port evidence byte limit was reached before TLS evidence could be retained.")); + } + + private static RemoteFingerprint? CreateTlsFingerprint( + TlsHandshakeResult handshake, + RemoteByteBudget budget, + string source, + out bool evidenceTruncated) + { + var stream = handshake.Stream + ?? throw new ArgumentException("A completed TLS stream is required.", nameof(handshake)); + var attributes = new Dictionary(StringComparer.Ordinal) + { + ["protocol"] = stream.SslProtocol.ToString(), + ["cipherSuite"] = stream.NegotiatedCipherSuite.ToString(), + ["certificatePolicyErrors"] = handshake.PolicyErrors.ToString(), + }; + if (!stream.NegotiatedApplicationProtocol.Protocol.IsEmpty) + { + attributes["applicationProtocol"] = Encoding.ASCII.GetString( + stream.NegotiatedApplicationProtocol.Protocol.Span); + } + var certificate = handshake.Certificate; + if (certificate is not null) + { + attributes["certificateSubject"] = certificate.Subject; + attributes["certificateIssuer"] = certificate.Issuer; + attributes["certificateSha256"] = certificate.GetCertHashString(HashAlgorithmName.SHA256); + attributes["certificateNotBeforeUtc"] = certificate.NotBefore + .ToUniversalTime() + .ToString("O", CultureInfo.InvariantCulture); + attributes["certificateNotAfterUtc"] = certificate.NotAfter + .ToUniversalTime() + .ToString("O", CultureInfo.InvariantCulture); + var dnsName = certificate.GetNameInfo(X509NameType.DnsName, forIssuer: false); + if (!string.IsNullOrWhiteSpace(dnsName)) + { + attributes["certificateDnsName"] = dnsName; + } + } + + var evidence = $"Negotiated {stream.SslProtocol}; cipher {stream.NegotiatedCipherSuite}; " + + $"certificate policy errors {handshake.PolicyErrors}."; + if (certificate is not null) + { + evidence += $" Certificate SHA-256 {certificate.GetCertHashString(HashAlgorithmName.SHA256)}; " + + $"valid {certificate.NotBefore.ToUniversalTime():O} " + + $"through {certificate.NotAfter.ToUniversalTime():O}."; + } + + return CreateBudgetedFingerprint( + RemoteFingerprintKind.Tls, + "tls", + RemoteFingerprintConfidence.ProtocolConfirmed, + source, + evidence, + attributes, + budget, + out evidenceTruncated); + } + + private static RemoteFingerprint? CreateBudgetedFingerprint( + RemoteFingerprintKind kind, + string service, + RemoteFingerprintConfidence confidence, + string source, + string evidence, + IReadOnlyDictionary attributes, + RemoteByteBudget budget, + out bool evidenceTruncated) + { + evidenceTruncated = false; + if (budget.Remaining == 0) + { + evidenceTruncated = true; + return null; + } + + var boundedAttributes = new Dictionary(StringComparer.Ordinal); + foreach (var attribute in attributes) + { + if (budget.Remaining == 0) + { + evidenceTruncated = true; + break; + } + + var value = ConsumeSanitizedText( + attribute.Value, + budget, + out var attributeTruncated); + evidenceTruncated |= attributeTruncated; + if (value.Length > 0) + { + boundedAttributes[attribute.Key] = value; + } + } + + var boundedEvidence = ConsumeSanitizedText( + evidence, + budget, + out var bodyTruncated); + evidenceTruncated |= bodyTruncated; + if (boundedAttributes.Count == 0 && boundedEvidence.Length == 0) + { + return null; + } + + return new( + kind, + service, + confidence, + source, + boundedEvidence, + RemoteFingerprint.ReadOnlyAttributes(boundedAttributes)); + } + + private static string ConsumeSanitizedText( + string? value, + RemoteByteBudget budget, + out bool truncated) + { + var fullValue = RemoteEvidenceSanitizer.Sanitize(value, int.MaxValue); + var sanitized = RemoteEvidenceSanitizer.Sanitize(fullValue, budget.Remaining); + truncated = Encoding.UTF8.GetByteCount(sanitized) < Encoding.UTF8.GetByteCount(fullValue); + budget.Consume(Encoding.UTF8.GetByteCount(sanitized)); + return sanitized; + } + + private static async Task ConnectAsync( + IPEndPoint endpoint, + TimeSpan connectTimeout, + IRemoteConnectionRateLimiter rateLimiter, + CancellationToken cancellationToken) + { + await rateLimiter.WaitAsync(cancellationToken); + var client = new TcpClient(endpoint.AddressFamily) + { + NoDelay = true, + }; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(connectTimeout); + try + { + await client.ConnectAsync(endpoint.Address, endpoint.Port, timeout.Token); + return new(client, RemotePortState.Open, null); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + client.Dispose(); + return new( + null, + RemotePortState.TimedOut, + new("connect_timeout", "The TCP connection attempt exceeded the configured timeout.")); + } + catch (SocketException exception) + { + client.Dispose(); + var state = exception.SocketErrorCode switch + { + SocketError.ConnectionRefused => RemotePortState.Closed, + SocketError.TimedOut => RemotePortState.TimedOut, + SocketError.HostUnreachable or + SocketError.NetworkUnreachable or + SocketError.AddressNotAvailable or + SocketError.HostNotFound => RemotePortState.Unreachable, + _ => RemotePortState.Error, + }; + return new( + null, + state, + new($"connect_{exception.SocketErrorCode.ToString().ToLowerInvariant()}", SafeMessage(exception.Message))); + } + catch + { + client.Dispose(); + throw; + } + } + + private static byte[] BuildHttpRequest(string method, string path, string target, int port) + { + var defaultPort = port is 80 or 443; + var host = IPAddress.TryParse(target, out var address) + ? address.AddressFamily == AddressFamily.InterNetworkV6 + ? $"[{address}]" + : address.ToString() + : target; + if (!defaultPort) + { + host = string.Create(CultureInfo.InvariantCulture, $"{host}:{port}"); + } + + var request = string.Create( + CultureInfo.InvariantCulture, + $"{method} {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: PortCVE-Remote/1\r\nAccept: */*\r\nConnection: close\r\n\r\n"); + return Encoding.ASCII.GetBytes(request); + } + + private static async Task ReadHeaderBlockAsync( + Stream stream, + RemoteByteBudget budget, + CancellationToken cancellationToken) + { + using var output = new MemoryStream(Math.Min(budget.Remaining, 4_096)); + while (budget.Remaining > 0) + { + var buffer = new byte[Math.Min(1_024, budget.Remaining)]; + var read = await stream.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + break; + } + + budget.Consume(read); + output.Write(buffer, 0, read); + var data = output.GetBuffer().AsSpan(0, checked((int)output.Length)); + var delimiter = FindHeaderDelimiter(data); + if (delimiter > 0) + { + return new(data[..delimiter].ToArray(), true, false); + } + } + + return new(output.ToArray(), false, budget.Remaining == 0); + } + + private static async Task ReadGreetingAsync( + Stream stream, + RemoteByteBudget budget, + CancellationToken readCancellationToken, + CancellationToken operationCancellationToken) + { + using var output = new MemoryStream(Math.Min(budget.Remaining, 1_024)); + while (budget.Remaining > 0) + { + var buffer = new byte[Math.Min(512, budget.Remaining)]; + int read; + try + { + read = await stream.ReadAsync(buffer, readCancellationToken); + } + catch (OperationCanceledException) when ( + output.Length > 0 + && !operationCancellationToken.IsCancellationRequested) + { + // A read timeout after partial data is still positive evidence that + // this service was not silent. Preserve it so adaptive cross-protocol + // probes stay suppressed. A caller cancellation still propagates. + break; + } + catch (Exception exception) when ( + output.Length > 0 + && exception is IOException or SocketException or AuthenticationException) + { + // Preserve bytes received before a peer reset/close. They are positive + // evidence that the service was not silent and must suppress adaptive + // cross-protocol probes, even when the greeting is incomplete. + break; + } + + if (read == 0) + { + break; + } + + budget.Consume(read); + var lineFeed = Array.IndexOf(buffer, (byte)'\n', 0, read); + var captured = lineFeed >= 0 ? lineFeed + 1 : read; + output.Write(buffer, 0, captured); + if (lineFeed >= 0) + { + break; + } + } + + var complete = output.Length > 0 && output.GetBuffer()[output.Length - 1] == '\n'; + return new(output.ToArray(), complete, !complete && budget.Remaining == 0); + } + + private static int FindHeaderDelimiter(ReadOnlySpan bytes) + { + for (var index = 0; index < bytes.Length - 1; index++) + { + if (bytes[index] == '\n' && bytes[index + 1] == '\n') + { + return index + 2; + } + + if (index < bytes.Length - 3 + && bytes[index] == '\r' + && bytes[index + 1] == '\n' + && bytes[index + 2] == '\r' + && bytes[index + 3] == '\n') + { + return index + 4; + } + } + + return -1; + } + + private static void AddAnalysis( + RemoteFingerprintAnalysis analysis, + ICollection fingerprints, + ICollection candidates) + { + foreach (var fingerprint in analysis.Fingerprints) + { + fingerprints.Add(fingerprint); + } + + foreach (var candidate in analysis.ProductCandidates) + { + candidates.Add(candidate); + } + } + + private static RemotePortResult CreatePortResult( + IPEndPoint endpoint, + RemotePortState state, + long durationMs, + IEnumerable fingerprints, + IEnumerable candidates, + IEnumerable diagnostics) => + new( + endpoint.Address.ToString(), + endpoint.AddressFamily == AddressFamily.InterNetwork ? "ipv4" : "ipv6", + endpoint.Port, + state, + durationMs, + fingerprints.ToArray(), + candidates + .DistinctBy(static candidate => ( + candidate.Product.ToUpperInvariant(), + candidate.Version?.ToUpperInvariant(), + candidate.Source.ToUpperInvariant())) + .ToArray(), + diagnostics.ToArray()); + + private static RemoteHostReport EmptyReport(string target, RemoteDiagnostic diagnostic) => + new(target, [], [], [diagnostic]); + + private IRemoteConnectionRateLimiter GetSharedRateLimiter(int maximumConnectionsPerSecond) + { + lock (rateLimiterSync) + { + if (sharedRateLimiter is null) + { + sharedRateLimiter = rateLimiterFactory(maximumConnectionsPerSecond) + ?? throw new InvalidOperationException("The connection rate-limiter factory returned null."); + sharedConnectionRate = maximumConnectionsPerSecond; + } + else if (sharedConnectionRate != maximumConnectionsPerSecond) + { + throw new InvalidOperationException( + "One RemoteHostScanner run must use a consistent MaxConnectionsPerSecond value."); + } + + return sharedRateLimiter; + } + } + + private static string SafeMessage(string? message) => + RemoteEvidenceSanitizer.Sanitize(message ?? "Operation failed.", 512); + + private sealed record ConnectionResult( + TcpClient? Client, + RemotePortState State, + RemoteDiagnostic? Diagnostic); + + private sealed record TlsHandshakeResult( + SslStream? Stream, + X509Certificate2? Certificate, + SslPolicyErrors PolicyErrors, + bool TimedOut, + string? Error); + + private sealed record InitialTlsProbeResult(bool TlsConfirmed, bool HttpConfirmed); + + private sealed record GreetingProbeResult(bool ReceivedBytes); + + private sealed record BoundedReadResult(byte[] Bytes, bool Complete, bool LimitReached); + + private sealed class RemoteByteBudget(int maximumBytes) + { + public int Remaining { get; private set; } = maximumBytes; + + public void Consume(int bytes) + { + if (bytes < 0 || bytes > Remaining) + { + throw new ArgumentOutOfRangeException(nameof(bytes)); + } + + Remaining -= bytes; + } + } + + private sealed class IPAddressValueComparer : IEqualityComparer + { + public static IPAddressValueComparer Instance { get; } = new(); + + public bool Equals(IPAddress? left, IPAddress? right) => + left is not null && right is not null && left.Equals(right); + + public int GetHashCode(IPAddress address) => address.GetHashCode(); + } +} diff --git a/src/PortCVE/Remote/RemoteInputParser.cs b/src/PortCVE/Remote/RemoteInputParser.cs new file mode 100644 index 0000000..ebb1734 --- /dev/null +++ b/src/PortCVE/Remote/RemoteInputParser.cs @@ -0,0 +1,170 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace PortCVE.Remote; + +internal sealed class RemoteInputException(string message) : Exception(message); + +internal sealed record RemoteTargetPlan( + string Selector, + IReadOnlyList Targets, + bool IsRange); + +internal static class RemoteInputParser +{ + public const int DefaultMaximumHosts = 256; + public const int AbsoluteMaximumHosts = 65_536; + + private static readonly int[] CommonTcpPorts = + [ + 21, 22, 23, 25, 53, 80, 110, 111, 135, 139, 143, 389, 443, 445, 465, 587, + 636, 993, 995, 1433, 1521, 2049, 2375, 2376, 3000, 3306, 3389, 5000, 5432, + 5601, 5672, 5900, 5985, 5986, 6379, 6443, 8000, 8008, 8080, 8081, 8443, + 8888, 9000, 9090, 9200, 9300, 11211, 27017, + ]; + + public static IReadOnlyList ParsePorts(string? specification) + { + if (specification is null + || specification.Equals("common", StringComparison.OrdinalIgnoreCase)) + { + return CommonTcpPorts; + } + + if (string.IsNullOrWhiteSpace(specification)) + { + throw new RemoteInputException("--ports must select at least one TCP port."); + } + + if (specification.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + return Enumerable.Range(1, 65535).ToArray(); + } + + var ports = new SortedSet(); + foreach (var rawToken in specification.Split(',', StringSplitOptions.TrimEntries)) + { + if (rawToken.Length == 0) + { + throw new RemoteInputException("--ports contains an empty item."); + } + + var rangeParts = rawToken.Split('-', 2, StringSplitOptions.TrimEntries); + var first = ParsePort(rangeParts[0]); + var last = rangeParts.Length == 1 ? first : ParsePort(rangeParts[1]); + if (last < first) + { + throw new RemoteInputException($"Port range '{rawToken}' is descending."); + } + + for (var port = first; port <= last; port++) + { + ports.Add(port); + } + } + + if (ports.Count == 0) + { + throw new RemoteInputException("--ports must select at least one TCP port."); + } + + return ports.ToArray(); + } + + public static RemoteTargetPlan ParseTargets( + string selector, + int maximumHosts = DefaultMaximumHosts) + { + if (string.IsNullOrWhiteSpace(selector)) + { + throw new RemoteInputException("scan-host requires an IP address, hostname, or IPv4 CIDR."); + } + + if (maximumHosts is < 1 or > AbsoluteMaximumHosts) + { + throw new RemoteInputException( + $"--max-hosts must be from 1 to {AbsoluteMaximumHosts.ToString(CultureInfo.InvariantCulture)}."); + } + + var trimmed = selector.Trim(); + if (trimmed.Contains("//", StringComparison.Ordinal) + || trimmed.Contains('\\') + || trimmed.Contains('?') + || trimmed.Contains('#')) + { + throw new RemoteInputException("Target must be a host or CIDR, not a URL or path."); + } + + if (!trimmed.Contains('/')) + { + if (IPAddress.TryParse(trimmed, out var literal)) + { + return new(trimmed, [literal.ToString()], false); + } + + if (Uri.CheckHostName(trimmed) is not UriHostNameType.Dns) + { + throw new RemoteInputException($"Target '{trimmed}' is not a valid IP address or DNS hostname."); + } + + return new(trimmed, [trimmed], false); + } + + var slash = trimmed.LastIndexOf('/'); + var addressText = trimmed[..slash]; + var prefixText = trimmed[(slash + 1)..]; + if (!IPAddress.TryParse(addressText, out var address) + || address.AddressFamily != AddressFamily.InterNetwork) + { + throw new RemoteInputException("CIDR scanning currently supports IPv4 ranges only."); + } + + if (!int.TryParse(prefixText, NumberStyles.None, CultureInfo.InvariantCulture, out var prefix) + || prefix is < 0 or > 32) + { + throw new RemoteInputException($"CIDR prefix '{prefixText}' must be from 0 to 32."); + } + + var hostCount = 1L << (32 - prefix); + if (hostCount > maximumHosts) + { + throw new RemoteInputException( + $"CIDR selects {hostCount.ToString(CultureInfo.InvariantCulture)} addresses; " + + $"the current --max-hosts limit is {maximumHosts.ToString(CultureInfo.InvariantCulture)}."); + } + + var bytes = address.GetAddressBytes(); + var raw = ((uint)bytes[0] << 24) + | ((uint)bytes[1] << 16) + | ((uint)bytes[2] << 8) + | bytes[3]; + var mask = prefix == 0 ? 0u : uint.MaxValue << (32 - prefix); + var network = raw & mask; + var targets = new string[hostCount]; + for (long index = 0; index < hostCount; index++) + { + var current = network + (uint)index; + targets[index] = new IPAddress( + [ + (byte)(current >> 24), + (byte)(current >> 16), + (byte)(current >> 8), + (byte)current, + ]).ToString(); + } + + return new(trimmed, targets, true); + } + + private static int ParsePort(string value) + { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var port) + || port is < 1 or > 65535) + { + throw new RemoteInputException($"Port '{value}' must be an integer from 1 to 65535."); + } + + return port; + } +} diff --git a/src/PortCVE/Remote/RemoteModels.cs b/src/PortCVE/Remote/RemoteModels.cs new file mode 100644 index 0000000..3a87ae1 --- /dev/null +++ b/src/PortCVE/Remote/RemoteModels.cs @@ -0,0 +1,244 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Net; + +namespace PortCVE.Remote; + +internal enum ProbeDepth +{ + Passive, + Active, +} + +internal enum RemotePortState +{ + Open, + Closed, + TimedOut, + Unreachable, + Error, +} + +internal enum RemoteFingerprintKind +{ + Greeting, + Ssh, + Ftp, + Smtp, + Pop3, + Imap, + Http, + Tls, + HttpOptions, + HttpEndpoint, + TlsProtocolProbe, +} + +internal enum RemoteFingerprintConfidence +{ + Observed, + StrongPattern, + ProtocolConfirmed, +} + +internal enum RemoteProductConfidence +{ + BannerPattern, + HeaderReported, +} + +internal sealed class RemoteScanOptions +{ + internal const int MaximumPortCount = 65_535; + internal const int MaximumConcurrency = 512; + internal const int MaximumConnectionsPerSecondLimit = 10_000; + internal const int MaximumEndpointCount = 262_144; + internal const int MinimumEvidenceBytes = 256; + internal const int MaximumEvidenceBytesLimit = 65_536; + internal static readonly TimeSpan MaximumTimeout = TimeSpan.FromMinutes(5); + + public RemoteScanOptions( + string target, + IReadOnlyList ports, + TimeSpan connectTimeout, + TimeSpan readTimeout, + int concurrency, + ProbeDepth probeDepth = ProbeDepth.Passive, + int maximumEvidenceBytes = 8_192, + int maxConnectionsPerSecond = 100) + { + Target = NormalizeTarget(target); + Ports = ValidatePorts(ports); + ConnectTimeout = ValidateTimeout(connectTimeout, nameof(connectTimeout)); + ReadTimeout = ValidateTimeout(readTimeout, nameof(readTimeout)); + + if (concurrency is < 1 or > MaximumConcurrency) + { + throw new ArgumentOutOfRangeException( + nameof(concurrency), + string.Create( + CultureInfo.InvariantCulture, + $"Concurrency must be between 1 and {MaximumConcurrency}.")); + } + + if (!Enum.IsDefined(probeDepth)) + { + throw new ArgumentOutOfRangeException(nameof(probeDepth)); + } + + if (maximumEvidenceBytes is < MinimumEvidenceBytes or > MaximumEvidenceBytesLimit) + { + throw new ArgumentOutOfRangeException( + nameof(maximumEvidenceBytes), + string.Create( + CultureInfo.InvariantCulture, + $"Evidence bytes must be between {MinimumEvidenceBytes} and {MaximumEvidenceBytesLimit}.")); + } + + if (maxConnectionsPerSecond is < 1 or > MaximumConnectionsPerSecondLimit) + { + throw new ArgumentOutOfRangeException( + nameof(maxConnectionsPerSecond), + string.Create( + CultureInfo.InvariantCulture, + $"Connection rate must be between 1 and {MaximumConnectionsPerSecondLimit} per second.")); + } + + Concurrency = concurrency; + ProbeDepth = probeDepth; + MaximumEvidenceBytes = maximumEvidenceBytes; + MaxConnectionsPerSecond = maxConnectionsPerSecond; + } + + public string Target { get; } + + public IReadOnlyList Ports { get; } + + public TimeSpan ConnectTimeout { get; } + + public TimeSpan ReadTimeout { get; } + + public int Concurrency { get; } + + public ProbeDepth ProbeDepth { get; } + + public int MaximumEvidenceBytes { get; } + + public int MaxConnectionsPerSecond { get; } + + private static string NormalizeTarget(string target) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target); + var candidate = target.Trim(); + if (candidate.Length >= 2 + && candidate[0] == '[' + && candidate[^1] == ']' + && IPAddress.TryParse(candidate[1..^1], out var bracketedAddress)) + { + return bracketedAddress.ToString(); + } + + if (IPAddress.TryParse(candidate, out var address)) + { + return address.ToString(); + } + + if (candidate.Length > 253 + || Uri.CheckHostName(candidate) != UriHostNameType.Dns) + { + throw new ArgumentException("Target must be one DNS hostname or IP address.", nameof(target)); + } + + string ascii; + try + { + ascii = new IdnMapping().GetAscii(candidate.TrimEnd('.')); + } + catch (ArgumentException exception) + { + throw new ArgumentException("Target contains an invalid DNS hostname.", nameof(target), exception); + } + + if (ascii.Length is 0 or > 253 || ascii.Contains("..", StringComparison.Ordinal)) + { + throw new ArgumentException("Target contains an invalid DNS hostname.", nameof(target)); + } + + return ascii.ToLowerInvariant(); + } + + private static IReadOnlyList ValidatePorts(IReadOnlyList ports) + { + ArgumentNullException.ThrowIfNull(ports); + if (ports.Count is 0 or > MaximumPortCount) + { + throw new ArgumentOutOfRangeException( + nameof(ports), + $"At least one and at most {MaximumPortCount} explicit ports are required."); + } + + var result = ports + .Distinct() + .Order() + .ToArray(); + if (result.Any(static port => port is < 1 or > IPEndPoint.MaxPort)) + { + throw new ArgumentOutOfRangeException(nameof(ports), "Ports must be between 1 and 65535."); + } + + return Array.AsReadOnly(result); + } + + private static TimeSpan ValidateTimeout(TimeSpan timeout, string parameterName) + { + if (timeout <= TimeSpan.Zero || timeout > MaximumTimeout) + { + throw new ArgumentOutOfRangeException( + parameterName, + $"Timeout must be greater than zero and no more than {MaximumTimeout}."); + } + + return timeout; + } +} + +internal sealed record RemoteDiagnostic(string Code, string Message); + +internal sealed record RemoteFingerprint( + RemoteFingerprintKind Kind, + string Service, + RemoteFingerprintConfidence Confidence, + string Source, + string Evidence, + IReadOnlyDictionary Attributes) +{ + public static IReadOnlyDictionary ReadOnlyAttributes( + IDictionary? attributes = null) => + new ReadOnlyDictionary( + attributes is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(attributes, StringComparer.Ordinal)); +} + +internal sealed record RemoteProductCandidate( + string Product, + string? Version, + RemoteProductConfidence Confidence, + string Source, + string Evidence); + +internal sealed record RemotePortResult( + string Address, + string AddressFamily, + int Port, + RemotePortState State, + long DurationMs, + IReadOnlyList Fingerprints, + IReadOnlyList ProductCandidates, + IReadOnlyList Diagnostics); + +internal sealed record RemoteHostReport( + string Target, + IReadOnlyList ResolvedAddresses, + IReadOnlyList Ports, + IReadOnlyList Diagnostics); diff --git a/src/PortCVE/Remote/RemoteProbePolicy.cs b/src/PortCVE/Remote/RemoteProbePolicy.cs new file mode 100644 index 0000000..33272c0 --- /dev/null +++ b/src/PortCVE/Remote/RemoteProbePolicy.cs @@ -0,0 +1,47 @@ +namespace PortCVE.Remote; + +internal sealed class RemoteProbePolicy +{ + private static readonly int[] DefaultHttpPorts = [ + 80, 81, 3000, 5000, 8000, 8008, 8080, 8081, 8888, + ]; + + private static readonly int[] DefaultTlsPorts = [ + 443, 465, 636, 853, 989, 990, 992, 993, 994, 995, 8443, 9443, + ]; + + private static readonly int[] DefaultHttpsPorts = [443, 8443, 9443]; + + public RemoteProbePolicy( + IEnumerable? httpPorts = null, + IEnumerable? tlsPorts = null, + IEnumerable? httpsPorts = null) + { + HttpPorts = ValidatePortSet(httpPorts ?? DefaultHttpPorts, nameof(httpPorts)); + TlsPorts = ValidatePortSet(tlsPorts ?? DefaultTlsPorts, nameof(tlsPorts)); + HttpsPorts = ValidatePortSet(httpsPorts ?? DefaultHttpsPorts, nameof(httpsPorts)); + + if (HttpsPorts.Any(port => !TlsPorts.Contains(port))) + { + throw new ArgumentException("Every HTTPS port must also be configured as a TLS port.", nameof(httpsPorts)); + } + } + + public IReadOnlySet HttpPorts { get; } + + public IReadOnlySet TlsPorts { get; } + + public IReadOnlySet HttpsPorts { get; } + + private static IReadOnlySet ValidatePortSet(IEnumerable ports, string parameterName) + { + ArgumentNullException.ThrowIfNull(ports); + var result = ports.ToHashSet(); + if (result.Any(static port => port is < 1 or > 65_535)) + { + throw new ArgumentOutOfRangeException(parameterName, "Probe-policy ports must be between 1 and 65535."); + } + + return result; + } +} diff --git a/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs b/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs index e963909..47b1e6d 100644 --- a/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs +++ b/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs @@ -40,17 +40,46 @@ public static LocalPathValidation ValidateLocalDirectoryPath(string path) public static LocalPathValidation ValidateExistingLocalFile(string path) { - return ValidateLocalFile(path, requireExists: true); + return ValidateLocalFile(path, requireExists: true, "sbom_path", "SBOM"); } public static LocalPathValidation ValidateOptionalLocalFile(string path) { - return ValidateLocalFile(path, requireExists: false); + return ValidateLocalFile(path, requireExists: false, "sbom_path", "SBOM"); } - private static LocalPathValidation ValidateLocalFile(string path, bool requireExists) + public static LocalPathValidation ValidateExistingTrivyExecutable(string path) { - var resolved = Resolve(path, "sbom_path"); + return ValidateLocalFile(path, requireExists: true, "trivy_executable", "Trivy executable"); + } + + public static LocalPathValidation ValidateExistingTrivyDatabaseFile(string path) + { + return ValidateLocalFile(path, requireExists: true, "trivy_database", "Trivy database"); + } + + public static LocalPathValidation ValidateExistingImportFile(string path) + { + return ValidateLocalFile(path, requireExists: true, "import_path", "Import input"); + } + + public static LocalPathValidation ValidateOptionalImportOutputFile(string path) + { + return ValidateLocalFile(path, requireExists: false, "import_output_path", "Import output"); + } + + public static LocalPathValidation ValidateOptionalRemoteOutputFile(string path) + { + return ValidateLocalFile(path, requireExists: false, "remote_output_path", "Remote report output"); + } + + private static LocalPathValidation ValidateLocalFile( + string path, + bool requireExists, + string codePrefix, + string displayName) + { + var resolved = Resolve(path, codePrefix); if (!resolved.IsValid) { return resolved; @@ -58,17 +87,17 @@ private static LocalPathValidation ValidateLocalFile(string path, bool requireEx var fullPath = resolved.FullPath!; var root = Path.GetPathRoot(fullPath)!; - var inspection = InspectComponents(fullPath, root, allowMissingTail: !requireExists, "sbom_path"); + var inspection = InspectComponents(fullPath, root, allowMissingTail: !requireExists, codePrefix); if (!inspection.IsValid) { return inspection.IsMissing && requireExists - ? Invalid("sbom_not_found", $"SBOM file not found: '{fullPath}'.") + ? Invalid($"{codePrefix}_not_found", $"{displayName} file not found: '{fullPath}'.") : Invalid(inspection.Code!, inspection.Message!); } if (inspection.FinalExists && IsDirectory(inspection.FinalAttributes)) { - return Invalid("sbom_path_invalid", "The SBOM path must name a regular file, not a directory."); + return Invalid($"{codePrefix}_invalid", $"The {displayName.ToLowerInvariant()} path must name a regular file, not a directory."); } return new(true, fullPath, "ok", "The path is a local regular file."); diff --git a/src/PortCVE/Vulnerabilities/TrivyDatabaseDocument.cs b/src/PortCVE/Vulnerabilities/TrivyDatabaseDocument.cs new file mode 100644 index 0000000..b265f04 --- /dev/null +++ b/src/PortCVE/Vulnerabilities/TrivyDatabaseDocument.cs @@ -0,0 +1,85 @@ +namespace PortCVE.Vulnerabilities; + +internal sealed record TrivyDatabaseDocument( + int SchemaVersion, + string ToolVersion, + string Provider, + TrivyDatabaseOperation Operation, + TrivyDatabaseState State, + bool Ready, + bool NetworkRequested, + string PrivacyMode, + string ExecutablePath, + string? EngineVersion, + string CacheDirectory, + int? DatabaseSchemaVersion, + DateTimeOffset? DatabaseUpdatedAt, + DateTimeOffset? DatabaseNextUpdate, + long? DatabaseAgeSeconds, + long MaximumDatabaseAgeSeconds, + long DurationMs, + string Code, + string Message) +{ + internal const int CurrentSchemaVersion = 1; + internal const string PrivateMode = "private"; + internal const string ReducedMode = "reduced"; + internal const string UnresolvedPath = "unresolved"; + + public static TrivyDatabaseDocument FromStatus( + TrivyDatabaseStatus status, + string toolVersion) => new( + CurrentSchemaVersion, + toolVersion, + status.Provider, + status.Operation, + status.State, + status.Ready, + status.NetworkRequested, + PrivateMode, + status.ExecutablePath ?? UnresolvedPath, + status.EngineVersion, + status.CacheDirectory ?? UnresolvedPath, + status.DatabaseSchemaVersion, + status.DatabaseUpdatedAt, + status.DatabaseNextUpdate, + status.DatabaseAgeSeconds, + status.MaximumDatabaseAgeSeconds, + status.DurationMs, + status.Code, + status.Message); +} + +internal static class TrivyDatabaseDocumentRedactor +{ + internal const string ExecutableAlias = "local-trivy-executable"; + internal const string CacheAlias = "local-trivy-cache"; + + public static TrivyDatabaseDocument Redact(TrivyDatabaseDocument document) => document with + { + PrivacyMode = TrivyDatabaseDocument.ReducedMode, + ExecutablePath = RedactPath(document.ExecutablePath, ExecutableAlias), + CacheDirectory = RedactPath(document.CacheDirectory, CacheAlias), + Message = RedactMessage(document), + }; + + private static string RedactPath(string path, string alias) => + path == TrivyDatabaseDocument.UnresolvedPath ? path : alias; + + private static string RedactMessage(TrivyDatabaseDocument document) + { + var result = document.Message; + foreach (var item in new[] + { + (Path: document.ExecutablePath, Alias: ExecutableAlias), + (Path: document.CacheDirectory, Alias: CacheAlias), + } + .Where(static item => item.Path != TrivyDatabaseDocument.UnresolvedPath) + .OrderByDescending(static item => item.Path.Length)) + { + result = result.Replace(item.Path, item.Alias, StringComparison.OrdinalIgnoreCase); + } + + return result; + } +} diff --git a/src/PortCVE/Vulnerabilities/TrivyDatabaseService.cs b/src/PortCVE/Vulnerabilities/TrivyDatabaseService.cs new file mode 100644 index 0000000..385aac5 --- /dev/null +++ b/src/PortCVE/Vulnerabilities/TrivyDatabaseService.cs @@ -0,0 +1,1138 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace PortCVE.Vulnerabilities; + +internal enum TrivyDatabaseOperation +{ + Status, + Update, +} + +internal enum TrivyDatabaseState +{ + Ready, + Missing, + Stale, + Invalid, + Unavailable, + Failed, +} + +internal sealed record TrivyDatabaseStatus( + int SchemaVersion, + string Provider, + TrivyDatabaseOperation Operation, + TrivyDatabaseState State, + bool Ready, + bool NetworkRequested, + string? ExecutablePath, + string? EngineVersion, + string? CacheDirectory, + int? DatabaseSchemaVersion, + DateTimeOffset? DatabaseUpdatedAt, + DateTimeOffset? DatabaseNextUpdate, + long? DatabaseAgeSeconds, + long MaximumDatabaseAgeSeconds, + long DurationMs, + string Code, + string Message); + +internal interface ITrivyDatabaseService +{ + Task GetStatusAsync(CancellationToken cancellationToken); + + Task UpdateAsync(CancellationToken cancellationToken); +} + +internal sealed partial class TrivyDatabaseService : ITrivyDatabaseService +{ + internal const int StatusSchemaVersion = TrivyDatabaseDocument.CurrentSchemaVersion; + internal static readonly TimeSpan MaximumDatabaseAge = TimeSpan.FromHours(72); + + private const int MaximumMetadataBytes = 1024 * 1024; + private const int MaximumVersionOutputCharacters = 64 * 1024; + private const int MaximumUpdateOutputCharacters = 256 * 1024; + private static readonly TimeSpan VersionTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan DatabaseValidationTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan DefaultUpdateTimeout = TimeSpan.FromMinutes(10); + private static readonly string[] RemovedEnvironmentVariables = + [ + "GITHUB_TOKEN", + "GH_TOKEN", + "CI_JOB_TOKEN", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + ]; + private static readonly string[] RemovedEnvironmentVariablePrefixes = + [ + "TRIVY_", + "DOCKER_", + "CONTAINERD_", + "PODMAN_", + "AWS_", + "AZURE_", + "GOOGLE_", + "OCI_", + "ORAS_", + ]; + + private readonly string? configuredExecutable; + private readonly string configuredCacheDirectory; + private readonly IProcessRunner processRunner; + private readonly TimeProvider timeProvider; + private readonly TimeSpan updateTimeout; + private readonly string tempRootDirectory; + private readonly Func executableLocator; + private readonly Func cachePathValidator; + + public TrivyDatabaseService() + : this( + Environment.GetEnvironmentVariable("PORTCVE_TRIVY_PATH"), + ResolveCacheDirectory(), + new BoundedProcessRunner(), + TimeProvider.System, + DefaultUpdateTimeout, + ResolveTempRootDirectory()) + { + } + + internal TrivyDatabaseService( + string? configuredExecutable, + string cacheDirectory, + IProcessRunner processRunner, + TimeProvider timeProvider, + TimeSpan updateTimeout, + string? tempRootDirectory = null, + Func? executableLocator = null, + Func? cachePathValidator = null) + { + if (updateTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(updateTimeout)); + } + + this.configuredExecutable = configuredExecutable; + configuredCacheDirectory = cacheDirectory; + this.processRunner = processRunner; + this.timeProvider = timeProvider; + this.updateTimeout = updateTimeout; + this.tempRootDirectory = tempRootDirectory ?? ResolveTempRootDirectory(); + this.executableLocator = executableLocator ?? LocateExecutable; + this.cachePathValidator = cachePathValidator ?? LocalPathPolicy.ValidateLocalDirectoryPath; + } + + public Task GetStatusAsync(CancellationToken cancellationToken) => + RunAsync(TrivyDatabaseOperation.Status, cancellationToken); + + public Task UpdateAsync(CancellationToken cancellationToken) => + RunAsync(TrivyDatabaseOperation.Update, cancellationToken); + + internal static LocalPathValidation LocateExecutable(string? configuredPath) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + try + { + if (!Path.IsPathFullyQualified(configuredPath)) + { + return new( + false, + null, + "trivy_executable_invalid", + "PORTCVE_TRIVY_PATH must be an absolute path to a local executable."); + } + + if (!Path.GetExtension(configuredPath).Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + return new( + false, + null, + "trivy_executable_invalid", + "PORTCVE_TRIVY_PATH must name a Windows .exe file."); + } + + return LocalPathPolicy.ValidateExistingTrivyExecutable(configuredPath); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) + { + return new( + false, + null, + "trivy_executable_invalid", + "PORTCVE_TRIVY_PATH is not a valid local executable path."); + } + } + + var pathValue = Environment.GetEnvironmentVariable("PATH"); + foreach (var entry in (pathValue ?? string.Empty).Split( + Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var directory = Environment.ExpandEnvironmentVariables(entry.Trim('"')); + if (!Path.IsPathFullyQualified(directory)) + { + continue; + } + + var directoryValidation = LocalPathPolicy.ValidateLocalDirectoryPath(directory); + if (!directoryValidation.IsValid) + { + continue; + } + + string candidate; + try + { + candidate = Path.Combine(directoryValidation.FullPath!, "trivy.exe"); + } + catch (ArgumentException) + { + continue; + } + + if (!File.Exists(candidate)) + { + continue; + } + + return LocalPathPolicy.ValidateExistingTrivyExecutable(candidate); + } + + return new( + false, + null, + "trivy_executable_not_found", + "Trivy was not found on the local PATH. Set PORTCVE_TRIVY_PATH to its absolute path."); + } + + private async Task RunAsync( + TrivyDatabaseOperation operation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var networkRequested = operation == TrivyDatabaseOperation.Update; + var executableValidation = executableLocator(configuredExecutable); + if (!executableValidation.IsValid) + { + return Status( + operation, + TrivyDatabaseState.Unavailable, + networkRequested, + null, + null, + SafeFullPath(configuredCacheDirectory), + null, + 0, + executableValidation.Code, + "Trivy could not be resolved to a safe existing local executable."); + } + + var executablePath = executableValidation.FullPath!; + var cacheValidation = cachePathValidator(configuredCacheDirectory); + if (!cacheValidation.IsValid) + { + return Status( + operation, + TrivyDatabaseState.Invalid, + networkRequested, + executablePath, + null, + null, + null, + 0, + "trivy_cache_unsafe", + "The configured Trivy cache directory is not a safe local directory."); + } + + var cacheDirectory = cacheValidation.FullPath!; + if (networkRequested) + { + try + { + Directory.CreateDirectory(cacheDirectory); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return Status( + operation, + TrivyDatabaseState.Failed, + true, + executablePath, + null, + cacheDirectory, + null, + 0, + "trivy_cache_create_failed", + "PortCVE could not create the local Trivy cache directory."); + } + + cacheValidation = cachePathValidator(cacheDirectory); + if (!cacheValidation.IsValid) + { + return Status( + operation, + TrivyDatabaseState.Invalid, + true, + executablePath, + null, + null, + null, + 0, + "trivy_cache_unsafe", + "The Trivy cache directory became unsafe before the update started."); + } + } + + var temp = CreateInvocationTempDirectory(); + if (temp.Path is null) + { + return Status( + operation, + TrivyDatabaseState.Failed, + networkRequested, + executablePath, + null, + cacheDirectory, + null, + 0, + "trivy_temp_unavailable", + "PortCVE could not create a safe local Trivy temporary directory."); + } + + try + { + executableValidation = LocalPathPolicy.ValidateExistingTrivyExecutable(executablePath); + if (!executableValidation.IsValid) + { + return Status( + operation, + TrivyDatabaseState.Unavailable, + networkRequested, + null, + null, + cacheDirectory, + null, + 0, + "trivy_executable_changed", + "The validated Trivy executable became unavailable or unsafe before launch."); + } + + var versionResult = await processRunner.RunAsync( + CreateVersionInvocation(executablePath, cacheDirectory, temp.Path), + cancellationToken); + var versionFailure = VersionFailure( + operation, + networkRequested, + executablePath, + cacheDirectory, + versionResult); + if (versionFailure is not null) + { + return versionFailure; + } + + var engineVersion = ParseEngineVersion(versionResult.StandardOutput); + if (engineVersion is null) + { + return Status( + operation, + TrivyDatabaseState.Unavailable, + networkRequested, + executablePath, + null, + cacheDirectory, + null, + versionResult.DurationMs, + "trivy_version_invalid", + "Trivy returned an unrecognized version response."); + } + + var durationMs = versionResult.DurationMs; + if (networkRequested) + { + cacheValidation = cachePathValidator(cacheDirectory); + executableValidation = LocalPathPolicy.ValidateExistingTrivyExecutable(executablePath); + if (!cacheValidation.IsValid || !executableValidation.IsValid) + { + return Status( + operation, + TrivyDatabaseState.Invalid, + true, + executableValidation.IsValid ? executablePath : null, + engineVersion, + cacheValidation.IsValid ? cacheDirectory : null, + null, + durationMs, + "trivy_update_path_changed", + "A validated Trivy path became unavailable or unsafe before the update started."); + } + + var updateResult = await processRunner.RunAsync( + CreateUpdateInvocation(executablePath, cacheDirectory, temp.Path), + cancellationToken); + durationMs += updateResult.DurationMs; + var updateFailure = UpdateFailure( + operation, + executablePath, + engineVersion, + cacheDirectory, + durationMs, + updateResult); + if (updateFailure is not null) + { + return updateFailure; + } + } + + cacheValidation = cachePathValidator(cacheDirectory); + if (!cacheValidation.IsValid) + { + return Status( + operation, + TrivyDatabaseState.Invalid, + networkRequested, + executablePath, + engineVersion, + null, + null, + durationMs, + "trivy_cache_changed", + "The Trivy cache directory became unavailable or unsafe before verification."); + } + + var databaseStatus = InspectDatabase( + operation, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + durationMs); + if (!databaseStatus.Ready) + { + return databaseStatus; + } + + var validationTarget = CreateDatabaseValidationTarget(temp.Path); + if (validationTarget is null) + { + return databaseStatus with + { + State = TrivyDatabaseState.Failed, + Ready = false, + Code = "trivy_database_validation_target_failed", + Message = "PortCVE could not create an empty local database-validation target.", + }; + } + + cacheValidation = cachePathValidator(cacheDirectory); + executableValidation = LocalPathPolicy.ValidateExistingTrivyExecutable(executablePath); + if (!cacheValidation.IsValid || !executableValidation.IsValid) + { + return databaseStatus with + { + State = TrivyDatabaseState.Invalid, + Ready = false, + ExecutablePath = executableValidation.IsValid ? executablePath : null, + CacheDirectory = cacheValidation.IsValid ? cacheDirectory : null, + Code = "trivy_database_validation_path_changed", + Message = "A validated Trivy path became unavailable or unsafe before database validation.", + }; + } + + var validationResult = await processRunner.RunAsync( + CreateDatabaseValidationInvocation( + executablePath, + cacheDirectory, + temp.Path, + validationTarget), + cancellationToken); + return ApplyDatabaseValidationResult(databaseStatus, validationResult); + } + finally + { + TryDeleteInvocationTempDirectory(tempRootDirectory, temp.Path); + } + } + + private TrivyDatabaseStatus InspectDatabase( + TrivyDatabaseOperation operation, + bool networkRequested, + string executablePath, + string engineVersion, + string cacheDirectory, + long durationMs) + { + var databaseDirectory = Path.Combine(cacheDirectory, "db"); + var databaseDirectoryValidation = LocalPathPolicy.ValidateLocalDirectoryPath(databaseDirectory); + if (!databaseDirectoryValidation.IsValid) + { + return Status( + operation, + TrivyDatabaseState.Invalid, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "trivy_database_path_unsafe", + "The Trivy database directory is not a safe local directory."); + } + + if (!Directory.Exists(databaseDirectory)) + { + return Status( + operation, + TrivyDatabaseState.Missing, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "vulnerability_db_missing", + "The local Trivy vulnerability database is not installed."); + } + + var metadataPath = Path.Combine(databaseDirectory, "metadata.json"); + var databasePath = Path.Combine(databaseDirectory, "trivy.db"); + var metadataValidation = LocalPathPolicy.ValidateExistingTrivyDatabaseFile(metadataPath); + var databaseValidation = LocalPathPolicy.ValidateExistingTrivyDatabaseFile(databasePath); + if (!metadataValidation.IsValid || !databaseValidation.IsValid) + { + var missing = metadataValidation.Code == "trivy_database_not_found" + || databaseValidation.Code == "trivy_database_not_found"; + return Status( + operation, + missing ? TrivyDatabaseState.Missing : TrivyDatabaseState.Invalid, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + missing ? "vulnerability_db_missing" : "vulnerability_db_invalid", + missing + ? "The local Trivy vulnerability database is incomplete." + : "The local Trivy vulnerability database path is unsafe or invalid."); + } + + DatabaseMetadata metadata; + try + { + var metadataFile = new FileInfo(metadataPath); + var databaseFile = new FileInfo(databasePath); + if (metadataFile.Length is <= 0 or > MaximumMetadataBytes || databaseFile.Length <= 0) + { + return Status( + operation, + TrivyDatabaseState.Invalid, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "vulnerability_db_invalid", + "The local Trivy vulnerability database files are empty or malformed."); + } + + using var document = JsonDocument.Parse( + File.ReadAllBytes(metadataPath), + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 16, + }); + metadata = ParseMetadata(document.RootElement); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + return Status( + operation, + TrivyDatabaseState.Invalid, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "vulnerability_db_invalid", + "The local Trivy vulnerability database metadata is invalid or unreadable."); + } + + var age = timeProvider.GetUtcNow() - metadata.UpdatedAt; + var ageSeconds = Math.Max(0, (long)age.TotalSeconds); + if (age < TimeSpan.Zero) + { + return Status( + operation, + TrivyDatabaseState.Stale, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + metadata, + durationMs, + "vulnerability_db_timestamp_future", + "The local Trivy vulnerability database timestamp is in the future.", + ageSeconds); + } + + if (age > MaximumDatabaseAge) + { + return Status( + operation, + TrivyDatabaseState.Stale, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + metadata, + durationMs, + "vulnerability_db_stale", + "The local Trivy vulnerability database is older than 72 hours.", + ageSeconds); + } + + return Status( + operation, + TrivyDatabaseState.Ready, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + metadata, + durationMs, + "ok", + networkRequested + ? "The Trivy database update completed and its local structure and freshness are valid." + : "The local Trivy vulnerability database is ready.", + ageSeconds); + } + + private static DatabaseMetadata ParseMetadata(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object + || !TryGetProperty(root, "Version", out var versionValue) + || versionValue.ValueKind != JsonValueKind.Number + || !versionValue.TryGetInt32(out var version) + || version <= 0) + { + throw new InvalidDataException("The database metadata has no valid schema version."); + } + + if (!TryGetProperty(root, "UpdatedAt", out var updatedValue) + || updatedValue.ValueKind != JsonValueKind.String + || !updatedValue.TryGetDateTimeOffset(out var updatedAt)) + { + throw new InvalidDataException("The database metadata has no valid update time."); + } + + DateTimeOffset? nextUpdate = null; + if (TryGetProperty(root, "NextUpdate", out var nextValue) + && nextValue.ValueKind != JsonValueKind.Null) + { + if (nextValue.ValueKind != JsonValueKind.String + || !nextValue.TryGetDateTimeOffset(out var parsedNextUpdate)) + { + throw new InvalidDataException("The database metadata has an invalid next-update time."); + } + + nextUpdate = parsedNextUpdate; + } + + return new(version, updatedAt, nextUpdate); + } + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + if (element.TryGetProperty(name, out value)) + { + return true; + } + + var camel = char.ToLowerInvariant(name[0]) + name[1..]; + return element.TryGetProperty(camel, out value); + } + + private static ProcessInvocation CreateVersionInvocation( + string executablePath, + string cacheDirectory, + string tempDirectory) => new( + executablePath, + ["--version"], + VersionTimeout, + MaximumVersionOutputCharacters, + MaximumVersionOutputCharacters, + RemovedEnvironmentVariables, + SafeEnvironment(cacheDirectory, tempDirectory, allowNetwork: false), + RemovedEnvironmentVariablePrefixes); + + private ProcessInvocation CreateUpdateInvocation( + string executablePath, + string cacheDirectory, + string tempDirectory) => new( + executablePath, + [ + "image", + "--download-db-only", + "--cache-dir", cacheDirectory, + "--timeout", $"{(long)Math.Ceiling(updateTimeout.TotalSeconds)}s", + "--skip-java-db-update", + "--skip-check-update", + "--skip-vex-repo-update", + "--skip-version-check", + "--disable-telemetry", + "--no-progress", + ], + updateTimeout, + MaximumUpdateOutputCharacters, + MaximumUpdateOutputCharacters, + RemovedEnvironmentVariables, + SafeEnvironment(cacheDirectory, tempDirectory, allowNetwork: true), + RemovedEnvironmentVariablePrefixes); + + private static ProcessInvocation CreateDatabaseValidationInvocation( + string executablePath, + string cacheDirectory, + string tempDirectory, + string validationTarget) => new( + executablePath, + [ + "filesystem", + "--scanners", "vuln", + "--format", "json", + "--exit-code", "0", + "--cache-dir", cacheDirectory, + "--skip-db-update", + "--skip-java-db-update", + "--skip-check-update", + "--skip-vex-repo-update", + "--offline-scan", + "--skip-version-check", + "--disable-telemetry", + "--no-progress", + validationTarget, + ], + DatabaseValidationTimeout, + MaximumUpdateOutputCharacters, + MaximumUpdateOutputCharacters, + RemovedEnvironmentVariables, + SafeEnvironment(cacheDirectory, tempDirectory, allowNetwork: false), + RemovedEnvironmentVariablePrefixes); + + private static TrivyDatabaseStatus ApplyDatabaseValidationResult( + TrivyDatabaseStatus status, + ProcessExecutionResult result) + { + var durationMs = status.DurationMs + result.DurationMs; + if (!result.Started) + { + return status with + { + State = TrivyDatabaseState.Unavailable, + Ready = false, + DurationMs = durationMs, + Code = "trivy_database_validation_unavailable", + Message = "Trivy could not start the bounded offline database validation.", + }; + } + + if (result.TimedOut) + { + return status with + { + State = TrivyDatabaseState.Failed, + Ready = false, + DurationMs = durationMs, + Code = "trivy_database_validation_timeout", + Message = "Trivy exceeded the bounded offline database-validation timeout.", + }; + } + + if (result.OutputLimitExceeded) + { + return status with + { + State = TrivyDatabaseState.Failed, + Ready = false, + DurationMs = durationMs, + Code = "trivy_database_validation_output_too_large", + Message = "Trivy exceeded the bounded offline database-validation output limit.", + }; + } + + if (result.ExitCode != 0) + { + return status with + { + State = TrivyDatabaseState.Invalid, + Ready = false, + DurationMs = durationMs, + Code = "vulnerability_db_unreadable", + Message = "Trivy could not open and validate the local vulnerability database offline.", + }; + } + + try + { + using var report = JsonDocument.Parse( + result.StandardOutput, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + var root = report.RootElement; + if (!TryGetProperty(root, "SchemaVersion", out var schemaVersion) + || schemaVersion.ValueKind != JsonValueKind.Number + || !schemaVersion.TryGetInt32(out var parsedSchemaVersion) + || parsedSchemaVersion != 2 + || !TryGetProperty(root, "ArtifactType", out var artifactType) + || artifactType.ValueKind != JsonValueKind.String + || !string.Equals(artifactType.GetString(), "filesystem", StringComparison.Ordinal)) + { + throw new InvalidDataException(); + } + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + return status with + { + State = TrivyDatabaseState.Invalid, + Ready = false, + DurationMs = durationMs, + Code = "trivy_database_validation_output_invalid", + Message = "Trivy returned an invalid offline database-validation report.", + }; + } + + return status with { DurationMs = durationMs }; + } + + private static IReadOnlyDictionary SafeEnvironment( + string cacheDirectory, + string tempDirectory, + bool allowNetwork) + { + var environment = new Dictionary + { + ["TEMP"] = tempDirectory, + ["TMP"] = tempDirectory, + ["NO_COLOR"] = "1", + ["TRIVY_CACHE_DIR"] = cacheDirectory, + ["TRIVY_DISABLE_TELEMETRY"] = "true", + ["TRIVY_SKIP_VERSION_CHECK"] = "true", + ["TRIVY_SKIP_JAVA_DB_UPDATE"] = "true", + ["TRIVY_SKIP_CHECK_UPDATE"] = "true", + ["TRIVY_SKIP_VEX_REPO_UPDATE"] = "true", + }; + if (!allowNetwork) + { + environment["TRIVY_SKIP_DB_UPDATE"] = "true"; + environment["TRIVY_OFFLINE_SCAN"] = "true"; + } + + return environment; + } + + private static TrivyDatabaseStatus? VersionFailure( + TrivyDatabaseOperation operation, + bool networkRequested, + string executablePath, + string cacheDirectory, + ProcessExecutionResult result) + { + if (!result.Started) + { + return Status( + operation, + TrivyDatabaseState.Unavailable, + networkRequested, + executablePath, + null, + cacheDirectory, + null, + result.DurationMs, + "trivy_unavailable", + "The validated Trivy executable could not be started."); + } + + if (result.TimedOut) + { + return Status( + operation, + TrivyDatabaseState.Unavailable, + networkRequested, + executablePath, + null, + cacheDirectory, + null, + result.DurationMs, + "trivy_version_timeout", + "Trivy exceeded the ten-second version check limit."); + } + + if (result.OutputLimitExceeded) + { + return Status( + operation, + TrivyDatabaseState.Unavailable, + networkRequested, + executablePath, + null, + cacheDirectory, + null, + result.DurationMs, + "trivy_version_output_too_large", + "Trivy exceeded the bounded version output limit."); + } + + return result.ExitCode == 0 + ? null + : Status( + operation, + TrivyDatabaseState.Unavailable, + networkRequested, + executablePath, + null, + cacheDirectory, + null, + result.DurationMs, + "trivy_version_failed", + "Trivy could not report its version successfully."); + } + + private static TrivyDatabaseStatus? UpdateFailure( + TrivyDatabaseOperation operation, + string executablePath, + string engineVersion, + string cacheDirectory, + long durationMs, + ProcessExecutionResult result) + { + if (!result.Started) + { + return Status( + operation, + TrivyDatabaseState.Failed, + true, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "trivy_update_unavailable", + "Trivy could not start the explicit database update."); + } + + if (result.TimedOut) + { + return Status( + operation, + TrivyDatabaseState.Failed, + true, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "trivy_update_timeout", + "Trivy exceeded the bounded database update limit."); + } + + if (result.OutputLimitExceeded) + { + return Status( + operation, + TrivyDatabaseState.Failed, + true, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "trivy_update_output_too_large", + "Trivy exceeded the bounded database update output limit."); + } + + return result.ExitCode == 0 + ? null + : Status( + operation, + TrivyDatabaseState.Failed, + true, + executablePath, + engineVersion, + cacheDirectory, + null, + durationMs, + "trivy_update_failed", + "Trivy did not complete the explicit database update."); + } + + private TempDirectoryResult CreateInvocationTempDirectory() + { + var rootValidation = LocalPathPolicy.ValidateLocalDirectoryPath(tempRootDirectory); + if (!rootValidation.IsValid) + { + return new(null); + } + + try + { + var root = rootValidation.FullPath!; + Directory.CreateDirectory(root); + rootValidation = LocalPathPolicy.ValidateLocalDirectoryPath(root); + if (!rootValidation.IsValid) + { + return new(null); + } + + var path = Path.Combine(root, $"db-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + var validation = LocalPathPolicy.ValidateLocalDirectoryPath(path); + if (!validation.IsValid) + { + TryDeleteInvocationTempDirectory(root, path); + return new(null); + } + + return new(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return new(null); + } + } + + private static string? CreateDatabaseValidationTarget(string tempDirectory) + { + try + { + var target = Path.Combine(tempDirectory, "database-validation-target"); + Directory.CreateDirectory(target); + var validation = LocalPathPolicy.ValidateLocalDirectoryPath(target); + if (!validation.IsValid || Directory.EnumerateFileSystemEntries(target).Any()) + { + return null; + } + + return validation.FullPath; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } + + internal static bool TryDeleteInvocationTempDirectory(string root, string candidate) + { + try + { + var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullCandidate = Path.GetFullPath(candidate) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.Equals(Path.GetDirectoryName(fullCandidate), fullRoot, StringComparison.OrdinalIgnoreCase) + || !InvocationTempNameRegex().IsMatch(Path.GetFileName(fullCandidate))) + { + return false; + } + + if (Directory.Exists(fullCandidate)) + { + var reparse = LocalPathPolicy.IsReparsePoint(File.GetAttributes(fullCandidate)); + Directory.Delete(fullCandidate, recursive: !reparse); + } + + return true; + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static TrivyDatabaseStatus Status( + TrivyDatabaseOperation operation, + TrivyDatabaseState state, + bool networkRequested, + string? executablePath, + string? engineVersion, + string? cacheDirectory, + DatabaseMetadata? metadata, + long durationMs, + string code, + string message, + long? ageSeconds = null) => new( + StatusSchemaVersion, + "trivy", + operation, + state, + state == TrivyDatabaseState.Ready, + networkRequested, + executablePath, + engineVersion, + cacheDirectory, + metadata?.Version, + metadata?.UpdatedAt, + metadata?.NextUpdate, + ageSeconds, + (long)MaximumDatabaseAge.TotalSeconds, + durationMs, + code, + message); + + private static string? ParseEngineVersion(string output) + { + var match = EngineVersionRegex().Match(output); + return match.Success ? match.Groups[1].Value : null; + } + + private static string? SafeFullPath(string path) + { + try + { + return Path.GetFullPath(path); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) + { + return null; + } + } + + private static string ResolveCacheDirectory() + { + var configured = Environment.GetEnvironmentVariable("PORTCVE_TRIVY_CACHE_DIR") + ?? Environment.GetEnvironmentVariable("TRIVY_CACHE_DIR"); + return !string.IsNullOrWhiteSpace(configured) + ? configured + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "trivy"); + } + + private static string ResolveTempRootDirectory() => Path.Combine( + Path.GetTempPath(), + "PortCVE", + "trivy-db"); + + private sealed record DatabaseMetadata( + int Version, + DateTimeOffset UpdatedAt, + DateTimeOffset? NextUpdate); + + private sealed record TempDirectoryResult(string? Path); + + [GeneratedRegex("^db-[0-9a-f]{32}$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)] + private static partial Regex InvocationTempNameRegex(); + + [GeneratedRegex( + @"(?im)^Version:\s*([0-9A-Za-z][0-9A-Za-z.+-]{0,63})\s*$", + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)] + private static partial Regex EngineVersionRegex(); +} diff --git a/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs b/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs index d1c4b13..57653c8 100644 --- a/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs +++ b/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs @@ -478,7 +478,7 @@ private DatabaseMetadata ReadDatabaseMetadata() var validation = LocalPathPolicy.ValidateExistingLocalFile(path); if (!validation.IsValid) { - return validation.Code == "sbom_not_found" + return validation.Code == "sbom_path_not_found" ? new(null, "vulnerability_db_missing", $"No local Trivy vulnerability database was found at '{path}'. PortCVE never downloads it automatically.") : new(null, "vulnerability_db_invalid", diff --git a/tests/PortCVE.Tests/CliParserTests.cs b/tests/PortCVE.Tests/CliParserTests.cs index b214c61..e240698 100644 --- a/tests/PortCVE.Tests/CliParserTests.cs +++ b/tests/PortCVE.Tests/CliParserTests.cs @@ -1,5 +1,6 @@ using PortCVE.Cli; using PortCVE.Domain; +using PortCVE.Remote.Imports; using PortCVE.Vulnerabilities; namespace PortCVE.Tests; @@ -114,6 +115,143 @@ public void Parse_ScanAll_IsTcpOnly() Assert.Equal(TransportProtocol.Tcp, result.Protocol); } + [Theory] + [InlineData("status", CommandKind.DbStatus, false)] + [InlineData("update", CommandKind.DbUpdate, true)] + public void Parse_TrivyDatabaseCommands_AreExplicitAndBounded( + string action, + CommandKind expectedCommand, + bool json) + { + var arguments = json + ? new[] { "db", action, "--json" } + : new[] { "db", action }; + + var result = CliParser.Parse(arguments); + + Assert.Equal(expectedCommand, result.Command); + Assert.Equal(json, result.Json); + } + + [Theory] + [InlineData("db")] + [InlineData("db", "unknown")] + [InlineData("db", "status", "extra")] + [InlineData("db", "update", "--strict")] + [InlineData("db", "update", "--output", "report.json")] + [InlineData("db", "status", "--include-private")] + [InlineData("db", "status", "--online-advisories")] + public void Parse_InvalidTrivyDatabaseCommands_AreRejected(params string[] arguments) + { + Assert.Throws(() => CliParser.Parse(arguments)); + } + + [Fact] + public void Parse_TrivyDatabaseHelp_UsesGlobalReference() + { + var result = CliParser.Parse(["db", "--help"]); + + Assert.Equal(CommandKind.Help, result.Command); + } + + [Fact] + public void Parse_TrivyDatabasePrivateJson_IsExplicitlyWired() + { + var result = CliParser.Parse(["db", "status", "--json", "--include-private"]); + + Assert.Equal(CommandKind.DbStatus, result.Command); + Assert.True(result.Json); + Assert.True(result.IncludePrivate); + } + + [Fact] + public void Parse_ScanHost_PentestOptionsAreWired() + { + var result = CliParser.Parse( + [ + "scan-host", "192.0.2.0/30", "--ports", "22,80,443,8000-8002", "--active", + "--authorized", "--online-advisories", "--concurrency", "64", "--rate", "250", "--connect-timeout", "750ms", + "--read-timeout", "2s", "--max-hosts", "16", "--fail-on", "high", "--json", + ]); + + Assert.Equal(CommandKind.ScanHost, result.Command); + Assert.Equal("192.0.2.0/30", result.RemoteTarget); + Assert.Equal("22,80,443,8000-8002", result.RemotePorts); + Assert.True(result.Active); + Assert.True(result.Authorized); + Assert.True(result.OnlineAdvisories); + Assert.Equal(64, result.Concurrency); + Assert.Equal(250, result.Rate); + Assert.Equal(TimeSpan.FromMilliseconds(750), result.ConnectTimeout); + Assert.Equal(TimeSpan.FromSeconds(2), result.ReadTimeout); + Assert.Equal(16, result.MaximumHosts); + Assert.Equal(VulnerabilitySeverity.High, result.FailOn); + Assert.True(result.Json); + } + + [Fact] + public void Parse_ScanHost_AllowsExplicitMaximumEngagementSize() + { + var result = CliParser.Parse( + ["scan-host", "10.20.0.0/16", "--authorized", "--max-hosts", "65536", "--ports", "443"]); + + Assert.Equal(65536, result.MaximumHosts); + Assert.Equal("443", result.RemotePorts); + } + + [Theory] + [InlineData("nmap", RemoteImportFormat.NmapXml)] + [InlineData("nmap-xml", RemoteImportFormat.NmapXml)] + [InlineData("nuclei", RemoteImportFormat.NucleiJsonl)] + [InlineData("nuclei-jsonl", RemoteImportFormat.NucleiJsonl)] + public void Parse_ImportFormatPathAndOutputFlags_AreWired( + string format, + RemoteImportFormat expectedFormat) + { + var result = CliParser.Parse( + ["import", format, "results.fixture", "--output", "normalized.json", "--force", "--strict", "--json"]); + + Assert.Equal(CommandKind.Import, result.Command); + Assert.Equal(expectedFormat, result.ImportFormat); + Assert.Equal("results.fixture", result.InputPath); + Assert.Equal("normalized.json", result.OutputPath); + Assert.True(result.Force); + Assert.True(result.Strict); + Assert.True(result.Json); + } + + [Theory] + [InlineData("import")] + [InlineData("import", "nmap")] + [InlineData("import", "unknown", "results.txt")] + [InlineData("import", "nmap", "results.xml", "extra")] + [InlineData("import", "nmap", "results.xml", "--include-private")] + [InlineData("import", "nuclei", "results.jsonl", "--active")] + [InlineData("import", "nuclei", "results.jsonl", "--fail-on", "high")] + public void Parse_InvalidImportCombinations_AreRejected(params string[] arguments) + { + Assert.Throws(() => CliParser.Parse(arguments)); + } + + [Theory] + [InlineData("scan-host")] + [InlineData("scan-host", "example.com", "extra")] + [InlineData("scan-host", "example.com")] + [InlineData("scan-host", "example.com", "--all")] + [InlineData("scan-host", "example.com", "--active")] + [InlineData("scan-host", "example.com", "--firewall")] + [InlineData("scan-host", "example.com", "--concurrency", "0")] + [InlineData("scan-host", "example.com", "--rate", "10001")] + [InlineData("scan-host", "example.com", "--authorized", "--max-hosts", "65537")] + [InlineData("scan-host", "example.com", "--authorized", "--fail-on", "high")] + [InlineData("scan-host", "example.com", "--connect-timeout", "31s")] + [InlineData("list", "--ports", "80")] + [InlineData("scan", "tcp:443", "--active")] + public void Parse_InvalidScanHostCombinations_AreRejected(params string[] arguments) + { + Assert.Throws(() => CliParser.Parse(arguments)); + } + [Theory] [InlineData("scan")] [InlineData("scan", "udp:53")] diff --git a/tests/PortCVE.Tests/RemoteAdvisoryCatalogTests.cs b/tests/PortCVE.Tests/RemoteAdvisoryCatalogTests.cs new file mode 100644 index 0000000..a296a22 --- /dev/null +++ b/tests/PortCVE.Tests/RemoteAdvisoryCatalogTests.cs @@ -0,0 +1,65 @@ +using PortCVE.Remote.Advisories; + +namespace PortCVE.Tests; + +public sealed class RemoteAdvisoryCatalogTests +{ + [Theory] + [InlineData("OpenSSH", "9.6p1", "SSH-2.0-OpenSSH_9.6p1", "cpe:2.3:a:openbsd:openssh:9.6:p1:*:*:*:*:*:*")] + [InlineData("Dropbear SSH", "2020.81", "SSH-2.0-dropbear_2020.81", "cpe:2.3:a:dropbear_ssh_project:dropbear_ssh:2020.81:*:*:*:*:*:*:*")] + [InlineData("ProFTPD", "1.3.8", "220 ProFTPD 1.3.8 Server (fixture) [192.0.2.1]", "cpe:2.3:a:proftpd:proftpd:1.3.8:*:*:*:*:*:*:*")] + [InlineData("ProFTPD", "1.3.8a", "220 ProFTPD 1.3.8a Server (fixture) [192.0.2.1]", "cpe:2.3:a:proftpd:proftpd:1.3.8a:*:*:*:*:*:*:*")] + [InlineData("vsftpd", "3.0.3", "220 (vsFTPd 3.0.3)", "cpe:2.3:a:vsftpd_project:vsftpd:3.0.3:*:*:*:*:*:*:*")] + [InlineData("Exim", "4.98.2", "220 mail.example ESMTP Exim 4.98.2 ready", "cpe:2.3:a:exim:exim:4.98.2:*:*:*:*:*:*:*")] + [InlineData("Apache httpd", "2.4.62", "Server: Apache/2.4.62", "cpe:2.3:a:apache:http_server:2.4.62:*:*:*:*:*:*:*")] + [InlineData("Apache HTTP Server", "2.4.62", "Server: Apache/2.4.62", "cpe:2.3:a:apache:http_server:2.4.62:*:*:*:*:*:*:*")] + public void Resolve_VerifiedExactMappingsProduceVersionedCpe( + string product, + string version, + string evidence, + string expectedCpe) + { + var result = new RemoteBannerCpeCatalog().Resolve( + product, + version, + evidence, + RemoteAdvisoryConfidence.Strong); + + Assert.True(result.IsResolved); + Assert.Equal(expectedCpe, result.Cpe23Uri); + Assert.Null(result.Diagnostic); + Assert.Contains("Official CPE Dictionary", result.MappingSource, StringComparison.Ordinal); + Assert.Equal( + RemoteBannerCpeCatalog.Resolution.VerifiedCatalogProvenance, + result.Provenance); + } + + [Theory] + [InlineData("Apache", "2.4.62", "Strong", "cpe_mapping_unverified")] + [InlineData("nginx", "1.26.2", "Strong", "cpe_mapping_unverified")] + [InlineData("Apache HTTP Server", "2.4.62-custom", "Strong", "version_not_cpe_safe")] + [InlineData("OpenSSH", "9.6p1 Ubuntu-3", "Strong", "version_not_cpe_safe")] + [InlineData("Dropbear SSH", "2020.81-test", "Strong", "version_not_cpe_safe")] + [InlineData("ProFTPD", "1.3.8rc4", "Strong", "version_not_cpe_safe")] + [InlineData("vsftpd", "3", "Strong", "version_not_cpe_safe")] + [InlineData("Exim", "4.98-RC3", "Strong", "version_not_cpe_safe")] + [InlineData("Exim", "4.98.2+deb12", "Strong", "version_not_cpe_safe")] + [InlineData("OpenSSH", "9.6p1", "Heuristic", "identity_confidence_insufficient")] + [InlineData("OpenSSH", "9.6p1", "Unresolved", "identity_confidence_insufficient")] + public void Resolve_UncertainOrAmbiguousIdentityRemainsUnresolved( + string product, + string version, + string confidence, + string expectedCode) + { + var result = new RemoteBannerCpeCatalog().Resolve( + product, + version, + "remote banner", + Enum.Parse(confidence)); + + Assert.False(result.IsResolved); + Assert.Null(result.Cpe23Uri); + Assert.Equal(expectedCode, result.Diagnostic?.Code); + } +} diff --git a/tests/PortCVE.Tests/RemoteAdvisoryClientTests.cs b/tests/PortCVE.Tests/RemoteAdvisoryClientTests.cs new file mode 100644 index 0000000..5b6bf95 --- /dev/null +++ b/tests/PortCVE.Tests/RemoteAdvisoryClientTests.cs @@ -0,0 +1,890 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using PortCVE.Remote.Advisories; + +namespace PortCVE.Tests; + +public sealed class RemoteAdvisoryClientTests +{ + private const string OpenSshCpe = "cpe:2.3:a:openbsd:openssh:9.6:p1:*:*:*:*:*:*"; + + [Fact] + public async Task EnrichAsync_OfflineUnresolvedAndHeuristicInputsMakeNoRequests() + { + using var httpClient = new HttpClient(new RecordingHandler(static (_, _, _) => + throw new InvalidOperationException("HTTP must not be called."))); + var client = Client(httpClient); + + var offline = await client.EnrichAsync(Request(explicitOnline: false), CancellationToken.None); + var unresolved = await client.EnrichAsync( + Request(cpe: null), + CancellationToken.None); + var heuristic = await client.EnrichAsync( + Request(confidence: RemoteAdvisoryConfidence.Heuristic), + CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.NotRequested, offline.Status); + Assert.Equal(RemoteAdvisoryResult.OfflineNetworkMode, offline.NetworkMode); + Assert.Equal(RemoteAdvisoryStatus.Unresolved, unresolved.Status); + Assert.Equal("cpe_unresolved", Assert.Single(unresolved.Diagnostics).Code); + Assert.Equal(RemoteAdvisoryStatus.Unresolved, heuristic.Status); + Assert.Empty(offline.Matches); + Assert.Empty(unresolved.Matches); + Assert.Empty(heuristic.Matches); + } + + [Fact] + public async Task EnrichAsync_UsesEncodedCpeAndApiKeyThenReturnsDeterministicMatches() + { + var response = Page( + 0, + 2, + "2026-08-09T08:10:00.000Z", + Cve("CVE-2026-12345", "LOW", "https://vendor.example/z", "https://vendor.example/a"), + Cve("CVE-2026-12346", "CRITICAL", "https://vendor.example/b", "https://vendor.example/a")); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse(response))); + using var httpClient = new HttpClient(handler); + var client = Client(httpClient); + + var result = await client.EnrichAsync( + Request(apiKey: "test-key-not-a-secret"), + CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Complete, result.Status); + Assert.Equal(RemoteAdvisoryResult.ProviderName, result.Provider); + Assert.Equal(RemoteAdvisoryResult.ExplicitOnlineNetworkMode, result.NetworkMode); + Assert.Equal(DateTimeOffset.Parse("2026-08-09T08:10:00Z"), result.SourceTimestamp); + Assert.Empty(result.Diagnostics); + + Assert.Equal(2, result.Matches.Count); + var match = result.Matches[0]; + Assert.Equal("CVE-2026-12345", match.AdvisoryId); + Assert.Equal("candidate", match.Classification); + Assert.Equal("remote_banner_match", match.MatchMethod); + Assert.Equal("not_assessed", match.Exploitability); + Assert.Equal( + RemoteAdvisoryApplicabilityDisposition.DirectCandidate, + match.Applicability.Disposition); + Assert.Equal(RemoteAdvisorySeverity.Low, match.Severity); + Assert.Equal( + ["https://vendor.example/a", "https://vendor.example/z"], + match.References); + Assert.Equal("CVE-2026-12346", result.Matches[1].AdvisoryId); + Assert.Equal(RemoteAdvisorySeverity.Critical, result.Matches[1].Severity); + Assert.Equal( + ["https://vendor.example/a", "https://vendor.example/b"], + result.Matches[1].References); + + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("services.nvd.nist.gov", request.Uri.Host); + Assert.StartsWith("/rest/json/cves/2.0", request.Uri.AbsolutePath, StringComparison.Ordinal); + Assert.Contains("cpeName=cpe%3A2.3%3Aa%3Aopenbsd%3Aopenssh%3A9.6%3Ap1", request.Uri.OriginalString, StringComparison.Ordinal); + Assert.Contains("isVulnerable", request.Uri.Query, StringComparison.Ordinal); + Assert.Contains("noRejected", request.Uri.Query, StringComparison.Ordinal); + Assert.Equal(["test-key-not-a-secret"], request.Headers["apiKey"]); + } + + [Fact] + public async Task EnrichAsync_DuplicateCveWithConflictingApplicabilityFailsClosed() + { + var direct = Cve("CVE-2026-12345", "HIGH"); + var inconclusive = Cve("CVE-2026-12345", "HIGH"); + inconclusive["configurations"] = RangeConfigurations(); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 2, "2026-08-09T08:10:00.000Z", direct, inconclusive)))); + using var httpClient = new HttpClient(handler); + var client = Client(httpClient); + + var result = await client.EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Failed, result.Status); + Assert.Equal("nvd_duplicate_cve", Assert.Single(result.Diagnostics).Code); + Assert.Empty(result.Matches); + Assert.Null(result.SourceTimestamp); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task EnrichAsync_PaginatesWithinCapsAndEnforcesSpacing() + { + var responses = new Queue( + [ + Page(0, 2, "2026-08-09T08:10:00Z", Cve("CVE-2026-10001", "LOW")), + Page(1, 2, "2026-08-09T08:10:06Z", Cve("CVE-2026-10002", "HIGH")), + ]); + var handler = new RecordingHandler((_, _, _) => + Task.FromResult(JsonResponse(responses.Dequeue()))); + var time = new ManualTime(new DateTimeOffset(2026, 8, 9, 8, 0, 0, TimeSpan.Zero)); + using var httpClient = new HttpClient(handler); + var client = new NvdAdvisoryClient( + httpClient, + time, + time, + Options(resultsPerPage: 1, maxRequests: 2, maxCandidates: 2)); + + var result = await client.EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Complete, result.Status); + Assert.Equal(["CVE-2026-10001", "CVE-2026-10002"], result.Matches.Select(static match => match.AdvisoryId)); + Assert.Equal([TimeSpan.FromSeconds(6)], time.Delays); + Assert.Equal(2, handler.Requests.Count); + Assert.Contains("startIndex=0", handler.Requests[0].Uri.Query, StringComparison.Ordinal); + Assert.Contains("startIndex=1", handler.Requests[1].Uri.Query, StringComparison.Ordinal); + Assert.Equal(DateTimeOffset.Parse("2026-08-09T08:10:06Z"), result.SourceTimestamp); + } + + [Fact] + public async Task EnrichAsync_ResultCapFailsClosedWithoutFetchingMorePages() + { + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 3, "2026-08-09T08:10:00Z", Cve("CVE-2026-10001", "LOW"))))); + using var httpClient = new HttpClient(handler); + var client = Client( + httpClient, + Options(resultsPerPage: 1, maxRequests: 2, maxCandidates: 2)); + + var result = await client.EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Failed, result.Status); + Assert.Equal("nvd_result_cap_exceeded", Assert.Single(result.Diagnostics).Code); + Assert.Empty(result.Matches); + Assert.Single(handler.Requests); + Assert.Null(result.SourceTimestamp); + } + + [Fact] + public async Task EnrichAsync_MalformedLaterRecordFailsClosedAndDiscardsEarlierRecord() + { + var valid = Cve("CVE-2026-10001", "LOW"); + var malformed = Cve("CVE-2026-10002", "HIGH"); + _ = malformed.Remove("references"); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 2, "2026-08-09T08:10:00Z", valid, malformed)))); + using var httpClient = new HttpClient(handler); + + var result = await Client(httpClient) + .EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Failed, result.Status); + Assert.Equal("nvd_schema_invalid", Assert.Single(result.Diagnostics).Code); + Assert.Empty(result.Matches); + Assert.Null(result.SourceTimestamp); + } + + [Fact] + public async Task EnrichAsync_OversizedResponseFailsClosed() + { + var oversized = new string('x', 2048); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse(oversized))); + using var httpClient = new HttpClient(handler); + var client = Client(httpClient, Options(maxResponseBytes: 1024)); + + var result = await client.EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Failed, result.Status); + Assert.Equal("nvd_response_too_large", Assert.Single(result.Diagnostics).Code); + Assert.Empty(result.Matches); + } + + [Fact] + public async Task EnrichAsync_RequestTimeoutReturnsUnavailableWithoutMatches() + { + var handler = new RecordingHandler(async (_, _, cancellationToken) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("Unreachable"); + }); + using var httpClient = new HttpClient(handler); + var options = Options() with { RequestTimeout = TimeSpan.FromMilliseconds(50) }; + var client = Client(httpClient, options); + + var result = await client.EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Unavailable, result.Status); + Assert.Equal("nvd_timeout", Assert.Single(result.Diagnostics).Code); + Assert.Empty(result.Matches); + } + + [Fact] + public async Task EnrichAsync_CallerCancellationPropagates() + { + var handler = new RecordingHandler(static (_, _, _) => + throw new InvalidOperationException("HTTP must not be called.")); + using var httpClient = new HttpClient(handler); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + Client(httpClient).EnrichAsync(Request(), cancellation.Token)); + + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task EnrichAsync_RateLimitResponseIsUnavailableAndDoesNotParseBody() + { + var handler = new RecordingHandler((_, _, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent("not-json", Encoding.UTF8, "text/plain"), + })); + using var httpClient = new HttpClient(handler); + + var result = await Client(httpClient) + .EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Unavailable, result.Status); + Assert.Equal("nvd_rate_limited", Assert.Single(result.Diagnostics).Code); + Assert.Empty(result.Matches); + } + + [Fact] + public async Task EnrichAsync_CompoundApplicabilityIsConditionalAndPreserved() + { + var cve = Cve("CVE-2026-20001", "HIGH"); + cve["configurations"] = CompoundConfigurations(negate: false); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var result = await Client(httpClient).EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Complete, result.Status); + var match = Assert.Single(result.Matches); + Assert.Equal("conditional_candidate", match.Classification); + Assert.Equal( + RemoteAdvisoryApplicabilityDisposition.ConditionalCandidate, + match.Applicability.Disposition); + Assert.True(match.Applicability.QueriedCpeVulnerableLeafFound); + Assert.True(match.Applicability.HasRequiredCofactors); + var node = Assert.Single(Assert.Single(match.Applicability.Configurations).Nodes); + Assert.Equal("AND", node.Operator); + Assert.Equal(2, node.CpeMatches.Count); + Assert.Contains(node.CpeMatches, static criterion => + criterion.MatchesQueriedIdentity && criterion.Vulnerable); + Assert.Contains(node.CpeMatches, static criterion => !criterion.Vulnerable); + Assert.Equal("not_assessed", match.Exploitability); + } + + [Fact] + public async Task EnrichAsync_WrongVersionDirectBranchCannotOverrideTrueConditionalBranch() + { + var cve = Cve("CVE-2026-20006", "HIGH"); + cve["configurations"] = WrongVersionAndConditionalConfigurations(); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var match = Assert.Single((await Client(httpClient) + .EnrichAsync(Request(), CancellationToken.None)).Matches); + + Assert.Equal("conditional_candidate", match.Classification); + Assert.Equal( + RemoteAdvisoryApplicabilityDisposition.ConditionalCandidate, + match.Applicability.Disposition); + var wrongVersion = match.Applicability.Configurations[0].Nodes[0].CpeMatches[0]; + Assert.Equal(RemoteAdvisoryCpeAlignment.NoMatch, wrongVersion.IdentityAlignment); + Assert.False(wrongVersion.MatchesQueriedIdentity); + var queriedVersion = match.Applicability.Configurations[1].Nodes[0].CpeMatches[0]; + Assert.Equal(RemoteAdvisoryCpeAlignment.Proven, queriedVersion.IdentityAlignment); + Assert.True(queriedVersion.MatchesQueriedIdentity); + } + + [Fact] + public async Task EnrichAsync_UnobservedCpeQualifierCannotBecomeDirectCandidate() + { + var cve = Cve("CVE-2026-20007", "HIGH"); + cve["configurations"] = QualifiedConfigurations(); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var match = Assert.Single((await Client(httpClient) + .EnrichAsync(Request(), CancellationToken.None)).Matches); + + Assert.Equal("conditional_candidate", match.Classification); + Assert.False(match.Applicability.QueriedCpeVulnerableLeafFound); + Assert.True(match.Applicability.HasRequiredCofactors); + var criterion = Assert.Single( + Assert.Single(Assert.Single(match.Applicability.Configurations).Nodes).CpeMatches); + Assert.Equal( + RemoteAdvisoryCpeAlignment.ConditionalOnUnobservedQualifier, + criterion.IdentityAlignment); + Assert.False(criterion.MatchesQueriedIdentity); + Assert.True(criterion.HasUnobservedQualifiers); + } + + [Fact] + public async Task EnrichAsync_VersionRangeIsInconclusiveWithoutMatchCriteriaExpansion() + { + var cve = Cve("CVE-2026-20008", "HIGH"); + cve["configurations"] = RangeConfigurations(); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var match = Assert.Single((await Client(httpClient) + .EnrichAsync(Request(), CancellationToken.None)).Matches); + + Assert.Equal("inconclusive", match.Classification); + Assert.Equal( + RemoteAdvisoryApplicabilityDisposition.Inconclusive, + match.Applicability.Disposition); + var criterion = Assert.Single( + Assert.Single(Assert.Single(match.Applicability.Configurations).Nodes).CpeMatches); + Assert.Equal( + RemoteAdvisoryCpeAlignment.InconclusiveConstraint, + criterion.IdentityAlignment); + Assert.False(criterion.MatchesQueriedIdentity); + Assert.Contains(match.Applicability.Limitations, limitation => + limitation.Contains("range", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task EnrichAsync_DirectAlternativeWinsWithoutReportingRequiredCofactors() + { + var cve = Cve("CVE-2026-20009", "HIGH"); + cve["configurations"] = DirectConfigurations() + .Concat(CompoundConfigurations(negate: false)) + .ToArray(); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var match = Assert.Single((await Client(httpClient) + .EnrichAsync(Request(), CancellationToken.None)).Matches); + + Assert.Equal("candidate", match.Classification); + Assert.False(match.Applicability.HasRequiredCofactors); + } + + [Fact] + public async Task EnrichAsync_NegatedApplicabilityIsInconclusive() + { + var cve = Cve("CVE-2026-20002", "HIGH"); + cve["configurations"] = CompoundConfigurations(negate: true); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var match = Assert.Single((await Client(httpClient) + .EnrichAsync(Request(), CancellationToken.None)).Matches); + + Assert.Equal("inconclusive", match.Classification); + Assert.Equal( + RemoteAdvisoryApplicabilityDisposition.Inconclusive, + match.Applicability.Disposition); + Assert.Contains(match.Applicability.Limitations, limitation => + limitation.Contains("negation", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task EnrichAsync_AnalyzedRecordWithoutConfigurationsFailsClosed() + { + var cve = Cve("CVE-2026-20003", "HIGH"); + _ = cve.Remove("configurations"); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var result = await Client(httpClient).EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Failed, result.Status); + Assert.Equal("nvd_schema_invalid", Assert.Single(result.Diagnostics).Code); + Assert.Empty(result.Matches); + } + + [Fact] + public async Task EnrichAsync_ModifiedRecordIsRetainedAsPartialWithStatus() + { + var cve = Cve("CVE-2026-20004", "HIGH"); + cve["vulnStatus"] = "Modified"; + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var result = await Client(httpClient).EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Partial, result.Status); + var match = Assert.Single(result.Matches); + Assert.Equal("Modified", match.NvdStatus); + Assert.Equal(DateTimeOffset.Parse("2026-08-02T00:00:00Z"), match.NvdLastModified); + Assert.Equal("nvd_enrichment_modified", Assert.Single(result.Diagnostics).Code); + } + + [Fact] + public async Task EnrichAsync_AwaitingAnalysisIsPartialAndEmitsNoMatch() + { + var cve = Cve("CVE-2026-20005", "HIGH"); + cve["vulnStatus"] = "Awaiting Analysis"; + _ = cve.Remove("configurations"); + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 1, "2026-08-09T08:10:00Z", cve)))); + using var httpClient = new HttpClient(handler); + + var result = await Client(httpClient).EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Partial, result.Status); + Assert.Empty(result.Matches); + Assert.Equal("nvd_enrichment_incomplete", Assert.Single(result.Diagnostics).Code); + } + + [Fact] + public async Task EnrichAsync_CatalogResolutionIdentityMismatchMakesNoRequest() + { + var handler = new RecordingHandler(static (_, _, _) => + throw new InvalidOperationException("HTTP must not be called.")); + using var httpClient = new HttpClient(handler); + var resolution = new RemoteBannerCpeCatalog().Resolve( + "OpenSSH", + "9.6p1", + "SSH-2.0-OpenSSH_9.6p1", + RemoteAdvisoryConfidence.Strong); + var mismatched = new RemoteAdvisoryRequest( + new( + "Apache HTTP Server", + "9.6p1", + "SSH-2.0-OpenSSH_9.6p1", + RemoteAdvisoryConfidence.Strong, + resolution), + ExplicitOnline: true); + + var result = await Client(httpClient).EnrichAsync(mismatched, CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Unresolved, result.Status); + Assert.Equal("cpe_identity_binding_mismatch", Assert.Single(result.Diagnostics).Code); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task EnrichAsync_ProcessLimiterSpacesRequestsAcrossClientInstances() + { + var handler = new RecordingHandler((_, _, _) => Task.FromResult(JsonResponse( + Page(0, 0, "2026-08-09T08:10:00Z")))); + var time = new ManualTime(new DateTimeOffset(2026, 8, 9, 8, 0, 0, TimeSpan.Zero)); + var limiter = new NvdProcessRateLimiter(time, time); + using var httpClient = new HttpClient(handler); + var firstClient = new NvdAdvisoryClient(httpClient, time, time, Options(), limiter); + var secondClient = new NvdAdvisoryClient(httpClient, time, time, Options(), limiter); + + _ = await firstClient.EnrichAsync(Request(), CancellationToken.None); + _ = await secondClient.EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(2, handler.Requests.Count); + Assert.Equal([NvdProcessRateLimiter.ProductionMinimumSpacing], time.Delays); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task EnrichAsync_RetryAfterDelaysNextProcessRequest( + HttpStatusCode statusCode) + { + var handler = new RecordingHandler((_, requestNumber, _) => + { + if (requestNumber == 0) + { + var response = new HttpResponseMessage(statusCode); + response.Headers.RetryAfter = new(TimeSpan.FromSeconds(20)); + return Task.FromResult(response); + } + + return Task.FromResult(JsonResponse(Page(0, 0, "2026-08-09T08:10:20Z"))); + }); + var time = new ManualTime(new DateTimeOffset(2026, 8, 9, 8, 0, 0, TimeSpan.Zero)); + var limiter = new NvdProcessRateLimiter(time, time); + using var httpClient = new HttpClient(handler); + var firstClient = new NvdAdvisoryClient(httpClient, time, time, Options(), limiter); + var secondClient = new NvdAdvisoryClient(httpClient, time, time, Options(), limiter); + + var limited = await firstClient.EnrichAsync(Request(), CancellationToken.None); + var recovered = await secondClient.EnrichAsync(Request(), CancellationToken.None); + + Assert.Equal(RemoteAdvisoryStatus.Unavailable, limited.Status); + Assert.Equal(RemoteAdvisoryStatus.Complete, recovered.Status); + Assert.Equal([TimeSpan.FromSeconds(20)], time.Delays); + } + + [Fact] + public async Task RateLimiter_RetryAfterExtendsAnAlreadyWaitingRequest() + { + var time = new BlockingTime( + new DateTimeOffset(2026, 8, 9, 8, 0, 0, TimeSpan.Zero)); + var limiter = new NvdProcessRateLimiter(time, time); + await limiter.WaitAsync(CancellationToken.None); + + var waitingRequest = limiter.WaitAsync(CancellationToken.None); + await time.FirstDelayStarted.WaitAsync(TimeSpan.FromSeconds(2)); + await limiter.ApplyRetryAfterAsync(TimeSpan.FromSeconds(20), CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(2)); + time.ReleaseFirstDelay(); + await waitingRequest.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Equal([TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(14)], time.Delays); + } + + private static RemoteAdvisoryRequest Request( + bool explicitOnline = true, + string? cpe = OpenSshCpe, + RemoteAdvisoryConfidence confidence = RemoteAdvisoryConfidence.Strong, + string? apiKey = null) + { + var resolution = cpe is null + ? null + : new RemoteBannerCpeCatalog().Resolve( + "OpenSSH", + "9.6p1", + "SSH-2.0-OpenSSH_9.6p1", + confidence); + return new( + new( + "OpenSSH", + "9.6p1", + "SSH-2.0-OpenSSH_9.6p1", + confidence, + resolution), + explicitOnline, + apiKey); + } + + private static NvdAdvisoryClient Client( + HttpClient httpClient, + NvdAdvisoryClientOptions? options = null) + { + var time = new ManualTime(new DateTimeOffset(2026, 8, 9, 8, 0, 0, TimeSpan.Zero)); + return new(httpClient, time, time, options); + } + + private static NvdAdvisoryClientOptions Options( + int resultsPerPage = 10, + int maxRequests = 2, + int maxCandidates = 20, + int maxResponseBytes = 64 * 1024) => + new( + resultsPerPage, + maxRequests, + maxCandidates, + maxResponseBytes, + MaxReferencesPerAdvisory: 20, + RequestTimeout: TimeSpan.FromSeconds(5)); + + private static Dictionary Cve( + string id, + string severity, + params string[] references) => + new(StringComparer.Ordinal) + { + ["id"] = id, + ["sourceIdentifier"] = "security@example.test", + ["published"] = "2026-08-01T00:00:00.000Z", + ["lastModified"] = "2026-08-02T00:00:00.000Z", + ["vulnStatus"] = "Analyzed", + ["descriptions"] = new[] + { + new { lang = "en", value = $"Description for {id}." }, + }, + ["metrics"] = new + { + cvssMetricV31 = new[] + { + new + { + source = "nvd@nist.gov", + type = "Primary", + cvssData = new + { + version = "3.1", + vectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + baseScore = severity == "CRITICAL" ? 9.8 : severity == "HIGH" ? 8.1 : 3.1, + baseSeverity = severity, + }, + }, + }, + }, + ["configurations"] = DirectConfigurations(), + ["references"] = (references.Length == 0 + ? new[] { "https://nvd.nist.gov/vuln/detail/" + id } + : references) + .Select(static url => new { url }) + .ToArray(), + }; + + private static object[] DirectConfigurations() => + [ + new + { + nodes = new[] + { + new + { + @operator = "OR", + negate = false, + cpeMatch = new[] + { + new + { + vulnerable = true, + criteria = "cpe:2.3:a:openbsd:openssh:*:*:*:*:*:*:*:*", + matchCriteriaId = "c6d7d468-c829-4a4e-8865-e62d8ec5e274", + }, + }, + }, + }, + }, + ]; + + private static object[] CompoundConfigurations(bool negate) => + [ + new + { + nodes = new[] + { + new + { + @operator = "AND", + negate, + cpeMatch = new object[] + { + new + { + vulnerable = true, + criteria = "cpe:2.3:a:openbsd:openssh:*:*:*:*:*:*:*:*", + matchCriteriaId = "c6d7d468-c829-4a4e-8865-e62d8ec5e274", + }, + new + { + vulnerable = false, + criteria = "cpe:2.3:o:microsoft:windows_11:*:*:*:*:*:*:*:*", + matchCriteriaId = "11111111-2222-3333-4444-555555555555", + }, + }, + }, + }, + }, + ]; + + private static object[] WrongVersionAndConditionalConfigurations() => + [ + new + { + nodes = new[] + { + new + { + @operator = "OR", + negate = false, + cpeMatch = new[] + { + new + { + vulnerable = true, + criteria = "cpe:2.3:a:openbsd:openssh:9.9:p1:*:*:*:*:*:*", + matchCriteriaId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + }, + }, + }, + }, + }, + new + { + nodes = new[] + { + new + { + @operator = "AND", + negate = false, + cpeMatch = new object[] + { + new + { + vulnerable = true, + criteria = OpenSshCpe, + matchCriteriaId = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + }, + new + { + vulnerable = false, + criteria = "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", + matchCriteriaId = "cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa", + }, + }, + }, + }, + }, + ]; + + private static object[] QualifiedConfigurations() => + [ + new + { + nodes = new[] + { + new + { + @operator = "OR", + negate = false, + cpeMatch = new[] + { + new + { + vulnerable = true, + criteria = "cpe:2.3:a:openbsd:openssh:9.6:p1:*:*:*:windows:*:*", + matchCriteriaId = "dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb", + }, + }, + }, + }, + }, + ]; + + private static object[] RangeConfigurations() => + [ + new + { + nodes = new[] + { + new + { + @operator = "OR", + negate = false, + cpeMatch = new[] + { + new + { + vulnerable = true, + criteria = "cpe:2.3:a:openbsd:openssh:*:*:*:*:*:*:*:*", + matchCriteriaId = "eeeeeeee-ffff-aaaa-bbbb-cccccccccccc", + versionStartIncluding = "10.0", + }, + }, + }, + }, + }, + ]; + + private static string Page( + int startIndex, + int totalResults, + string timestamp, + params Dictionary[] cves) => + JsonSerializer.Serialize(new + { + resultsPerPage = cves.Length, + startIndex, + totalResults, + format = "NVD_CVE", + version = "2.0", + timestamp, + vulnerabilities = cves.Select(static cve => new { cve }).ToArray(), + }); + + private static HttpResponseMessage JsonResponse(string json) => + new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private sealed class RecordingHandler( + Func> responder) + : HttpMessageHandler + { + private int _requestNumber; + + internal List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var requestNumber = _requestNumber++; + Requests.Add(new( + request.Method, + request.RequestUri!, + request.Headers.ToDictionary( + static header => header.Key, + static header => header.Value.ToArray(), + StringComparer.OrdinalIgnoreCase))); + return responder(request, requestNumber, cancellationToken); + } + } + + private sealed record RecordedRequest( + HttpMethod Method, + Uri Uri, + IReadOnlyDictionary Headers); + + private sealed class ManualTime(DateTimeOffset utcNow) : + IRemoteAdvisoryClock, + IRemoteAdvisoryDelay + { + public DateTimeOffset UtcNow { get; private set; } = utcNow; + + public TimeSpan MonotonicNow { get; private set; } + + internal List Delays { get; } = []; + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Delays.Add(delay); + UtcNow += delay; + MonotonicNow += delay; + return Task.CompletedTask; + } + } + + private sealed class BlockingTime(DateTimeOffset initialUtcNow) : + IRemoteAdvisoryClock, + IRemoteAdvisoryDelay + { + private readonly Lock _sync = new(); + private readonly TaskCompletionSource _firstDelayStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseFirstDelay = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List _delays = []; + private long _elapsedTicks; + private int _delayCount; + + public DateTimeOffset UtcNow => + initialUtcNow + TimeSpan.FromTicks(Interlocked.Read(ref _elapsedTicks)); + + public TimeSpan MonotonicNow => + TimeSpan.FromTicks(Interlocked.Read(ref _elapsedTicks)); + + internal Task FirstDelayStarted => _firstDelayStarted.Task; + + internal IReadOnlyList Delays + { + get + { + lock (_sync) + { + return _delays.ToArray(); + } + } + } + + public async Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + int delayNumber; + lock (_sync) + { + _delays.Add(delay); + delayNumber = ++_delayCount; + } + + if (delayNumber == 1) + { + _firstDelayStarted.TrySetResult(true); + await _releaseFirstDelay.Task.WaitAsync(cancellationToken); + } + + _ = Interlocked.Add(ref _elapsedTicks, delay.Ticks); + } + + internal void ReleaseFirstDelay() => _releaseFirstDelay.TrySetResult(true); + } +} diff --git a/tests/PortCVE.Tests/RemoteAuditServiceTests.cs b/tests/PortCVE.Tests/RemoteAuditServiceTests.cs new file mode 100644 index 0000000..1647e5c --- /dev/null +++ b/tests/PortCVE.Tests/RemoteAuditServiceTests.cs @@ -0,0 +1,587 @@ +using PortCVE.Output; +using PortCVE.Remote; +using PortCVE.Remote.Advisories; + +namespace PortCVE.Tests; + +public sealed class RemoteAuditServiceTests +{ + [Fact] + public async Task AssessAsync_DeduplicatesVerifiedIdentityQueriesAcrossTargets() + { + var scanner = new FixedHostScanner(RemoteProductConfidence.BannerPattern); + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [], + [])); + var service = new RemoteAuditService(scanner, advisory); + + var report = await service.AssessAsync( + Options(["192.0.2.10", "192.0.2.11"], online: true), + CancellationToken.None); + + Assert.Equal(1, advisory.CallCount); + Assert.Equal(2, report.AdvisoryAssessments.Count); + var providerResult = Assert.Single(report.AdvisoryResults); + Assert.Equal(RemoteAdvisoryStatus.Complete, providerResult.Status); + Assert.All(report.AdvisoryAssessments, static item => + { + Assert.Equal(RemoteIdentityDisposition.Resolved, item.IdentityDisposition); + Assert.Equal("remote-advisory-result-0001", item.AdvisoryResultId); + Assert.StartsWith("cpe:2.3:a:openbsd:openssh:9.6:p1:", item.Cpe23Uri, StringComparison.Ordinal); + }); + Assert.Equal(RemoteAdvisoryStatus.Complete, report.AdvisoryStatus); + Assert.True(report.Summary.IsComplete); + } + + [Theory] + [InlineData( + "Dropbear SSH", + "2020.81", + "SSH-2.0-dropbear_2020.81", + "cpe:2.3:a:dropbear_ssh_project:dropbear_ssh:2020.81:*:*:*:*:*:*:*")] + [InlineData( + "ProFTPD", + "1.3.8a", + "220 ProFTPD 1.3.8a Server (fixture) [192.0.2.99]", + "cpe:2.3:a:proftpd:proftpd:1.3.8a:*:*:*:*:*:*:*")] + [InlineData( + "vsftpd", + "3.0.3", + "220 (vsFTPd 3.0.3)", + "cpe:2.3:a:vsftpd_project:vsftpd:3.0.3:*:*:*:*:*:*:*")] + [InlineData( + "Exim", + "4.98.2", + "220 mail.example ESMTP Exim 4.98.2 Sun, 10 Aug 2026 00:00:00 +0000", + "cpe:2.3:a:exim:exim:4.98.2:*:*:*:*:*:*:*")] + public async Task AssessAsync_ProtocolBoundCatalogProductsReachOneNormalizedProviderQuery( + string product, + string version, + string evidence, + string expectedCpe) + { + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [], + [])); + var service = new RemoteAuditService( + new FixedHostScanner( + RemoteProductConfidence.BannerPattern, + product, + version, + evidence), + advisory); + + var report = await service.AssessAsync( + Options(["192.0.2.10"], online: true), + CancellationToken.None); + + Assert.Equal(1, advisory.CallCount); + var assessment = Assert.Single(report.AdvisoryAssessments); + Assert.Equal(RemoteIdentityDisposition.Resolved, assessment.IdentityDisposition); + Assert.Equal(expectedCpe, assessment.Cpe23Uri); + var providerResult = Assert.Single(report.AdvisoryResults); + Assert.Equal(expectedCpe, providerResult.Cpe23Uri); + Assert.Equal("remote-advisory-result-0001", assessment.AdvisoryResultId); + Assert.Equal(RemoteAdvisoryStatus.Complete, report.AdvisoryStatus); + } + + [Fact] + public async Task AssessAsync_RepeatedIdentitySerializesProviderMatchesOnceAndKeepsEndpointReferences() + { + var targets = Enumerable.Range(0, 200) + .Select(index => $"host-{index:000}.example") + .ToArray(); + var advisory = new FixedAdvisoryClient(CompleteAdvisoryResultWithMatch()); + var service = new RemoteAuditService( + new FixedHostScanner(RemoteProductConfidence.BannerPattern), + advisory); + + var report = await service.AssessAsync( + Options(targets, online: true), + CancellationToken.None); + + Assert.Equal(1, advisory.CallCount); + Assert.Equal(200, report.AdvisoryAssessments.Count); + Assert.Single(report.AdvisoryResults); + Assert.All(report.AdvisoryAssessments, static assessment => + Assert.Equal("remote-advisory-result-0001", assessment.AdvisoryResultId)); + Assert.Equal(1, report.Summary.AdvisoryResultCount); + Assert.Equal(1, report.Summary.AdvisoryMatchCount); + + var privateJson = JsonOutput.Serialize(report); + var redactedJson = JsonOutput.Serialize(RemoteAuditRedactor.Redact(report)); + Assert.Equal(1, CountOccurrences(privateJson, "CVE-2026-42424")); + Assert.Equal(1, CountOccurrences(redactedJson, "CVE-2026-42424")); + Assert.Contains("host-000.example", privateJson, StringComparison.Ordinal); + Assert.DoesNotContain("host-000.example", redactedJson, StringComparison.Ordinal); + Assert.Contains("SSH-2.0-OpenSSH_9.6p1", privateJson, StringComparison.Ordinal); + Assert.DoesNotContain("SSH-2.0-OpenSSH_9.6p1", redactedJson, StringComparison.Ordinal); + + using var textOutput = new StringWriter(); + using var textError = new StringWriter(); + RemoteAuditTextRenderer.Render(report, textOutput, textError); + Assert.Equal(1, CountOccurrences(textOutput.ToString(), "CVE-2026-42424")); + Assert.Contains("200 endpoint association(s)", textOutput.ToString(), StringComparison.Ordinal); + Assert.Contains("(+195 more)", textOutput.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task AssessAsync_DistinctIdentityCapStopsNvdAndMarksReportPartial() + { + const int identitiesBeyondLimit = 60; + var targetCount = RemoteAuditService.MaximumUniqueAdvisoryIdentities + identitiesBeyondLimit; + var targets = Enumerable.Range(0, targetCount) + .Select(index => $"identity-{index:000}.example") + .ToArray(); + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [], + [])); + var service = new RemoteAuditService(new DistinctIdentityHostScanner(), advisory); + + var report = await service.AssessAsync( + Options(targets, online: true), + CancellationToken.None); + + Assert.Equal(RemoteAuditService.MaximumUniqueAdvisoryIdentities, advisory.CallCount); + Assert.Equal(RemoteAuditService.MaximumUniqueAdvisoryIdentities, report.AdvisoryResults.Count); + Assert.Equal(RemoteAuditService.MaximumUniqueAdvisoryIdentities, report.Summary.AdvisoryResultCount); + Assert.Equal(RemoteAdvisoryStatus.Partial, report.AdvisoryStatus); + Assert.False(report.Summary.IsComplete); + Assert.Equal(identitiesBeyondLimit, report.AdvisoryAssessments.Count(static assessment => + assessment.AdvisoryResultId is null && + assessment.Diagnostics.Any(static diagnostic => + diagnostic.Code == "nvd_identity_cap_exceeded"))); + Assert.Contains(report.Diagnostics, static diagnostic => + diagnostic.Code == "remote_advisory_identity_limit_exceeded"); + + using var textOutput = new StringWriter(); + using var textError = new StringWriter(); + RemoteAuditTextRenderer.Render(report, textOutput, textError); + Assert.Equal(50, CountOccurrences(textError.ToString(), "nvd_identity_cap_exceeded:")); + Assert.Contains( + "remote_assessment_diagnostics_truncated: 10 additional", + textError.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task AssessAsync_HeaderReportedIdentityNeverCallsNvdAndStrictEvidenceIsIncomplete() + { + var scanner = new FixedHostScanner(RemoteProductConfidence.HeaderReported); + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [], + [])); + var service = new RemoteAuditService(scanner, advisory); + + var report = await service.AssessAsync( + Options(["192.0.2.10"], online: true), + CancellationToken.None); + + Assert.Equal(0, advisory.CallCount); + var assessment = Assert.Single(report.AdvisoryAssessments); + Assert.Equal(RemoteIdentityDisposition.NotEligible, assessment.IdentityDisposition); + Assert.Null(assessment.AdvisoryResultId); + Assert.Empty(report.AdvisoryResults); + Assert.Equal(RemoteAdvisoryStatus.Partial, report.AdvisoryStatus); + Assert.False(report.Summary.IsComplete); + } + + [Theory] + [InlineData("timed_out")] + [InlineData("unreachable")] + public async Task AssessAsync_InconclusiveEndpointStateIsIncomplete(string stateName) + { + var state = stateName switch + { + "timed_out" => RemotePortState.TimedOut, + "unreachable" => RemotePortState.Unreachable, + _ => throw new ArgumentOutOfRangeException(nameof(stateName)), + }; + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [], + [])); + var service = new RemoteAuditService(new FixedPortStateHostScanner(state), advisory); + + var report = await service.AssessAsync( + Options(["192.0.2.10"], online: true), + CancellationToken.None); + + Assert.Equal(0, advisory.CallCount); + Assert.False(report.Summary.IsComplete); + Assert.Contains(report.Diagnostics, static diagnostic => + diagnostic.Code == "remote_endpoints_incomplete"); + } + + [Fact] + public async Task Redact_RemovesTargetAddressAndRawBannerButRetainsProductAndPort() + { + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.NotRequested, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.OfflineNetworkMode, + null, + [], + [])); + var service = new RemoteAuditService( + new FixedHostScanner(RemoteProductConfidence.BannerPattern), + advisory); + var report = await service.AssessAsync( + Options(["private.example"], online: false), + CancellationToken.None); + + var json = JsonOutput.Serialize(RemoteAuditRedactor.Redact(report)); + + Assert.DoesNotContain("private.example", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("SSH-2.0-OpenSSH_9.6p1", json, StringComparison.Ordinal); + Assert.Contains("target-001", json, StringComparison.Ordinal); + Assert.Contains("OpenSSH", json, StringComparison.Ordinal); + Assert.Contains("\"port\": 22", json, StringComparison.Ordinal); + Assert.Equal(0, advisory.CallCount); + } + + [Fact] + public async Task Redact_ReplacesDiagnosticMessagesAndDropsRemoteControlledAllowAttribute() + { + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Unavailable, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + null, + [], + [new( + "nvd_fixture_error", + "NVD lookup mentioned 203.0.113.77 and secret-advisory-detail.")])); + var service = new RemoteAuditService(new LeakyDiagnosticHostScanner(), advisory); + var report = await service.AssessAsync( + Options(["private.example"], online: true), + CancellationToken.None); + report = report with + { + AdvisoryAssessments = report.AdvisoryAssessments.Select(assessment => assessment with + { + Diagnostics = + [ + .. assessment.Diagnostics, + new( + "assessment_fixture_error", + "Assessment mentioned private.example and secret-assessment-detail."), + ], + }).ToArray(), + Diagnostics = + [ + .. report.Diagnostics, + new( + "report_fixture_error", + "Report mentioned 203.0.113.77 and secret-report-detail."), + ], + }; + + var privateJson = JsonOutput.Serialize(report); + var redactedJson = JsonOutput.Serialize(RemoteAuditRedactor.Redact(report)); + + Assert.Contains("secret-host-detail", privateJson, StringComparison.Ordinal); + Assert.Contains("secret-port-detail", privateJson, StringComparison.Ordinal); + Assert.Contains("secret-advisory-detail", privateJson, StringComparison.Ordinal); + Assert.Contains("secret-assessment-detail", privateJson, StringComparison.Ordinal); + Assert.Contains("secret-report-detail", privateJson, StringComparison.Ordinal); + Assert.Contains("secret-allow-detail", privateJson, StringComparison.Ordinal); + Assert.DoesNotContain("private.example", redactedJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("203.0.113.77", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret-host-detail", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret-port-detail", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret-advisory-detail", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret-assessment-detail", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret-report-detail", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret-allow-detail", redactedJson, StringComparison.Ordinal); + Assert.Contains("host_fixture_error", redactedJson, StringComparison.Ordinal); + Assert.Contains("port_fixture_error", redactedJson, StringComparison.Ordinal); + Assert.Contains("nvd_fixture_error", redactedJson, StringComparison.Ordinal); + Assert.Contains("assessment_fixture_error", redactedJson, StringComparison.Ordinal); + Assert.Contains("report_fixture_error", redactedJson, StringComparison.Ordinal); + } + + [Fact] + public async Task AssessAsync_RejectsPlanThatCannotFitBoundedInMemoryReport() + { + var service = new RemoteAuditService( + new FixedHostScanner(RemoteProductConfidence.BannerPattern), + new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.NotRequested, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.OfflineNetworkMode, + null, + [], + []))); + var targets = Enumerable.Range(0, 1001) + .Select(index => $"192.0.{index / 256}.{index % 256}") + .ToArray(); + var options = Options(targets, online: false) with + { + Ports = Enumerable.Range(1, 1000).ToArray(), + }; + + var error = await Assert.ThrowsAsync(() => + service.AssessAsync(options, CancellationToken.None)); + + Assert.Contains("1,000,000", error.Message, StringComparison.Ordinal); + } + + private static RemoteAdvisoryResult CompleteAdvisoryResultWithMatch() + { + const string cpe = "cpe:2.3:a:openbsd:openssh:9.6:p1:*:*:*:*:*:*"; + var applicability = new RemoteAdvisoryApplicability( + RemoteAdvisoryApplicabilityDisposition.DirectCandidate, + true, + false, + [ + new( + "OR", + false, + [ + new( + "OR", + false, + [ + new( + true, + cpe, + "00000000-0000-0000-0000-000000000001", + null, + null, + null, + null, + RemoteAdvisoryCpeAlignment.Proven, + true, + false), + ]), + ]), + ], + ["Candidate association only."]); + var match = new RemoteAdvisoryMatch( + "CVE-2026-42424", + "candidate", + "remote_banner_match", + "OpenSSH", + "9.6p1", + cpe, + "SSH-2.0-OpenSSH_9.6p1", + RemoteAdvisoryConfidence.Strong, + "Analyzed", + DateTimeOffset.UnixEpoch, + applicability, + RemoteAdvisorySeverity.High, + "nvd@nist.gov/CVSS:3.1", + "Fixture candidate.", + ["https://example.invalid/reference"], + false, + "not_assessed"); + return new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [match], + []); + } + + private static int CountOccurrences(string value, string needle) + { + var count = 0; + var startIndex = 0; + while ((startIndex = value.IndexOf(needle, startIndex, StringComparison.Ordinal)) >= 0) + { + count++; + startIndex += needle.Length; + } + + return count; + } + + private static RemoteAuditOptions Options(IReadOnlyList targets, bool online) => + new( + "test", + new(string.Join(',', targets), targets, targets.Count > 1), + [22], + ProbeDepth.Passive, + true, + online, + 8, + 100, + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(100), + null); + + private sealed class FixedHostScanner( + RemoteProductConfidence confidence, + string product = "OpenSSH", + string version = "9.6p1", + string evidence = "SSH-2.0-OpenSSH_9.6p1") : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var candidate = new RemoteProductCandidate( + product, + version, + confidence, + confidence == RemoteProductConfidence.BannerPattern + ? "passive-greeting" + : "passive-http-head:server", + evidence); + return Task.FromResult(new RemoteHostReport( + options.Target, + ["192.0.2.99"], + [ + new( + "192.0.2.99", + "ipv4", + 22, + RemotePortState.Open, + 1, + [ + new( + RemoteFingerprintKind.Ssh, + "ssh", + RemoteFingerprintConfidence.ProtocolConfirmed, + "passive-greeting", + evidence, + RemoteFingerprint.ReadOnlyAttributes()), + ], + [candidate], + []), + ], + [])); + } + } + + private sealed class DistinctIdentityHostScanner : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var separator = options.Target.IndexOf('-', StringComparison.Ordinal); + var suffixEnd = options.Target.IndexOf('.', separator); + var suffix = options.Target[(separator + 1)..suffixEnd]; + var version = $"9.{int.Parse(suffix, System.Globalization.CultureInfo.InvariantCulture)}p1"; + var evidence = $"SSH-2.0-OpenSSH_{version}"; + return Task.FromResult(new RemoteHostReport( + options.Target, + ["192.0.2.99"], + [ + new( + "192.0.2.99", + "ipv4", + 22, + RemotePortState.Open, + 1, + [], + [new("OpenSSH", version, RemoteProductConfidence.BannerPattern, "passive-greeting", evidence)], + []), + ], + [])); + } + } + + private sealed class FixedPortStateHostScanner(RemotePortState state) : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new RemoteHostReport( + options.Target, + ["192.0.2.99"], + [new("192.0.2.99", "ipv4", 22, state, 100, [], [], [])], + [])); + } + } + + private sealed class LeakyDiagnosticHostScanner : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + const string address = "203.0.113.77"; + const string evidence = "SSH-2.0-OpenSSH_9.6p1"; + return Task.FromResult(new RemoteHostReport( + options.Target, + [address], + [ + new( + address, + "ipv4", + 22, + RemotePortState.Open, + 1, + [ + new( + RemoteFingerprintKind.Ssh, + "ssh", + RemoteFingerprintConfidence.ProtocolConfirmed, + "passive-greeting", + evidence, + RemoteFingerprint.ReadOnlyAttributes(new Dictionary + { + ["allow"] = "GET, secret-allow-detail", + ["protocolVersion"] = "2.0", + })), + ], + [new( + "OpenSSH", + "9.6p1", + RemoteProductConfidence.BannerPattern, + "passive-greeting", + evidence)], + [new( + "port_fixture_error", + "Socket failure at 203.0.113.77:22 secret-port-detail.")]), + ], + [new( + "host_fixture_error", + "Target private.example failed with secret-host-detail.")])); + } + } + + private sealed class FixedAdvisoryClient(RemoteAdvisoryResult result) : IRemoteAdvisoryClient + { + private int callCount; + + internal int CallCount => Volatile.Read(ref callCount); + + public Task EnrichAsync( + RemoteAdvisoryRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref callCount); + return Task.FromResult(result); + } + } +} diff --git a/tests/PortCVE.Tests/RemoteCliTests.cs b/tests/PortCVE.Tests/RemoteCliTests.cs new file mode 100644 index 0000000..81ad3c8 --- /dev/null +++ b/tests/PortCVE.Tests/RemoteCliTests.cs @@ -0,0 +1,450 @@ +using PortCVE.Cli; +using PortCVE.Collection; +using PortCVE.Domain; +using PortCVE.Remote; +using PortCVE.Remote.Advisories; +using PortCVE.Snapshots; +using PortCVE.Vulnerabilities; + +namespace PortCVE.Tests; + +public sealed class RemoteCliTests +{ + [Fact] + public async Task ScanHost_DefaultJsonRedactsNetworkIdentityAndReturnsSuccess() + { + var application = Application( + new FixedHostScanner(includeStrongIdentity: false), + new NeverAdvisoryClient()); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + Json: true, + RemoteTarget: "private.internal", + RemotePorts: "22", + Authorized: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("\"schema_version\": 1", output.ToString(), StringComparison.Ordinal); + Assert.Contains("target-001", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("private.internal", output.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Empty(error.ToString()); + } + + [Fact] + public async Task ScanHost_RuntimeRejectsFailOnWithoutOnlineAdvisories() + { + var scanner = new FixedHostScanner(includeStrongIdentity: true); + var application = Application(scanner, new NeverAdvisoryClient()); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + RemoteTarget: "192.0.2.10", + RemotePorts: "22", + Authorized: true, + FailOn: VulnerabilitySeverity.High), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.UsageOrSchema, exitCode); + Assert.Contains("requires --online-advisories", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanHost_OnlineProviderFailureReturnsIncompleteEvidence() + { + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Unavailable, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + null, + [], + [new("nvd_timeout", "Fixture timeout.")])); + var application = Application(new FixedHostScanner(includeStrongIdentity: true), advisory); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + RemoteTarget: "192.0.2.10", + RemotePorts: "22", + Authorized: true, + OnlineAdvisories: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Equal(1, advisory.CallCount); + Assert.Contains("nvd_timeout", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanHost_FailOnCannotPassWithPartialAdvisoryEvidence() + { + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Partial, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UtcNow, + [], + [new("nvd_status_partial", "Fixture evidence is partial.")])); + var application = Application(new FixedHostScanner(includeStrongIdentity: true), advisory); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + RemoteTarget: "192.0.2.10", + RemotePorts: "22", + Authorized: true, + OnlineAdvisories: true, + FailOn: VulnerabilitySeverity.High), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Equal(1, advisory.CallCount); + } + + [Theory] + [InlineData("timed_out", true)] + [InlineData("unreachable", false)] + public async Task ScanHost_InconclusiveEndpointCannotPassStrictOrFailOn( + string stateName, + bool strict) + { + var state = stateName switch + { + "timed_out" => RemotePortState.TimedOut, + "unreachable" => RemotePortState.Unreachable, + _ => throw new ArgumentOutOfRangeException(nameof(stateName)), + }; + var application = Application( + new FixedPortStateHostScanner(state), + new NeverAdvisoryClient()); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + Json: true, + Strict: strict, + RemoteTarget: "192.0.2.10", + RemotePorts: "22", + Authorized: true, + OnlineAdvisories: true, + FailOn: strict ? null : VulnerabilitySeverity.High), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Contains("remote_endpoints_incomplete", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanHost_RuntimeGuardRejectsMissingAuthorization() + { + var application = Application( + new FixedHostScanner(includeStrongIdentity: false), + new NeverAdvisoryClient()); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new(CommandKind.ScanHost, RemoteTarget: "192.0.2.10", RemotePorts: "22"), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.UsageOrSchema, exitCode); + Assert.Contains("--authorized", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanHost_RejectsNetworkOutputBeforeAnyTargetConnection() + { + var application = Application(new NeverHostScanner(), new NeverAdvisoryClient()); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + OutputPath: "\\\\fixture.invalid\\share\\remote.json", + RemoteTarget: "192.0.2.10", + RemotePorts: "22", + Authorized: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.UsageOrSchema, exitCode); + Assert.Contains("remote_output_path_network", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanHost_ResolvedTargetWithNoProbedEndpointsReturnsIncompleteEvidence() + { + var application = Application(new EndpointLimitHostScanner(), new NeverAdvisoryClient()); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + Json: true, + RemoteTarget: "many-addresses.internal", + RemotePorts: "all", + Authorized: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Contains("scan_endpoint_limit_exceeded", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanHost_StrictDistinctIdentityCapReturnsIncompleteEvidence() + { + var advisory = new FixedAdvisoryClient(new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [], + [])); + var application = Application( + new ManyIdentityHostScanner(RemoteAuditService.MaximumUniqueAdvisoryIdentities + 1), + advisory); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + Json: true, + Strict: true, + RemoteTarget: "192.0.2.10", + RemotePorts: "22", + Authorized: true, + OnlineAdvisories: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Equal(RemoteAuditService.MaximumUniqueAdvisoryIdentities, advisory.CallCount); + Assert.Contains("\"advisory_status\": \"partial\"", output.ToString(), StringComparison.Ordinal); + Assert.Contains("remote_advisory_identity_limit_exceeded", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanHost_FailOnUsesNormalizedDirectMatches() + { + var advisory = new FixedAdvisoryClient(CompleteHighAdvisoryResult()); + var application = Application( + new FixedHostScanner(includeStrongIdentity: true), + advisory); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.ScanHost, + Json: true, + FailOn: VulnerabilitySeverity.High, + RemoteTarget: "192.0.2.10", + RemotePorts: "22", + Authorized: true, + OnlineAdvisories: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.NegativeResult, exitCode); + Assert.Equal(1, advisory.CallCount); + Assert.Contains("CVE-2026-51515", output.ToString(), StringComparison.Ordinal); + } + + private static RemoteAdvisoryResult CompleteHighAdvisoryResult() + { + const string cpe = "cpe:2.3:a:openbsd:openssh:9.6:p1:*:*:*:*:*:*"; + var applicability = new RemoteAdvisoryApplicability( + RemoteAdvisoryApplicabilityDisposition.DirectCandidate, + true, + false, + [], + ["Candidate association only."]); + var match = new RemoteAdvisoryMatch( + "CVE-2026-51515", + "candidate", + "remote_banner_match", + "OpenSSH", + "9.6p1", + cpe, + "SSH-2.0-OpenSSH_9.6p1", + RemoteAdvisoryConfidence.Strong, + "Analyzed", + DateTimeOffset.UnixEpoch, + applicability, + RemoteAdvisorySeverity.High, + "nvd@nist.gov/CVSS:3.1", + "Fixture candidate.", + [], + false, + "not_assessed"); + return new( + RemoteAdvisoryStatus.Complete, + RemoteAdvisoryResult.ProviderName, + RemoteAdvisoryResult.ExplicitOnlineNetworkMode, + DateTimeOffset.UnixEpoch, + [match], + []); + } + + private static CliApplication Application( + IRemoteHostScanner hostScanner, + IRemoteAdvisoryClient advisoryClient) => + new( + new UnusedSnapshotBuilder(), + new LockfileService(), + new VulnerabilityAssessmentTests.FixedScanner(VulnerabilityAssessmentTests.CompleteResult()), + static path => new(true, Path.GetFullPath(path), "ok", "fixture"), + hostScanner, + advisoryClient, + static () => null); + + private sealed class FixedHostScanner(bool includeStrongIdentity) : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + IReadOnlyList candidates = includeStrongIdentity + ? [new("OpenSSH", "9.6p1", RemoteProductConfidence.BannerPattern, "passive-greeting", "SSH-2.0-OpenSSH_9.6p1")] + : []; + return Task.FromResult(new RemoteHostReport( + options.Target, + ["192.0.2.10"], + [new("192.0.2.10", "ipv4", 22, RemotePortState.Open, 1, [], candidates, [])], + [])); + } + } + + private sealed class NeverAdvisoryClient : IRemoteAdvisoryClient + { + public Task EnrichAsync( + RemoteAdvisoryRequest request, + CancellationToken cancellationToken) => + throw new InvalidOperationException("NVD must not be called in this fixture."); + } + + private sealed class FixedPortStateHostScanner(RemotePortState state) : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new RemoteHostReport( + options.Target, + ["192.0.2.10"], + [new("192.0.2.10", "ipv4", 22, state, 100, [], [], [])], + [])); + } + } + + private sealed class NeverHostScanner : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) => + throw new InvalidOperationException("The target scanner must not run in this fixture."); + } + + private sealed class EndpointLimitHostScanner : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new RemoteHostReport( + options.Target, + ["192.0.2.10", "192.0.2.11", "192.0.2.12", "192.0.2.13", "192.0.2.14"], + [], + [new( + "scan_endpoint_limit_exceeded", + "The frozen address and port set exceeded the endpoint limit.")])); + } + } + + private sealed class ManyIdentityHostScanner(int identityCount) : IRemoteHostScanner + { + public Task ScanAsync( + RemoteScanOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var candidates = Enumerable.Range(0, identityCount) + .Select(index => + { + var version = $"9.{index}p1"; + return new RemoteProductCandidate( + "OpenSSH", + version, + RemoteProductConfidence.BannerPattern, + "passive-greeting", + $"SSH-2.0-OpenSSH_{version}"); + }) + .ToArray(); + return Task.FromResult(new RemoteHostReport( + options.Target, + ["192.0.2.10"], + [new("192.0.2.10", "ipv4", 22, RemotePortState.Open, 1, [], candidates, [])], + [])); + } + } + + private sealed class FixedAdvisoryClient(RemoteAdvisoryResult result) : IRemoteAdvisoryClient + { + private int callCount; + + internal int CallCount => Volatile.Read(ref callCount); + + public Task EnrichAsync( + RemoteAdvisoryRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref callCount); + return Task.FromResult(result); + } + } + + private sealed class UnusedSnapshotBuilder : ISnapshotBuilder + { + public Task CollectAsync( + SnapshotOptions options, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Local snapshot collection is not part of remote scans."); + } +} diff --git a/tests/PortCVE.Tests/RemoteFingerprintParserTests.cs b/tests/PortCVE.Tests/RemoteFingerprintParserTests.cs new file mode 100644 index 0000000..b2e4933 --- /dev/null +++ b/tests/PortCVE.Tests/RemoteFingerprintParserTests.cs @@ -0,0 +1,215 @@ +using PortCVE.Remote; + +namespace PortCVE.Tests; + +public sealed class RemoteFingerprintParserTests +{ + [Fact] + public void GreetingParser_ExtractsStrongSshProductAndVersionEvidence() + { + var result = RemoteFingerprintParser.AnalyzeGreeting( + "SSH-2.0-OpenSSH_9.9p1 Debian-3\r\n", + maximumEvidenceBytes: 1_024); + + var fingerprint = Assert.Single(result.Fingerprints); + Assert.Equal(RemoteFingerprintKind.Ssh, fingerprint.Kind); + Assert.Equal(RemoteFingerprintConfidence.ProtocolConfirmed, fingerprint.Confidence); + Assert.Equal("2.0", fingerprint.Attributes["protocolVersion"]); + var candidate = Assert.Single(result.ProductCandidates); + Assert.Equal("OpenSSH", candidate.Product); + Assert.Equal("9.9p1", candidate.Version); + Assert.Equal(RemoteProductConfidence.BannerPattern, candidate.Confidence); + Assert.Equal("passive-greeting", candidate.Source); + } + + [Theory] + [InlineData("SSH-2.0-dropbear_2020.81", "ssh", "Dropbear SSH", "2020.81")] + [InlineData("220 ProFTPD 1.3.8a Server (example) [192.0.2.1]", "ftp", "ProFTPD", "1.3.8a")] + [InlineData("220 (vsFTPd 3.0.3)", "ftp", "vsftpd", "3.0.3")] + [InlineData("220 mail.example ESMTP Exim 4.98 ready", "smtp", "Exim", "4.98")] + [InlineData("+OK Dovecot 2.3.21 POP3 ready", "pop3", "Dovecot", "2.3.21")] + [InlineData("* OK Dovecot 2.3.21 IMAP ready", "imap", "Dovecot", "2.3.21")] + public void GreetingParser_RecognizesOnlyEvidenceBackedServices( + string banner, + string service, + string product, + string version) + { + var result = RemoteFingerprintParser.AnalyzeGreeting(banner, 1_024); + + Assert.Equal(service, Assert.Single(result.Fingerprints).Service); + var candidate = Assert.Single(result.ProductCandidates); + Assert.Equal(product, candidate.Product); + Assert.Equal(version, candidate.Version); + } + + [Theory] + [InlineData("SSH-2.0-vendor-dropbear_2020.81")] + [InlineData("SSH-2.0-dropbear_2020.81-custom")] + [InlineData("SSH-2.0-vendor-OpenSSH_9.9p1")] + [InlineData("220 ftp.example FTP server (ProFTPD 1.3.8a) ready")] + [InlineData("220 ProFTPD 1.3.8a ready")] + [InlineData("220 ProFTPD 1.3.8rc4 Server (example) [192.0.2.1]")] + [InlineData("220 welcome (vsFTPd 3.0.3)")] + [InlineData("220 (vsFTPd 3.0.3-custom)")] + [InlineData("220 mail.example ESMTP gateway Exim 4.98.2")] + [InlineData("220 mail.example ESMTP Exim 4.98.2-custom")] + public void GreetingParser_CatalogProductsRequireCanonicalProtocolBoundGrammar(string banner) + { + var result = RemoteFingerprintParser.AnalyzeGreeting(banner + "\r\n", 1_024); + + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public void GreetingParser_DoesNotTurnGenericPortStyleTextIntoAProductClaim() + { + var result = RemoteFingerprintParser.AnalyzeGreeting( + "220 service ready\r\n", + maximumEvidenceBytes: 1_024); + + var fingerprint = Assert.Single(result.Fingerprints); + Assert.Equal(RemoteFingerprintKind.Greeting, fingerprint.Kind); + Assert.Equal("unknown", fingerprint.Service); + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public void GreetingParser_DoesNotChooseBetweenConflictingFtpAndSmtpMarkers() + { + var result = RemoteFingerprintParser.AnalyzeGreeting( + "220 ProFTPD 1.3.8 ESMTP Exim 4.98 ready\r\n", + maximumEvidenceBytes: 1_024); + + var fingerprint = Assert.Single(result.Fingerprints); + Assert.Equal(RemoteFingerprintKind.Greeting, fingerprint.Kind); + Assert.Equal("unknown", fingerprint.Service); + } + + [Fact] + public void HttpParser_ExtractsStatusSelectedHeadersAndReportedProducts() + { + const string response = "HTTP/1.1 302 Found\r\n" + + "Server: nginx/1.27.4\r\n" + + "X-Powered-By: PHP/8.3.10\r\n" + + "Location: https://elsewhere.example/\r\n" + + "Set-Cookie: should-not-be-evidence\r\n\r\n" + + "body-must-not-be-parsed"; + + var result = RemoteFingerprintParser.AnalyzeHttpResponse( + response, + RemoteFingerprintKind.Http, + "passive-http-head", + maximumEvidenceBytes: 2_048); + + var fingerprint = Assert.Single(result.Fingerprints); + Assert.Equal("302", fingerprint.Attributes["statusCode"]); + Assert.Equal("true", fingerprint.Attributes["headersComplete"]); + Assert.Equal("https://elsewhere.example/", fingerprint.Attributes["location"]); + Assert.DoesNotContain("Set-Cookie", fingerprint.Evidence, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("body-must-not-be-parsed", fingerprint.Evidence, StringComparison.Ordinal); + Assert.Collection( + result.ProductCandidates.OrderBy(static candidate => candidate.Product), + nginx => + { + Assert.Equal("nginx", nginx.Product); + Assert.Equal("1.27.4", nginx.Version); + Assert.Equal(RemoteProductConfidence.HeaderReported, nginx.Confidence); + }, + php => + { + Assert.Equal("PHP", php.Product); + Assert.Equal("8.3.10", php.Version); + Assert.Equal(RemoteProductConfidence.HeaderReported, php.Confidence); + }); + } + + [Fact] + public void HttpParser_RejectsMalformedOrNonHttpResponses() + { + var result = RemoteFingerprintParser.AnalyzeHttpResponse( + "SSH-2.0-OpenSSH_9.9\r\nServer: nginx/1.2.3\r\n\r\n", + RemoteFingerprintKind.Http, + "passive-http-head", + maximumEvidenceBytes: 1_024); + + Assert.Empty(result.Fingerprints); + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public void HttpParser_DoesNotPromoteCatalogedNonHttpProductFromServerHeader() + { + var result = RemoteFingerprintParser.AnalyzeHttpResponse( + "HTTP/1.1 200 OK\r\nServer: Exim/4.98.2\r\n\r\n", + RemoteFingerprintKind.Http, + "passive-http-head", + maximumEvidenceBytes: 1_024); + + Assert.Single(result.Fingerprints); + Assert.Empty(result.ProductCandidates); + } + + [Theory] + [InlineData("HTTP/9 200 OK\r\nServer: nginx/1.2.3\r\n\r\n")] + [InlineData("HTTP/1 200 OK\r\nServer: nginx/1.2.3\r\n\r\n")] + [InlineData("HTTP/2.0 200 OK\r\nServer: nginx/1.2.3\r\n\r\n")] + [InlineData("HTTP/1.2 200 OK\r\nServer: nginx/1.2.3\r\n\r\n")] + public void HttpParser_RejectsUnsupportedTextualProtocolVersions(string response) + { + var result = RemoteFingerprintParser.AnalyzeHttpResponse( + response, + RemoteFingerprintKind.Http, + "passive-http-head", + maximumEvidenceBytes: 1_024); + + Assert.Empty(result.Fingerprints); + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public void GreetingParser_DoesNotPromoteAnIncompleteBannerToAProduct() + { + var result = RemoteFingerprintParser.AnalyzeGreeting( + "SSH-2.0-OpenSSH_9.9p1", + maximumEvidenceBytes: 1_024, + isComplete: false); + + var fingerprint = Assert.Single(result.Fingerprints); + Assert.Equal(RemoteFingerprintKind.Greeting, fingerprint.Kind); + Assert.Equal("false", fingerprint.Attributes["complete"]); + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public void GreetingParser_DoesNotCreateAProductByReplacingInjectedControls() + { + var result = RemoteFingerprintParser.AnalyzeGreeting( + "SSH-2.0-Other OpenSSH\09.9p1\r\n", + maximumEvidenceBytes: 1_024); + + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public void GreetingParser_BindsSshProductOnlyToTheProtocolSoftwareField() + { + var result = RemoteFingerprintParser.AnalyzeGreeting( + "SSH-2.0-Unrelated_1.0 comment OpenSSH_9.9p1\r\n", + maximumEvidenceBytes: 1_024); + + Assert.Equal(RemoteFingerprintKind.Ssh, Assert.Single(result.Fingerprints).Kind); + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public void GreetingParser_DoesNotMapAProductPatternFromAnUnrelatedProtocol() + { + var result = RemoteFingerprintParser.AnalyzeGreeting( + "220 generic service OpenSSH_9.9p1 ready\r\n", + maximumEvidenceBytes: 1_024); + + Assert.Equal(RemoteFingerprintKind.Greeting, Assert.Single(result.Fingerprints).Kind); + Assert.Empty(result.ProductCandidates); + } +} diff --git a/tests/PortCVE.Tests/RemoteHostScannerTests.cs b/tests/PortCVE.Tests/RemoteHostScannerTests.cs new file mode 100644 index 0000000..3095d07 --- /dev/null +++ b/tests/PortCVE.Tests/RemoteHostScannerTests.cs @@ -0,0 +1,1135 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using PortCVE.Remote; + +namespace PortCVE.Tests; + +public sealed class RemoteHostScannerTests +{ + [Fact] + public async Task ScanAsync_FreezesResolutionConsultsRateLimiterAndFingerprintsGreeting() + { + var listener = StartListener(IPAddress.Loopback); + using var serverCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var server = ServeGreetingAsync( + listener, + "SSH-2.0-OpenSSH_9.9p1 Test\0Control\r\n", + serverCancellation.Token); + var resolver = new RecordingDnsResolver(IPAddress.Loopback); + var limiter = new RecordingRateLimiter(); + var requestedRate = 0; + var scanner = new RemoteHostScanner( + resolver, + NoConventionalProbes(), + rate => + { + requestedRate = rate; + return limiter; + }); + var options = Options( + "scanner.test", + ListenerPort(listener), + maxConnectionsPerSecond: 321); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync(options, serverCancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.Equal(1, resolver.CallCount); + Assert.Equal("scanner.test", resolver.LastTarget); + Assert.Equal(321, requestedRate); + Assert.Equal(1, limiter.WaitCount); + Assert.Equal(["127.0.0.1"], report.ResolvedAddresses); + var port = Assert.Single(report.Ports); + Assert.Equal(RemotePortState.Open, port.State); + Assert.Equal("ipv4", port.AddressFamily); + Assert.Equal(RemoteFingerprintKind.Ssh, Assert.Single(port.Fingerprints).Kind); + Assert.DoesNotContain(port.Fingerprints[0].Evidence, static character => char.IsControl(character)); + var product = Assert.Single(port.ProductCandidates); + Assert.Equal("OpenSSH", product.Product); + Assert.Equal("9.9p1", product.Version); + } + + [Theory] + [InlineData("SSH-2.0-dropbear_2020.81\r\n", "Dropbear SSH", "2020.81")] + [InlineData("220 ProFTPD 1.3.8a Server (fixture) [127.0.0.1]\r\n", "ProFTPD", "1.3.8a")] + [InlineData("220 (vsFTPd 3.0.3)\r\n", "vsftpd", "3.0.3")] + [InlineData("220 mail.example ESMTP Exim 4.98.2 Sun, 10 Aug 2026 00:00:00 +0000\r\n", "Exim", "4.98.2")] + public async Task ScanAsync_LocalGreetingFixtureRetainsProtocolBoundCatalogIdentity( + string greeting, + string expectedProduct, + string expectedVersion) + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var server = ServeGreetingAsync(listener, greeting, cancellation.Token); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options("catalog-fixture.test", ListenerPort(listener)), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + var product = Assert.Single(Assert.Single(report.Ports).ProductCandidates); + Assert.Equal(expectedProduct, product.Product); + Assert.Equal(expectedVersion, product.Version); + Assert.Equal(RemoteProductConfidence.BannerPattern, product.Confidence); + Assert.Equal("passive-greeting", product.Source); + } + + [Fact] + public async Task ScanAsync_HttpHeadDoesNotFollowRedirectOrRetainBody() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + string? requestLine = null; + var server = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(cancellation.Token); + var request = await ReadRequestHeadersAsync(client.GetStream(), cancellation.Token); + requestLine = FirstLine(request); + var response = Encoding.ASCII.GetBytes( + "HTTP/1.1 302 Found\r\n" + + "Server: nginx/1.27.4\r\n" + + "Location: https://redirect.invalid/\r\n" + + "Content-Length: 18\r\n\r\n" + + "sensitive-body-data"); + await client.GetStream().WriteAsync(response, cancellation.Token); + }, cancellation.Token); + var port = ListenerPort(listener); + var limiter = new RecordingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + new RemoteProbePolicy([port], [], []), + _ => limiter); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync(Options("web.test", port), cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.Equal("HEAD / HTTP/1.1", requestLine); + Assert.Equal(1, limiter.WaitCount); + var result = Assert.Single(report.Ports); + var http = Assert.Single(result.Fingerprints); + Assert.Equal(RemoteFingerprintKind.Http, http.Kind); + Assert.Equal("302", http.Attributes["statusCode"]); + Assert.DoesNotContain("sensitive-body-data", http.Evidence, StringComparison.Ordinal); + var product = Assert.Single(result.ProductCandidates); + Assert.Equal("nginx", product.Product); + Assert.Equal("1.27.4", product.Version); + } + + [Fact] + public async Task ScanAsync_ActiveHttpUsesOnlyBoundedSafeMethodsAndEndpoints() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var requestLines = new ConcurrentQueue(); + var server = ServeHttpConnectionsAsync( + listener, + connectionCount: 4, + requestLines, + cancellation.Token); + var port = ListenerPort(listener); + var limiter = new RecordingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + new RemoteProbePolicy([port], [], []), + _ => limiter); + var options = Options("active.test", port, ProbeDepth.Active, maximumEvidenceBytes: 8_192); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync(options, cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.Equal( + [ + "HEAD / HTTP/1.1", + "OPTIONS / HTTP/1.1", + "HEAD /robots.txt HTTP/1.1", + "HEAD /.well-known/security.txt HTTP/1.1", + ], + requestLines.ToArray()); + Assert.Equal(4, limiter.WaitCount); + var result = Assert.Single(report.Ports); + Assert.Equal(4, result.Fingerprints.Count); + Assert.Contains(result.Fingerprints, static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.HttpOptions); + Assert.Equal(2, result.Fingerprints.Count(static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.HttpEndpoint)); + Assert.DoesNotContain(requestLines, static line => + line.StartsWith("GET ", StringComparison.Ordinal) + || line.StartsWith("POST ", StringComparison.Ordinal) + || line.StartsWith("PUT ", StringComparison.Ordinal)); + } + + [Fact] + public async Task ScanAsync_PassiveUnknownPortReadsGreetingWithoutSendingProtocolProbes() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var received = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(cancellation.Token); + return await ReadUntilPeerClosesAsync(client.GetStream(), cancellation.Token); + }, cancellation.Token); + var limiter = new RecordingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => limiter); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options( + "passive-unknown.test", + ListenerPort(listener), + readTimeout: TimeSpan.FromMilliseconds(150)), + cancellation.Token); + Assert.Empty(await received); + } + finally + { + listener.Stop(); + } + + Assert.Equal(1, limiter.WaitCount); + var result = Assert.Single(report.Ports); + Assert.Equal(RemotePortState.Open, result.State); + Assert.Empty(result.Fingerprints); + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public async Task ScanAsync_ActiveUnknownGreetingDoesNotTriggerCrossProtocolOrProductClaims() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var server = ServeGreetingAsync( + listener, + "NOTICE Server: nginx/1.27.4\r\n", + cancellation.Token); + var limiter = new RecordingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => limiter); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options( + "unknown-greeting.test", + ListenerPort(listener), + ProbeDepth.Active, + readTimeout: TimeSpan.FromMilliseconds(200)), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.Equal(1, limiter.WaitCount); + var result = Assert.Single(report.Ports); + var greeting = Assert.Single(result.Fingerprints); + Assert.Equal(RemoteFingerprintKind.Greeting, greeting.Kind); + Assert.Equal("unknown", greeting.Service); + Assert.Empty(result.ProductCandidates); + } + + [Fact] + public async Task ScanAsync_ActiveFallbackDiscoversHttpOnAnArbitraryPort() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var requestLines = new ConcurrentQueue(); + var firstConnectionBytes = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var server = Task.Run(async () => + { + using (var greetingClient = await listener.AcceptTcpClientAsync(cancellation.Token)) + { + firstConnectionBytes.SetResult(await ReadUntilPeerClosesAsync( + greetingClient.GetStream(), + cancellation.Token)); + } + + using var httpClient = await listener.AcceptTcpClientAsync(cancellation.Token); + var stream = httpClient.GetStream(); + var request = await ReadRequestHeadersAsync(stream, cancellation.Token); + requestLines.Enqueue(FirstLine(request)); + await stream.WriteAsync( + Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\nServer: nginx/1.27.4\r\nContent-Length: 0\r\n\r\n"), + cancellation.Token); + }, cancellation.Token); + var limiter = new RecordingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => limiter); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options( + "adaptive-http.test", + ListenerPort(listener), + ProbeDepth.Active, + readTimeout: TimeSpan.FromMilliseconds(200)), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.Empty(await firstConnectionBytes.Task); + Assert.Equal(["HEAD / HTTP/1.1"], requestLines.ToArray()); + Assert.Equal(2, limiter.WaitCount); + var result = Assert.Single(report.Ports); + var http = Assert.Single(result.Fingerprints); + Assert.Equal(RemoteFingerprintKind.Http, http.Kind); + Assert.Equal("active-adaptive-http-head", http.Source); + var product = Assert.Single(result.ProductCandidates); + Assert.Equal("nginx", product.Product); + Assert.Equal("1.27.4", product.Version); + } + + [Fact] + public async Task ScanAsync_ActiveFallbackDiscoversTlsAndAlpnBoundHttpsOnAnArbitraryPort() + { + using var certificate = CreateServerCertificate(); + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var clearRequestLines = new ConcurrentQueue(); + var tlsRequestLines = new ConcurrentQueue(); + var firstConnectionBytes = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var server = ServeAdaptiveHttpsConnectionsAsync( + listener, + certificate, + firstConnectionBytes, + clearRequestLines, + tlsRequestLines, + cancellation.Token); + var limiter = new RecordingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => limiter); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options( + "localhost", + ListenerPort(listener), + ProbeDepth.Active, + readTimeout: TimeSpan.FromMilliseconds(500)), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.Empty(await firstConnectionBytes.Task); + Assert.Equal(["HEAD / HTTP/1.1"], clearRequestLines.ToArray()); + Assert.Equal(["HEAD / HTTP/1.1"], tlsRequestLines.ToArray()); + Assert.Equal(3, limiter.WaitCount); + var result = Assert.Single(report.Ports); + var tls = Assert.Single(result.Fingerprints, static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.Tls); + Assert.Equal("active-adaptive-tls-handshake", tls.Source); + Assert.Equal("http/1.1", tls.Attributes["applicationProtocol"]); + var http = Assert.Single(result.Fingerprints, static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.Http); + Assert.Equal("active-adaptive-https-head", http.Source); + var product = Assert.Single(result.ProductCandidates); + Assert.Equal("Caddy", product.Product); + Assert.Equal("2.8.4", product.Version); + } + + [Fact] + public async Task ScanAsync_MarksAnUnterminatedHttpHeaderBlockIncomplete() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var server = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(cancellation.Token); + _ = await ReadRequestHeadersAsync(client.GetStream(), cancellation.Token); + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nServer: nginx/1.27.4\r\n"), + cancellation.Token); + }, cancellation.Token); + var port = ListenerPort(listener); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + new RemoteProbePolicy([port], [], []), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync(Options("incomplete.test", port), cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + var result = Assert.Single(report.Ports); + var http = Assert.Single(result.Fingerprints); + Assert.Equal("false", http.Attributes["headersComplete"]); + Assert.Contains(result.Diagnostics, static diagnostic => + diagnostic.Code == "http_headers_incomplete"); + } + + [Fact] + public async Task ScanAsync_DoesNotPromoteAnUnterminatedSshGreeting() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var server = ServeGreetingAsync( + listener, + "SSH-2.0-OpenSSH_9.9p1", + cancellation.Token); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options("incomplete-ssh.test", ListenerPort(listener)), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + var result = Assert.Single(report.Ports); + Assert.Equal(RemoteFingerprintKind.Greeting, Assert.Single(result.Fingerprints).Kind); + Assert.Empty(result.ProductCandidates); + Assert.Contains(result.Diagnostics, static diagnostic => + diagnostic.Code == "greeting_incomplete"); + } + + [Fact] + public async Task ScanAsync_PartialGreetingThatStallsSuppressesAdaptiveProbes() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var followupConnections = 0; + var server = Task.Run(async () => + { + using (var greetingClient = await listener.AcceptTcpClientAsync(cancellation.Token)) + { + await greetingClient.GetStream().WriteAsync( + Encoding.ASCII.GetBytes("SSH-"), + cancellation.Token); + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellation.Token); + } + + using var followupCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellation.Token); + followupCancellation.CancelAfter(TimeSpan.FromMilliseconds(500)); + try + { + using var followup = await listener.AcceptTcpClientAsync(followupCancellation.Token); + Interlocked.Increment(ref followupConnections); + } + catch (OperationCanceledException) when (!cancellation.IsCancellationRequested) + { + // No second connection is the expected safe behavior. + } + }, cancellation.Token); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options( + "slow-partial-greeting.test", + ListenerPort(listener), + ProbeDepth.Active, + readTimeout: TimeSpan.FromMilliseconds(100)), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + var result = Assert.Single(report.Ports); + Assert.Equal(RemoteFingerprintKind.Greeting, Assert.Single(result.Fingerprints).Kind); + Assert.Empty(result.ProductCandidates); + Assert.Equal(0, Volatile.Read(ref followupConnections)); + Assert.Contains(result.Diagnostics, static diagnostic => + diagnostic.Code == "greeting_incomplete"); + } + + [Fact] + public async Task ScanAsync_TlsReportsCertificateAndHttpsHeaderEvidenceWithoutTrustClaim() + { + using var certificate = CreateServerCertificate(); + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var requestLines = new ConcurrentQueue(); + var server = ServeTlsConnectionsAsync( + listener, + certificate, + connectionCount: 1, + requestLines, + cancellation.Token); + var port = ListenerPort(listener); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + new RemoteProbePolicy([], [port], [port]), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync(Options("localhost", port), cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.True( + requestLines.SequenceEqual(["HEAD / HTTP/1.1"]), + string.Join(" | ", requestLines.Concat( + report.Ports.SelectMany(static result => result.Diagnostics) + .Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}")))); + var result = Assert.Single(report.Ports); + var tls = Assert.Single(result.Fingerprints, static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.Tls); + Assert.Equal(certificate.GetCertHashString(HashAlgorithmName.SHA256), + tls.Attributes["certificateSha256"]); + Assert.Equal("tls", tls.Service); + Assert.True(tls.Attributes.ContainsKey("certificatePolicyErrors")); + var http = Assert.Single(result.Fingerprints, static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.Http); + Assert.Equal("200", http.Attributes["statusCode"]); + var product = Assert.Single(result.ProductCandidates); + Assert.Equal("Caddy", product.Product); + Assert.Equal("2.8.4", product.Version); + } + + [Fact] + public async Task ScanAsync_ChargesTlsMetadataToThePerPortEvidenceBudget() + { + using var certificate = CreateServerCertificate(); + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var server = ServeTlsConnectionsAsync( + listener, + certificate, + connectionCount: 1, + new ConcurrentQueue(), + cancellation.Token); + var port = ListenerPort(listener); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + new RemoteProbePolicy([], [port], []), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options("localhost", port, maximumEvidenceBytes: 256), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + var result = Assert.Single(report.Ports); + var tls = Assert.Single( + result.Fingerprints, + static fingerprint => fingerprint.Kind == RemoteFingerprintKind.Tls); + var retainedBytes = Encoding.UTF8.GetByteCount(tls.Evidence) + + tls.Attributes.Values.Sum(Encoding.UTF8.GetByteCount); + Assert.InRange(retainedBytes, 1, 256); + Assert.Contains(result.Diagnostics, static diagnostic => + diagnostic.Code == "evidence_budget_truncated"); + } + + [Fact] + public async Task ScanAsync_ActiveTlsPostureAndHttpsChecksAreDistinctFromProducts() + { + using var certificate = CreateServerCertificate(); + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var requestLines = new ConcurrentQueue(); + var server = ServeTlsConnectionsAsync( + listener, + certificate, + connectionCount: 6, + requestLines, + cancellation.Token); + var port = ListenerPort(listener); + var limiter = new RecordingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + new RemoteProbePolicy([], [port], [port]), + _ => limiter); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options("localhost", port, ProbeDepth.Active, maximumEvidenceBytes: 16_384), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + Assert.Equal(6, limiter.WaitCount); + var result = Assert.Single(report.Ports); + Assert.Equal(2, result.Fingerprints.Count(static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.TlsProtocolProbe)); + Assert.Contains(result.Fingerprints, static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.HttpOptions); + Assert.Equal(2, result.Fingerprints.Count(static fingerprint => + fingerprint.Kind == RemoteFingerprintKind.HttpEndpoint)); + Assert.All(result.ProductCandidates, static candidate => + Assert.Contains("http", candidate.Source, StringComparison.OrdinalIgnoreCase)); + Assert.Equal( + [ + "HEAD / HTTP/1.1", + "OPTIONS / HTTP/1.1", + "HEAD /robots.txt HTTP/1.1", + "HEAD /.well-known/security.txt HTTP/1.1", + ], + requestLines.ToArray()); + } + + [Fact] + public async Task ScanAsync_CapsGreetingEvidenceBytes() + { + var listener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var server = ServeGreetingAsync( + listener, + new string('A', 2_048), + cancellation.Token); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options("bounded.test", ListenerPort(listener), maximumEvidenceBytes: 256), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + var evidence = Assert.Single(Assert.Single(report.Ports).Fingerprints).Evidence; + Assert.Equal(256, Encoding.UTF8.GetByteCount(evidence)); + } + + [Fact] + public async Task ScanAsync_CallerCancellationInterruptsRateWait() + { + var limiter = new BlockingRateLimiter(); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => limiter); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAnyAsync(() => scanner.ScanAsync( + Options("cancel.test", 9), + cancellation.Token)); + Assert.Equal(1, limiter.WaitCount); + } + + [Fact] + public async Task ScanAsync_ReusesOneRateLimiterAcrossSequentialTargets() + { + var firstListener = StartListener(IPAddress.Loopback); + var secondListener = StartListener(IPAddress.Loopback); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var firstServer = ServeGreetingAsync( + firstListener, + "SSH-2.0-OpenSSH_9.9\r\n", + cancellation.Token); + var secondServer = ServeGreetingAsync( + secondListener, + "SSH-2.0-OpenSSH_9.9\r\n", + cancellation.Token); + var limiter = new RecordingRateLimiter(); + var factoryCalls = 0; + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.Loopback), + NoConventionalProbes(), + _ => + { + Interlocked.Increment(ref factoryCalls); + return limiter; + }); + + try + { + var first = await scanner.ScanAsync( + Options("first.test", ListenerPort(firstListener), maxConnectionsPerSecond: 42), + cancellation.Token); + var second = await scanner.ScanAsync( + Options("second.test", ListenerPort(secondListener), maxConnectionsPerSecond: 42), + cancellation.Token); + await Task.WhenAll(firstServer, secondServer); + + Assert.Equal(RemotePortState.Open, Assert.Single(first.Ports).State); + Assert.Equal(RemotePortState.Open, Assert.Single(second.Ports).State); + } + finally + { + firstListener.Stop(); + secondListener.Stop(); + } + + Assert.Equal(1, Volatile.Read(ref factoryCalls)); + Assert.Equal(2, limiter.WaitCount); + } + + [Fact] + public async Task ScanAsync_RejectsAnExcessiveFrozenEndpointSetBeforeConnecting() + { + var addresses = Enumerable.Range(1, 5) + .Select(index => IPAddress.Parse($"192.0.2.{index}")) + .ToArray(); + var limiterFactoryCalls = 0; + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(addresses), + NoConventionalProbes(), + _ => + { + Interlocked.Increment(ref limiterFactoryCalls); + return new RecordingRateLimiter(); + }); + var options = new RemoteScanOptions( + "bounded-target.test", + Enumerable.Range(1, 65_535).ToArray(), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(100), + concurrency: 4); + + var report = await scanner.ScanAsync(options, CancellationToken.None); + + Assert.Equal(5, report.ResolvedAddresses.Count); + Assert.Empty(report.Ports); + var diagnostic = Assert.Single(report.Diagnostics); + Assert.Equal("scan_endpoint_limit_exceeded", diagnostic.Code); + Assert.Equal(0, Volatile.Read(ref limiterFactoryCalls)); + } + + [Fact] + public async Task ScanAsync_SupportsIpv6LoopbackWhenAvailable() + { + if (!Socket.OSSupportsIPv6) + { + return; + } + + TcpListener listener; + try + { + listener = StartListener(IPAddress.IPv6Loopback); + } + catch (SocketException) + { + return; + } + + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var server = ServeGreetingAsync( + listener, + "SSH-2.0-OpenSSH_9.9\r\n", + cancellation.Token); + var scanner = new RemoteHostScanner( + new RecordingDnsResolver(IPAddress.IPv6Loopback), + NoConventionalProbes(), + _ => new RecordingRateLimiter()); + + RemoteHostReport report; + try + { + report = await scanner.ScanAsync( + Options("ipv6.test", ListenerPort(listener)), + cancellation.Token); + await server; + } + finally + { + listener.Stop(); + } + + var result = Assert.Single(report.Ports); + Assert.Equal(RemotePortState.Open, result.State); + Assert.Equal("ipv6", result.AddressFamily); + Assert.Equal("::1", result.Address); + } + + private static RemoteScanOptions Options( + string target, + int port, + ProbeDepth probeDepth = ProbeDepth.Passive, + int maximumEvidenceBytes = 8_192, + int maxConnectionsPerSecond = 100, + TimeSpan? readTimeout = null) => + new( + target, + [port], + connectTimeout: TimeSpan.FromSeconds(2), + readTimeout: readTimeout ?? TimeSpan.FromSeconds(2), + concurrency: 4, + probeDepth, + maximumEvidenceBytes, + maxConnectionsPerSecond); + + private static RemoteProbePolicy NoConventionalProbes() => new([], [], []); + + private static TcpListener StartListener(IPAddress address) + { + var listener = new TcpListener(address, 0); + listener.Start(); + return listener; + } + + private static int ListenerPort(TcpListener listener) => + ((IPEndPoint)listener.LocalEndpoint).Port; + + private static async Task ServeGreetingAsync( + TcpListener listener, + string greeting, + CancellationToken cancellationToken) + { + using var client = await listener.AcceptTcpClientAsync(cancellationToken); + await client.GetStream().WriteAsync(Encoding.UTF8.GetBytes(greeting), cancellationToken); + } + + private static async Task ServeHttpConnectionsAsync( + TcpListener listener, + int connectionCount, + ConcurrentQueue requestLines, + CancellationToken cancellationToken) + { + for (var index = 0; index < connectionCount; index++) + { + using var client = await listener.AcceptTcpClientAsync(cancellationToken); + var stream = client.GetStream(); + var request = await ReadRequestHeadersAsync(stream, cancellationToken); + requestLines.Enqueue(FirstLine(request)); + var response = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\nServer: nginx/1.27.4\r\nAllow: HEAD, OPTIONS\r\nContent-Length: 0\r\n\r\n"); + await stream.WriteAsync(response, cancellationToken); + } + } + + private static async Task ServeTlsConnectionsAsync( + TcpListener listener, + X509Certificate2 certificate, + int connectionCount, + ConcurrentQueue requestLines, + CancellationToken cancellationToken) + { + for (var index = 0; index < connectionCount; index++) + { + using var client = await listener.AcceptTcpClientAsync(cancellationToken); + using var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false); + try + { + await tls.AuthenticateAsServerAsync( + new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + EnabledSslProtocols = SslProtocols.Tls12, + ClientCertificateRequired = false, + ApplicationProtocols = [SslApplicationProtocol.Http11], + }, + cancellationToken); + } + catch (AuthenticationException) + { + continue; + } + + using var readCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + readCancellation.CancelAfter(TimeSpan.FromMilliseconds(500)); + try + { + var request = await ReadRequestHeadersAsync(tls, readCancellation.Token); + if (request.Length > 0) + { + requestLines.Enqueue(FirstLine(request)); + var response = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\nServer: Caddy/2.8.4\r\nAllow: HEAD, OPTIONS\r\nContent-Length: 0\r\n\r\n"); + await tls.WriteAsync(response, cancellationToken); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // TLS posture connections intentionally complete no HTTP request. + } + catch (IOException) + { + // The client closes TLS posture connections immediately after authentication. + } + } + } + + private static async Task ServeAdaptiveHttpsConnectionsAsync( + TcpListener listener, + X509Certificate2 certificate, + TaskCompletionSource firstConnectionBytes, + ConcurrentQueue clearRequestLines, + ConcurrentQueue tlsRequestLines, + CancellationToken cancellationToken) + { + using (var greetingClient = await listener.AcceptTcpClientAsync(cancellationToken)) + { + firstConnectionBytes.SetResult(await ReadUntilPeerClosesAsync( + greetingClient.GetStream(), + cancellationToken)); + } + + using (var clearHttpClient = await listener.AcceptTcpClientAsync(cancellationToken)) + { + var request = await ReadRequestHeadersAsync( + clearHttpClient.GetStream(), + cancellationToken); + clearRequestLines.Enqueue(FirstLine(request)); + } + + using var tlsClient = await listener.AcceptTcpClientAsync(cancellationToken); + using var tls = new SslStream(tlsClient.GetStream(), leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync( + new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + EnabledSslProtocols = SslProtocols.Tls12, + ClientCertificateRequired = false, + ApplicationProtocols = [SslApplicationProtocol.Http11], + }, + cancellationToken); + var tlsRequest = await ReadRequestHeadersAsync(tls, cancellationToken); + tlsRequestLines.Enqueue(FirstLine(tlsRequest)); + await tls.WriteAsync( + Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\nServer: Caddy/2.8.4\r\nContent-Length: 0\r\n\r\n"), + cancellationToken); + } + + private static async Task ReadUntilPeerClosesAsync( + Stream stream, + CancellationToken cancellationToken) + { + using var output = new MemoryStream(); + var buffer = new byte[1_024]; + while (output.Length < 8_192) + { + var read = await stream.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + break; + } + + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } + + private static async Task ReadRequestHeadersAsync( + Stream stream, + CancellationToken cancellationToken) + { + using var output = new MemoryStream(); + var one = new byte[1]; + while (output.Length < 8_192) + { + var read = await stream.ReadAsync(one, cancellationToken); + if (read == 0) + { + break; + } + + output.WriteByte(one[0]); + if (output.Length >= 4) + { + var data = output.GetBuffer(); + var length = checked((int)output.Length); + if (data[length - 4] == '\r' + && data[length - 3] == '\n' + && data[length - 2] == '\r' + && data[length - 1] == '\n') + { + break; + } + } + } + + return Encoding.ASCII.GetString(output.ToArray()); + } + + private static string FirstLine(string request) + { + var separator = request.IndexOf("\r\n", StringComparison.Ordinal); + return separator >= 0 ? request[..separator] : request; + } + + private static X509Certificate2 CreateServerCertificate() + { + using var rsa = RSA.Create(2_048); + var request = new CertificateRequest( + "CN=localhost", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName("localhost"); + request.CertificateExtensions.Add(san.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature, + critical: false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new("1.3.6.1.5.5.7.3.1") }, + critical: false)); + using var generated = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(1)); + var pfx = generated.Export(X509ContentType.Pfx); + try + { + return X509CertificateLoader.LoadPkcs12( + pfx, + password: null, + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.UserKeySet); + } + finally + { + CryptographicOperations.ZeroMemory(pfx); + } + } + + private sealed class RecordingDnsResolver(params IPAddress[] addresses) : IRemoteDnsResolver + { + private int callCount; + + public int CallCount => Volatile.Read(ref callCount); + + public string? LastTarget { get; private set; } + + public Task ResolveAsync(string target, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref callCount); + LastTarget = target; + return Task.FromResult(addresses.ToArray()); + } + } + + private sealed class RecordingRateLimiter : IRemoteConnectionRateLimiter + { + private int waitCount; + + public int WaitCount => Volatile.Read(ref waitCount); + + public ValueTask WaitAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref waitCount); + return ValueTask.CompletedTask; + } + } + + private sealed class BlockingRateLimiter : IRemoteConnectionRateLimiter + { + private int waitCount; + + public int WaitCount => Volatile.Read(ref waitCount); + + public async ValueTask WaitAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref waitCount); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } +} diff --git a/tests/PortCVE.Tests/RemoteImportCliTests.cs b/tests/PortCVE.Tests/RemoteImportCliTests.cs new file mode 100644 index 0000000..21a1e28 --- /dev/null +++ b/tests/PortCVE.Tests/RemoteImportCliTests.cs @@ -0,0 +1,148 @@ +using System.Text; +using System.Text.Json; +using PortCVE.Cli; + +namespace PortCVE.Tests; + +public sealed class RemoteImportCliTests +{ + [Fact] + public async Task ImportNmap_EmitsSchemaV1ThroughRealCliDispatch() + { + const string xml = """ + + + +
+ + + + + + + + + """; + var inputPath = TemporaryFile("scan.xml", xml); + try + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var exitCode = await new CliApplication().RunAsync( + CliParser.Parse(["import", "nmap", inputPath]), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(string.Empty, error.ToString()); + using var document = JsonDocument.Parse(output.ToString()); + Assert.Equal(1, document.RootElement.GetProperty("schema_version").GetInt32()); + Assert.Equal("nmap_xml", document.RootElement.GetProperty("source").GetString()); + Assert.True(document.RootElement.GetProperty("is_complete").GetBoolean()); + Assert.Single(document.RootElement.GetProperty("endpoints").EnumerateArray()); + } + finally + { + Directory.Delete(Path.GetDirectoryName(inputPath)!, recursive: true); + } + } + + [Fact] + public async Task ImportNuclei_OutputFileIsLocalVersionedJsonAndDoesNotReplaceInput() + { + const string jsonl = """ + {"template-id":"tls-version","info":{"name":"TLS observation","severity":"medium"},"host":"https://192.0.2.10","port":"443"} + """; + var inputPath = TemporaryFile("findings.jsonl", jsonl); + var directory = Path.GetDirectoryName(inputPath)!; + var outputPath = Path.Combine(directory, "normalized.json"); + try + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var exitCode = await new CliApplication().RunAsync( + CliParser.Parse(["import", "nuclei", inputPath, "--output", outputPath]), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(string.Empty, error.ToString()); + Assert.True(File.Exists(outputPath)); + Assert.Equal( + JsonDocument.Parse(output.ToString()).RootElement.GetRawText(), + JsonDocument.Parse(File.ReadAllText(outputPath)).RootElement.GetRawText()); + + using var sameFileError = new StringWriter(); + var sameFileExitCode = await new CliApplication().RunAsync( + CliParser.Parse(["import", "nuclei", inputPath, "--output", inputPath, "--force"]), + TextWriter.Null, + sameFileError, + CancellationToken.None); + Assert.Equal(ExitCodes.UsageOrSchema, sameFileExitCode); + Assert.Contains("must not replace", sameFileError.ToString(), StringComparison.Ordinal); + Assert.Equal(jsonl, File.ReadAllText(inputPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ImportNmap_StrictReturnsIncompleteWithoutDiscardingValidEvidence() + { + const string xml = """ + + +
+ + + + +
+ + + + + """; + var inputPath = TemporaryFile("incomplete.xml", xml); + try + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var exitCode = await new CliApplication().RunAsync( + CliParser.Parse(["import", "nmap", inputPath, "--strict"]), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + using var document = JsonDocument.Parse(output.ToString()); + Assert.False(document.RootElement.GetProperty("is_complete").GetBoolean()); + Assert.Contains( + document.RootElement.GetProperty("diagnostics").EnumerateArray(), + static item => item.GetProperty("code").GetString() == "nmap_host_without_address"); + Assert.Contains( + document.RootElement.GetProperty("diagnostics").EnumerateArray(), + static item => item.GetProperty("code").GetString() == "nmap_protocol_ignored"); + Assert.DoesNotContain( + document.RootElement.GetProperty("diagnostics").EnumerateArray(), + static item => item.GetProperty("code").GetString() == "nmap_scan_incomplete"); + } + finally + { + Directory.Delete(Path.GetDirectoryName(inputPath)!, recursive: true); + } + } + + private static string TemporaryFile(string fileName, string contents) + { + var directory = Path.Combine(Path.GetTempPath(), $"portcve-import-cli-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, fileName); + File.WriteAllText(path, contents, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return path; + } +} diff --git a/tests/PortCVE.Tests/RemoteImportServiceTests.cs b/tests/PortCVE.Tests/RemoteImportServiceTests.cs new file mode 100644 index 0000000..6ad3dbf --- /dev/null +++ b/tests/PortCVE.Tests/RemoteImportServiceTests.cs @@ -0,0 +1,157 @@ +using System.Security.Cryptography; +using System.Text; +using PortCVE.Remote.Imports; +using PortCVE.Vulnerabilities; + +namespace PortCVE.Tests; + +public sealed class RemoteImportServiceTests +{ + [Fact] + public void ImportNmap_ProducesVersionedDocumentWithStableInputIdentity() + { + const string xml = """ + + + +
+ + + + + + + + + + """; + var path = TemporaryFile(".xml", xml); + try + { + var document = new PentestImportService().Import( + RemoteImportFormat.NmapXml, + path, + "test-version", + strict: true); + + Assert.Equal(PentestImportDocument.CurrentSchemaVersion, document.SchemaVersion); + Assert.Equal("test-version", document.ToolVersion); + Assert.Equal("nmap_xml", document.Source); + Assert.Equal("7.98", document.SourceVersion); + Assert.True(document.IsComplete); + Assert.Equal(Path.GetFileName(path), document.Input.FileName); + Assert.Equal(new FileInfo(path).Length, document.Input.SizeBytes); + Assert.Equal( + Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))), + document.Input.Sha256); + Assert.Single(document.Endpoints); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ImportNuclei_LenientModePreservesValidRecordsAndMarksDocumentIncomplete() + { + const string jsonl = """ + {not-json} + {"template-id":"not-a-match","info":{"name":"Negative matcher","severity":"info"},"host":"https://192.0.2.10","matcher-status":false} + {"template-id":"tls-version","info":{"name":"TLS observation","severity":"medium"},"host":"https://192.0.2.10","port":"443"} + """; + var path = TemporaryFile(".jsonl", jsonl); + try + { + var document = new PentestImportService().Import( + RemoteImportFormat.NucleiJsonl, + path, + "test-version", + strict: false); + + Assert.False(document.IsComplete); + Assert.Single(document.Findings); + Assert.Contains(document.Diagnostics, static item => item.Code == "nuclei_record_invalid"); + + Assert.Throws(() => new PentestImportService().Import( + RemoteImportFormat.NucleiJsonl, + path, + "test-version", + strict: true)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ImportPathPolicy_RejectsUncAndReparseTraversal() + { + var unc = LocalPathPolicy.ValidateExistingImportFile("\\\\server\\share\\results.xml"); + Assert.False(unc.IsValid); + Assert.Equal("import_path_network", unc.Code); + + var parent = Path.Combine(Path.GetTempPath(), $"portcve-import-link-{Guid.NewGuid():N}"); + var target = Path.Combine(parent, "target"); + var link = Path.Combine(parent, "link"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "results.xml"), ""); + Directory.CreateSymbolicLink(link, target); + try + { + var validation = LocalPathPolicy.ValidateExistingImportFile(Path.Combine(link, "results.xml")); + + Assert.False(validation.IsValid); + Assert.Equal("import_path_reparse", validation.Code); + } + finally + { + Directory.Delete(link); + Directory.Delete(parent, recursive: true); + } + } + + [Fact] + public void ImportService_StopsBeforeOpeningARejectedPath() + { + var service = new PentestImportService(_ => + new(false, null, "import_path_network", "The path is not local.")); + + var exception = Assert.Throws(() => service.Import( + RemoteImportFormat.NmapXml, + "ignored.xml", + "test-version", + strict: true)); + + Assert.Equal("import_path_network", exception.Code); + } + + [Fact] + public void ImportService_ObservesCancellationBeforeHashingOrParsingInput() + { + var path = TemporaryFile(".jsonl", new string('x', 1024 * 1024)); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + try + { + Assert.Throws(() => new PentestImportService().Import( + RemoteImportFormat.NucleiJsonl, + path, + "test-version", + strict: true, + cancellation.Token)); + } + finally + { + File.Delete(path); + } + } + + private static string TemporaryFile(string extension, string content) + { + var path = Path.Combine(Path.GetTempPath(), $"portcve-import-{Guid.NewGuid():N}{extension}"); + File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return path; + } +} diff --git a/tests/PortCVE.Tests/RemoteImportTests.cs b/tests/PortCVE.Tests/RemoteImportTests.cs new file mode 100644 index 0000000..4554f59 --- /dev/null +++ b/tests/PortCVE.Tests/RemoteImportTests.cs @@ -0,0 +1,341 @@ +using System.Text; +using System.Xml; +using PortCVE.Remote.Imports; + +namespace PortCVE.Tests; + +public sealed class RemoteImportTests +{ + [Fact] + public void NmapXml_ImportsEvidenceWithoutTrustingPortTableAsStrongIdentity() + { + const string xml = """ + + + +
+ + + + + + cpe:/a:openbsd:openssh:9.6p1 + +