diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a5e4acf..de7671c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report reproducible incorrect behavior, a crash, or an evidence-quality problem. +description: Report a reproducible bug, crash, or misleading result. title: "[Bug]: " labels: - bug @@ -21,8 +21,6 @@ body: attributes: label: Installation method options: - - Signed managed installer - - Signed portable ZIP - Built from source - Other validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 48c6c6d..2dde43a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Propose a bounded defensive workflow or evidence improvement. +description: Suggest a new workflow or improvement. title: "[Feature]: " labels: - enhancement @@ -7,29 +7,29 @@ body: - type: textarea id: problem attributes: - label: Operational problem - description: Describe the analyst or administrator task, not only the proposed implementation. + label: Problem to solve + description: Describe the analyst or administrator task, not just the implementation you have in mind. validations: required: true - type: textarea id: workflow attributes: label: Desired command and workflow - description: Include an example command, expected evidence, and automation exit behavior. + description: Include an example command, expected output, 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. + label: Safety and privacy + description: Note any network activity, privileges, sensitive data, or destructive actions involved. 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. + description: Name any current tools and explain what PortCVE should do differently. validations: required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e7f9b21..b274026 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,14 +1,14 @@ -## Purpose +## What changed -Describe the user-visible problem and the evidence contract this change affects. +Explain the problem and the user-visible change. ## 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. +- [ ] Any new network access, privilege requirement, or destructive behavior is documented. +- [ ] Default and `--include-private` output were reviewed for sensitive data. +- [ ] `--strict` and finding gates still fail when required data is incomplete. +- [ ] CLI or JSON schema changes are documented and tested. +- [ ] New dependencies and GitHub Actions are pinned and justified. ## Verification @@ -18,4 +18,4 @@ Describe the user-visible problem and the evidence contract this change affects. - [ ] `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: +Notes, omitted checks, or sanitized test data: diff --git a/CHANGELOG.md b/CHANGELOG.md index e52c2d1..d06cddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,7 @@ All notable changes will be documented here. The project follows semantic versio - 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. - 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. +- 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 the dated results and limits. ## 0.1.0-alpha.1 - 2026-08-09 diff --git a/README.md b/README.md index c9acd11..ec75cef 100644 --- a/README.md +++ b/README.md @@ -1,294 +1,185 @@ # PortCVE -**Audit local listeners. Fingerprint authorized remote services. Correlate evidence to CVEs.** +[![CI](https://github.com/Labeeb2339/PortCVE/actions/workflows/ci.yml/badge.svg)](https://github.com/Labeeb2339/PortCVE/actions/workflows/ci.yml) +[![CodeQL](https://github.com/Labeeb2339/PortCVE/actions/workflows/codeql.yml/badge.svg)](https://github.com/Labeeb2339/PortCVE/actions/workflows/codeql.yml) -PortCVE is a non-destructive Windows CLI that connects the facts other port tools leave separate: +PortCVE is a Windows CLI for answering four practical questions: -- 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 -- whether that local attack surface changed since a trusted baseline. +1. What is listening? +2. Which process, service, or container owns it? +3. Has the local attack surface changed? +4. Do identified packages or authorized remote service fingerprints match known CVEs? -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. +The current version is `0.2.0-alpha.1`. Windows x64 is the only supported target. There is no signed PortCVE release yet, so current users must build from source. -> 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. +## Features -## Why this exists +- Lists native IPv4/IPv6 TCP listeners and UDP endpoints. +- Maps binds to processes, Windows services, interfaces, network profiles, and static Windows Firewall rules. +- Correlates Docker published ports with observed host binds. +- Creates reviewable listener baselines and fails CI when exposure widens or ownership changes. +- Uses a local Trivy database to check exact Docker images or an explicitly supplied SBOM. +- Fingerprints TCP services on hosts or IPv4 CIDRs you are authorized to assess. +- Maps a small reviewed set of strong service banners to NVD CVE candidates. +- Imports existing Nmap XML and Nuclei JSONL without launching either scanner. +- Emits text, versioned JSON, JSONL, and stable exit codes for automation. -`netstat`, TCPView, and PowerShell can show sockets and owners. PortCVE is for the next question: +PortCVE is not an exploit framework. It does not brute-force credentials, send exploit payloads, close ports, edit firewall rules, or claim that a CVE match is exploitable. -> 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? +## Build from source -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. +Install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0), then run: -## Quick demo - -Illustrative Docker-published port: - -```text -PS> portcve tcp:8080 --evidence - -TCP4 0.0.0.0:8080 LISTEN - -OWNER - Process com.docker.backend.exe pid 6840 - Binary C:\Program Files\Docker\Docker\resources\com.docker.backend.exe - User S-1-5-21-... - -CONTAINER PUBLICATION - Container web (docker) - Image example/web:1.0 - Mapping 0.0.0.0:8080 -> 80/tcp - Confidence medium - -BIND - Scope all IPv4 interfaces - Active on Wi-Fi 192.168.1.42/24 (Private) - -HOST POLICY - STATIC ALLOW A matching explicit inbound allow rule was observed; static host policy indicates allow. - Confidence medium - -REACHABILITY - Local socket LISTENING - application acceptance was not tested - LAN/routed STATIC HOST POLICY INDICATES ALLOW - packet path not tested - Internet UNKNOWN - router, NAT, cloud controls, and the remote path were not tested - -LIMITATIONS - - Docker Engine publication was correlated by protocol, host address, and host port; - the host socket may be owned by a Docker Desktop forwarding process. +```powershell +git clone https://github.com/Labeeb2339/PortCVE.git +cd PortCVE +dotnet restore PortCVE.sln --locked-mode +dotnet test PortCVE.sln -c Release --no-restore +dotnet publish src\PortCVE\PortCVE.csproj ` + -c Release -r win-x64 --self-contained true --no-restore ` + -o artifacts\win-x64 ``` -PortCVE reads published-port metadata from the local Docker Engine named pipe and attaches it only when protocol, host address, and host port match an observed Windows endpoint. That tuple join is useful but intentionally reported with medium confidence; it is not direct proof of guest-process socket ownership. - -### Live Docker validation - -The integrated path was exercised on 2026-08-09 using Windows NT `10.0.26200.0`, Docker Desktop client/server `28.3.2`, and the `desktop-linux` WSL2 context. An official `alpine:3.22` fixture (`sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`) published TCP `127.0.0.1:64458 -> 8080` and UDP `0.0.0.0:51731 -> 5353`; both returned real echo payloads. An independent Windows CIM check saw those exact host tuples owned by PID `30176`, while the then-named BindWitness build kept the observed owner `com.docker.backend.exe` and attached both container publications with the Docker collector `complete`. - -A container-aware lock recorded `evidence.containers: complete` and `owner_identity_strength: container_image`; an unchanged `check` passed. Replacing the same TCP host endpoint with PowerShell produced exit code `1` and `owner_changed`. This validates local Docker collection, tuple correlation, and baseline drift behavior on that environment. It does **not** prove external reachability, guest-process socket ownership, broader Docker-version compatibility, or Linux host support. - -After building Release, the check is reproducible with: +Start with: ```powershell -.\scripts\Test-DockerIntegration.ps1 -ValidateLockCheck +.\artifacts\win-x64\portcve.exe version +.\artifacts\win-x64\portcve.exe doctor +.\artifacts\win-x64\portcve.exe list ``` -The script is intentionally mutating: it may pull `alpine:3.22`, creates and removes one uniquely labeled container, and publishes temporary echo ports. Its safe default binds both host ports to loopback. Add `-AllowWildcardUdp` only to exercise wildcard bind classification; that briefly publishes the UDP echo fixture on `0.0.0.0`, so it may be reachable from the local network until the guarded cleanup completes. - -The reachability wording is intentional. `STATIC ALLOW` and `STATIC BLOCK` summarize a static assessment of Windows Firewall configuration; they are not results from the Windows Filtering Platform packet-classification path. A local socket table and firewall rules cannot prove what a third-party WFP filter, IPsec negotiation, router, cloud security group, VPN, or remote host will do. - -### Live vulnerability validation - -The offline scan path was exercised on 2026-08-09 with official Trivy `v0.73.0`, an isolated schema-2 database, and the immutable local Docker image ID `sha256:c4d56c24da4f009ecf8352146b43497fe78953edb4c679b841732beb97e588b0` (Alpine 3.22.1). PortCVE reported 87 known-advisory matches: 3 critical, 17 high, 26 medium, and 41 low. A fresh strict scan returned `0`; `--fail-on high` and `--fail-on critical` returned `1`; missing and 96-hour-stale databases returned `3` without converting incomplete evidence into a clean result. - -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 +Administrator rights are not required for basic inventory. Protected processes and some firewall or owner details may remain unavailable; `doctor` reports those gaps. -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. +## Common workflows -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). +### Inspect local listeners -## 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, 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. +```powershell +portcve list +portcve list --scope non-loopback +portcve tcp:8080 --evidence +``` -The installer never permits an unsigned production install. The historical `v0.1.0-alpha.1` BindWitness-era release is unsigned and is intentionally rejected. +Firewall conclusions describe the Windows policy PortCVE could read. They do not prove LAN or Internet reachability. -> 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. +### Track drift -### Portable release ZIP +Create a baseline after reviewing the machine as known-good: -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. +```powershell +portcve lock -o listeners.lock.json +portcve diff listeners.lock.json +portcve check listeners.lock.json --strict +``` -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. +`check` fails on new listeners, owner changes, wider binds, or more-permissive host-policy results. Incomplete evidence returns exit code `3` rather than a clean pass. -After installation, follow the concise [daily-use workflow](docs/daily-use.md) for readiness checks, baselines, authorized assessments, and evidence handling. +If baseline creation is blocked only by protected processes with readable image names, `lock --allow-weak-owner` stores an explicit name-only policy. This is weaker than a hash or exact service identity and cannot detect a different binary using the same filename; unknown owners still prevent a pass. -### Build from source +### Check known advisories -Install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0), then: +PortCVE uses Trivy as an optional external scanner. Install Trivy separately, verify its published checksum, set `PORTCVE_TRIVY_PATH`, and initialize a dedicated local cache: ```powershell -cd portcve -dotnet restore PortCVE.sln --locked-mode -dotnet test PortCVE.sln -c Release --no-restore -dotnet publish src\PortCVE\PortCVE.csproj -c Release -r win-x64 --self-contained true --no-restore -o artifacts\win-x64 +portcve db update +portcve db status +portcve scan tcp:8080 --strict +portcve scan --all --fail-on high ``` -## Commands +`--all` scans each distinct Docker image ID mapped to a TCP listener. It does not guess products for unrelated native Windows listeners; if no scan-capable image is present, the command returns exit code `3`. + +For an explicit CycloneDX or SPDX SBOM: ```powershell -portcve # fast local inventory -portcve 8080 # explain TCP and UDP binds on a port -portcve tcp:8080 --evidence # protocol-specific deep explanation -portcve list --scope non-loopback # filter likely remote-facing binds -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 -portcve check listeners.lock.json # CI-friendly security drift gate -portcve watch --json # stream changes as JSONL -portcve doctor # collection coverage and privacy mode +portcve scan tcp:8080 --sbom .\app.cdx.json --strict ``` -Direct port inspection collects Windows Firewall evidence by default. Fast inventory, lock, and watch do not; add `--firewall` when you need policy correlation and accept the extra collection time. - -Run `portcve help` for the concise built-in reference. The complete option behavior, including privacy and baseline flags, is documented in [docs/cli.md](docs/cli.md). +Local scans do not update the database, pull images, or upload package data. Setup details are in [docs/daily-use.md](docs/daily-use.md). -## Baseline workflow +### Assess an authorized host -Create a baseline when the machine is in a known-good state: +Remote scanning only runs when you pass `--authorized`: ```powershell -portcve lock -o listeners.lock.json +portcve scan-host 10.20.30.40 --ports 22,80,443 --authorized +portcve scan-host 10.20.30.40 --ports 22,80,443 --authorized --active ``` -Lockfiles are TCP-only by default. Use `--include-udp` only when UDP bind drift matters to the review. UDP is connectionless, duplicate/reused binds are valid, and short-lived endpoints can create substantially more baseline churn. The `includes_udp` choice is stored in the lockfile and reused by later `diff` and `check` runs. - -Review all drift: +Online NVD enrichment is separate and opt-in: ```powershell -portcve diff listeners.lock.json +portcve scan-host 10.20.30.40 --ports 22,443 ` + --authorized --online-advisories --strict --fail-on high ``` -Gate a build, image, kiosk, or lab host: +`--authorized` records the operator's assertion; PortCVE cannot verify permission. Connections and probes can appear in server, firewall, IDS, and rate-limit logs. + +### Reuse scanner output ```powershell -portcve check listeners.lock.json -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +portcve import nmap .\scan.xml -o .\scan.portcve.json --strict +portcve import nuclei .\findings.jsonl -o .\findings.portcve.json --strict ``` -`check` fails on a new endpoint, owner change, wider bind, or more-permissive host-policy assessment. Removed endpoints and narrower binds remain visible in `diff` but do not fail the security gate. - -Lockfiles are deterministic and privacy-reduced. They omit PIDs, timestamps, command lines, environment variables, usernames, and full executable paths, but still reveal ports, protocols, bind scopes, owner identities such as process or service names or hashes, and policy metadata. Review a lockfile before publishing it. Duplicate UDP binds are preserved as a multiset rather than silently collapsed. - -By default, `lock` refuses to write when ownership, bind-scope, or requested host-policy evidence is incomplete. `--allow-incomplete` overrides that write-time guard for investigation and diff workflows; it does **not** turn incomplete evidence into a passing security gate. `check` returns exit code `3` when its baseline or current evidence cannot support a decision. - -Container evidence has its own completeness field. If the local Docker Engine responds, correlated endpoints use a deterministic SHA-256 of the sorted Docker image-ID set as `owner_identity` with strength `container_image` when every correlated publication has an image ID. This avoids container names, IDs, and restart-specific values in the lockfile. If the Docker pipe is absent, container evidence is `not_collected` and normal host-owner identity is used. Access denial, timeout, collector failure, or an Engine publication that cannot be reconciled with the Windows socket table makes requested container evidence `partial`; `lock` then requires `--allow-incomplete`, `diff --strict` returns exit code `3`, and `check` cannot pass until comparable evidence is available. +The importers are streaming and size-limited. They remove raw response and extracted-value fields, but the normalized report can still contain sensitive assessment metadata. -## Evidence model +## Output and privacy -PortCVE keeps six layers separate: +Default JSON is privacy-reduced, not anonymous. It removes or aliases PIDs, local paths, interface addresses, account details, container identifiers, and free-form diagnostic text where the command supports reduction. Ports, process or service names, package versions, CVE identifiers, and policy results may remain. -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. **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. +Use `--include-private` only for reports that stay in a controlled location: -`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. - -More detail is in [docs/architecture.md](docs/architecture.md) and [docs/threat-model.md](docs/threat-model.md). - -### Native, Docker, and CIM evidence - -The evidence sources are intentionally identified rather than blended together: - -- TCP/UDP endpoint ownership comes from native Windows IP Helper tables (`GetExtendedTcpTable` and `GetExtendedUdpTable`). Process paths, timestamps, token SIDs, parent snapshots, and active-service candidates come from native process, token, Toolhelp, and Service Control Manager APIs. -- Adapter addresses come from local .NET network-interface APIs. -- Running-container published-port metadata comes from `/version` and the negotiated `/containers/json` endpoint over the local `\\.\pipe\docker_engine` named pipe. It is runtime metadata, not direct socket-owner evidence. -- Network profile names and Windows Firewall `ActiveStore` rules/filters come from local PowerShell NetSecurity commands that return structured CIM objects. - -The native socket row is direct point-in-time evidence from the host, although collection races still exist. CIM firewall data is configuration evidence consumed by a conservative static evaluator. It is slower, can degrade independently, and must not be described as a live packet decision. - -## Privacy and safety - -The default contract is deliberately small: - -- 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; -- 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. - -JSON list/inspect and `snapshot` output is redacted by default. It replaces interface identities and addresses, replaces the owning PID with `0`, removes process creation time and private owner fields, sanitizes firewall-rule fields, clears free-form evidence, and sanitizes diagnostic details. The schema covers both output modes, so optional private fields are absent unless they were collected and `--include-private` was supplied. Image names, service names, scopes, ports, profile labels, policy results, and collector metadata remain in the default snapshot; treat even redacted snapshots as host metadata. Human-readable inspection is intended for local viewing and can display local paths, SIDs/accounts, PIDs, and interface addresses. +```powershell +portcve snapshot --json -o host.portcve.json +portcve snapshot --json --include-private -o host.private.portcve.json +``` -For Docker correlations, default JSON replaces container IDs, container names, and image references, omits image IDs, and normalizes the published host address. It still reveals that a mapping exists, its host and container ports, protocol, runtime, and correlation confidence. `--include-private` permits the collected container identifiers and image references to be serialized. Review either mode before publishing. +Review every report before sharing it. -`--resolve-accounts` asks Windows to translate token SIDs into account names. `LookupAccountSid` can consult domain controllers or the global catalog when the answer is not local or cached, so this flag may cause network activity. It is off by default and is separate from `--include-private`: resolution controls collection, while `--include-private` controls whether resolved private values appear in JSON. +Versioned schemas live in [`schema/`](schema/): -## Machine-readable output +- [snapshot](schema/portcve.snapshot.v1.schema.json) +- [listener lockfile](schema/portcve.lock.v1.schema.json) +- [Trivy database status](schema/portcve.database.v1.schema.json) +- [local vulnerability report](schema/portcve.vulnerability.v1.schema.json) +- [remote assessment](schema/portcve.remote.v1.schema.json) +- [external evidence import](schema/portcve.import.v1.schema.json) -All JSON uses snake_case, stable enum strings, a mandatory `schema_version`, deterministic ordering, and diagnostics for partial evidence. Schema identifiers are stable URNs; they do not depend on a project website. +## Install and release status -- [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) +The checked-in `scripts/install.ps1` is a release template, not a bootstrap installer. It refuses to run until the release workflow has finalized and signed it. -Human-readable output is not a compatibility API. JSON and lockfile schema changes follow the policy in [docs/versioning.md](docs/versioning.md). +When signed releases become available, the installer will verify its own Authenticode signature, the release checksum, and the signed executable before changing the per-user installation. It supports update, exact-version rollback, and uninstall. See [docs/install.md](docs/install.md). -## Scope +PortCVE intentionally does not support `irm ... | iex`. Piped script text starts executing before a downloaded file can be inspected and Authenticode-verified. -Included now: +## Limits -- native IPv4/IPv6 TCP listener and UDP endpoint collection; -- PID, executable, parent process, account SID/name, and active-service attribution; -- loopback/interface/wildcard classification; -- active Windows network-profile mapping; -- local Docker Engine named-pipe collection and medium-confidence published-port correlation; -- 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, 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. +- Alpha CLI and schemas may change before `1.0`. +- Windows x64 only; no Linux host or WSL guest-process attribution yet. +- Native Windows software is not assigned a CPE from a filename or port guess. +- Remote CVE results are candidates based on observed banner data, not proof of applicability or exploitability. +- Windows Firewall analysis is static configuration review, not live packet-path testing. +- Dynamic collection can miss short-lived endpoints or lose metadata during process churn. +The full security model is in [docs/threat-model.md](docs/threat-model.md), and implementation details are in [docs/architecture.md](docs/architecture.md). -Not included: +## Validation -- 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”; -- proof of LAN or Internet reachability; -- exploitability proof, automatic remediation, automatic database downloads, or guessed CPEs from process/port names; -- WSL guest-process or Kubernetes workload attribution; or -- Linux support yet. +CI runs the test suite on Windows Server 2022 and 2025, builds the self-contained executable, checks formatting, exercises the installer and RFC 3161 verification harnesses, runs an authorized loopback scan, checks performance budgets, and performs CodeQL analysis. -See [ROADMAP.md](ROADMAP.md) for the deliberately staged follow-up work. +Live Docker, Trivy, corruption-handling, privacy, remote-loopback, and performance results are recorded in [docs/validation.md](docs/validation.md) and [docs/remote-live-validation.md](docs/remote-live-validation.md). ## Development ```powershell 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 -dotnet run --project src\PortCVE -- doctor +dotnet test PortCVE.sln -c Release --no-build --no-restore ``` -Runtime code has no third-party package dependency. Tests use xUnit. Committed NuGet lockfiles make `--locked-mode` fail if dependency resolution drifts. Warnings are treated as errors. - -Before contributing, read [CONTRIBUTING.md](CONTRIBUTING.md). Security reports belong in a private GitHub Security Advisory as described in [SECURITY.md](SECURITY.md). +See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Report security issues privately as described in [SECURITY.md](SECURITY.md). ## License diff --git a/ROADMAP.md b/ROADMAP.md index 42a6163..ef16705 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,12 +1,8 @@ # Roadmap -The order matters: correctness and evidence quality come before more platforms or a GUI. +Correctness comes before more platforms or a GUI. -## Current stabilization - -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: +## Before 1.0 - 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 @@ -14,14 +10,14 @@ Remaining before a stable `1.0` claim: - Disposable-VM firewall rule matrix - A public-trust Authenticode release after publisher identity validation and protected signing credentials are configured -## 0.2 guest and workload attribution +## Workload attribution - Best-effort WSL correlation without starting stopped distributions - Kubernetes workload attribution with an explicit context and consent boundary - Binary hashing as an explicit opt-in baseline field - Better protected-service owner-module enrichment when elevated -## 0.3 remote assessment hardening +## Remote assessment - 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 @@ -29,7 +25,7 @@ Remaining before a stable `1.0` claim: - 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 +## Policy and output - Source-IP-aware firewall explanation - SARIF output for CI findings @@ -48,5 +44,5 @@ Remaining before a stable `1.0` claim: - Process killing - Automatic firewall modification - Cloud dashboards or telemetry -- AI-generated risk scores +- Generic risk scoring - 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 5f2ec02..30e4e02 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -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. +PortCVE is alpha software and has no supported binary release yet. Once signed releases begin, only the latest release line will receive security fixes unless a release note says otherwise. ## Report a vulnerability diff --git a/docs/architecture.md b/docs/architecture.md index d5f7709..12e40b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,11 +19,11 @@ PortCVE is a collection-and-correlation CLI. It does not sniff packets, execute 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. -## Evidence source boundary +## Evidence sources PortCVE labels evidence by source because the sources have different semantics: -| Evidence | Source | Claim boundary | +| Evidence | Source | Limits | | --- | --- | --- | | TCP/UDP endpoint and PID | Native IP Helper owner-PID tables | Direct point-in-time host observation; still vulnerable to collection races and PID reuse. | | Process path/time/SID, parent and service candidates | Native process/token, Toolhelp, and Service Control Manager APIs | Best-effort enrichment after the socket sample; access denial and process churn are explicit limitations. | diff --git a/docs/cli.md b/docs/cli.md index 27775c3..351ac2c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,7 +14,7 @@ portcve lock [--output ] Write a normalized baseline 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 scan --all Check all mapped Docker image IDs 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 @@ -38,7 +38,7 @@ The scanner launches a separately installed Trivy executable without a shell, se | Option | Behavior | | --- | --- | -| `--all` | Select every observed TCP listener and deduplicate exact Docker subjects by immutable image ID. | +| `--all` | Inspect every observed TCP listener, then scan each distinct immutable Docker image ID that can be mapped. Unrelated native listeners are not guessed into subjects. No eligible image returns exit `3`. | | `--sbom ` | Add one explicit SBOM subject to an exact-port scan. UNC paths, mapped network drives, and paths traversing reparse points are rejected before collection. The file is hashed before and after scanning; changed input findings are discarded and cannot produce a successful scan. | | `--fail-on high` | Exit `1` for a high or critical known-advisory match. | | `--fail-on critical` | Exit `1` for a critical known-advisory match. | @@ -138,10 +138,15 @@ Import output always follows `schema/portcve.import.v1.schema.json`. Use `-o, -- | --- | --- | | `--include-udp` | Include UDP in `lock` and `watch`. TCP-only is the default because connectionless, duplicate, and short-lived UDP binds can create noisy baseline churn. A protocol-specific UDP lock also records `includes_udp: true`. | | `--allow-incomplete` | Permit `lock` to write a baseline with incomplete ownership, bind-scope, requested host-policy, or requested container evidence. Such a file is useful for manual diffing but cannot make `check` pass while evidence remains incomplete. | +| `--allow-weak-owner` | At `lock` creation only, store an explicit policy that permits `name_only` owner identity to support `diff` and `check`. The flag is not accepted by `diff` or `check`; those commands inherit the stored policy. `unknown` owners remain incomplete. | | `-o, --output ` | Write a lockfile or snapshot to a path. The default lockfile is `listeners.lock.json`. | | `--force` | Replace an existing lockfile or snapshot instead of failing with exit code `2`. | -Lockfiles omit PIDs, timestamps, command lines, environment variables, account names, full paths, container IDs, container names, and raw image references. They store normalized owner identity strength, host-policy confidence, evidence-completeness metadata, the selector, and whether UDP was included. +Lockfiles omit PIDs, timestamps, command lines, environment variables, account names, full paths, container IDs, container names, and raw image references. They store normalized owner identity strength, host-policy confidence, evidence-completeness metadata, the selector, the weak-owner policy, and whether UDP was included. Lockfile input is limited to 16 MiB and 50,000 listener records before it can be used by `diff` or `check`; exceeding either limit is an invalid-lockfile/schema error with exit code `2`. + +By default, a passing baseline requires every selected listener to have `sha256`, `container_image`, exact `service`, or `kernel` owner identity. `--allow-weak-owner` is a narrower alternative to `--allow-incomplete`: it can make a baseline/check decision-capable when every selected listener has at least a stable process image name and every bind-scope, requested firewall, and requested container evidence dimension is sufficient. The lockfile still records `evidence.ownership: partial`; it also records `allow_weak_owner: true`, so the weaker decision policy is visible and inherited. Human and JSON `diff`/`check` output identify the stored policy. A missing policy field means `false`, and ordinary strong lockfiles omit it. + +The weak policy never accepts `unknown`, never converts `name_only` into strong evidence, and never masks a later strong-to-weak, bind-scope, firewall, or container regression. A current SHA-256/service/kernel identity with the same observed process name is reported as improved evidence rather than a false owner change, but it does not ratchet the stored baseline; review and recreate the lockfile to adopt stronger evidence. A newly correlated `container_image` identity remains an owner change. Most importantly, a name-only baseline cannot distinguish a different binary using the same image name. Prefer an elevated strong baseline when that replacement threat matters. `evidence.containers` is `complete` when the Docker Engine answered successfully, including when it reported no running published ports. A normally absent Docker pipe is `not_collected`, so non-Docker hosts do not need `--allow-incomplete`. Access denial, timeout, malformed response, or collector failure is `partial` for a container-aware capture. Correlated endpoints use `container_image` owner strength and a deterministic `container-image-set:` identity when every correlated publication supplies an image ID; otherwise owner identity falls back to the observed host process/service rules. `diff` and `check` recollect Docker evidence when the baseline's container evidence was collected. diff --git a/docs/daily-use.md b/docs/daily-use.md index 74b6360..91bf84b 100644 --- a/docs/daily-use.md +++ b/docs/daily-use.md @@ -6,7 +6,7 @@ It is non-destructive and does not change local security state. It does not clos ## 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. +1. There is no signed PortCVE release yet. Build the current source as described in the [README](../README.md) and run the published executable from a controlled directory. Once a signed release exists, use the file-backed procedure in [install.md](install.md) or its portable ZIP. 2. Open a new terminal and verify the installation: ```powershell @@ -16,7 +16,7 @@ It is non-destructive and does not change local security state. It does not clos 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: +3. If Docker or SBOM vulnerability checks are needed, install a trusted Windows x64 build from the official [Trivy releases](https://github.com/aquasecurity/trivy/releases) 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( @@ -67,6 +67,14 @@ Create a baseline only after reviewing the current machine as known-good: portcve lock -o .\listeners.lock.json ``` +The default requires strong owner identity for every selected listener. If it refuses only because protected processes have stable image names but no readable hash or exact service identity, review those names and explicitly store the weaker policy: + +```powershell +portcve lock --allow-weak-owner -o .\listeners.lock.json +``` + +`diff` and `check` inherit that policy from the file; do not pass the flag again. Unknown owners still return exit `3`, as do strong-owner, bind-scope, requested firewall, or container evidence regressions. A same-name process observed later can pass even if its binary changed, so prefer an elevated strong baseline when binary replacement is in scope. Stronger evidence observed during a check does not silently rewrite the baseline; review and recreate the lockfile to adopt it. + Review drift manually: ```powershell @@ -80,7 +88,7 @@ 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. +Commit a reviewed privacy-reduced lockfile when it belongs to a repository policy. A weak-owner `PASS` is explicitly labeled in human output and JSON exposes `allow_weak_owner`; retain that context in automation logs. 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: @@ -98,6 +106,8 @@ portcve scan tcp:8080 --strict portcve scan --all --fail-on high ``` +`--all` scans every distinct immutable Docker image ID mapped to the observed TCP listeners. Native Windows listeners without an exact supported subject are not guessed into the report. If no scan-capable image is available, the command returns exit `3` rather than a clean gate. + An explicit local CycloneDX or SPDX SBOM can be associated with one selected listener: ```powershell @@ -174,4 +184,4 @@ powershell.exe -NoProfile -ExecutionPolicy AllSigned ` -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. +See [install.md](install.md) for signature, checksum, rollback, custom-directory, and portable-ZIP details. See [cli.md](cli.md) for the complete command, JSON, and exit-code reference. diff --git a/docs/release-signing.md b/docs/release-signing.md index 16ac4b8..333038c 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -1,74 +1,65 @@ -# PortCVE release signing +# Release signing -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 is the maintainer runbook for Windows releases. The workflow is fail-closed: it cannot publish a PortCVE release unless both `portcve.exe` and `install.ps1` pass the configured signing and verification checks. -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. +No signed PortCVE release exists yet. The workflow remains blocked until a verified publisher certificate and production eSigner credentials are configured. -## Malaysia signing route +## Prerequisites -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. +The current workflow uses [SSL.com eSigner](https://www.ssl.com/products/software-integrity/signing-service/). The certificate subject becomes the publisher shown by Windows, so use the exact validated subject rather than a project nickname. -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: +Configure the `release-signing` GitHub environment with these secrets: -- [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/) -- [SSL.com eSigner automation setup](https://www.ssl.com/how-to/automate-esigner-ev-code-signing/) +- `ES_USERNAME` +- `ES_PASSWORD` +- `CREDENTIAL_ID` +- `ES_TOTP_SECRET` -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. +Set the repository variable `EXPECTED_SIGNER_SUBJECT` to the complete X.500 subject returned by a controlled test signature: -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). - -## Required GitHub configuration - -Configure these controls before creating a release tag: +```powershell +(Get-AuthenticodeSignature -LiteralPath .\portcve.exe).SignerCertificate.Subject +``` -1. Create an environment named exactly `release-signing`. -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` - - `CREDENTIAL_ID` - - `ES_TOTP_SECRET` -4. Create the repository variable `EXPECTED_SIGNER_SUBJECT`. Its value must be the complete X.500 subject returned by `Get-AuthenticodeSignature`, with exact spelling, ordering, punctuation, and whitespace. A simple common name is insufficient. For example, capture it from a controlled SSL.com test-signed executable: +The value must match Windows output exactly, including ordering, punctuation, and whitespace. - ```powershell - (Get-AuthenticodeSignature -LiteralPath .\portcve.exe).SignerCertificate.Subject - ``` +Required repository controls: -5. Enable [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases). The workflow queries the repository setting and fails before creating a draft if it is disabled. -6. Protect `main` with a ruleset requiring reviews and passing CI. Restrict who can create tags matching `v*`. If available for the repository, require actions to be pinned to full commit SHAs. +- `main` changes go through a pull request and required CI checks; +- tags matching `v*` are protected; +- releases are immutable; +- GitHub Actions are pinned to full commit SHAs; and +- `release-signing` is limited to the release-tag policy, has a wait timer and approval gate, and does not allow administrator bypass. -GitHub should also limit the `release-signing` environment's deployment tag pattern to the narrowest pattern the UI supports. The workflow still performs its own exact SemVer, annotated-tag, project-version, and `origin/main` ancestry checks because an environment pattern alone is not sufficient. +The current solo-maintainer approval is a manual checkpoint, not separation of duties. Add a second trusted reviewer and enable prevent-self-review before describing the release process as independently approved. -## What the workflow enforces +## Workflow -The workflow in `.github/workflows/release.yml` has three security boundaries: +`.github/workflows/release.yml` has three jobs: -- `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 `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. +1. `build` restores locked dependencies, checks formatting, builds and tests, runs the installer harness, publishes one unsigned executable, and smoke-tests it. This job has no signing secrets. +2. `sign` runs behind the protected environment. It finalizes the installer, signs the executable and installer separately, and verifies both files. +3. `package_publish` repeats verification, packages the signed executable, writes checksums and signing metadata, creates provenance attestations, uploads a draft release, verifies GitHub's asset digests, and then publishes it. -Signature verification requires all of the following: +Signing secrets are exposed only to the configuration check and the two pinned eSigner steps. -- for `portcve.exe`, `signtool verify /pa /all /v` succeeds with exactly one SHA-256 signature, one validated timestamp, zero warnings, and zero errors; -- `Get-AuthenticodeSignature` reports `Valid`; -- the signer's full subject is an ordinal, exact match for `EXPECTED_SIGNER_SUBJECT`; -- the signer certificate contains Code Signing EKU `1.3.6.1.5.5.7.3.3`; -- a timestamp certificate containing Time Stamping EKU `1.3.6.1.5.5.7.3.8` is present; and -- under PowerShell 7.2 or newer, the PE certificate table or PowerShell signature block contains exactly one RFC 3161 token, with no legacy countersignature; `Rfc3161TimestampToken.TryDecode` consumes the complete token, `VerifySignatureForSignerInfo` cryptographically binds its message imprint to the primary Authenticode `SignerInfo`, and the returned TSA certificate exactly matches the Windows-trusted timestamp certificate. +Verification requires: -The release verifier fails if the PowerShell 7 platform primitive is unavailable, or if a token is malformed, has trailing data, is unbound, has an invalid signature, uses a different TSA certificate, is duplicated, or is accompanied by a legacy countersignature. Windows PowerShell 5.1 cannot access this .NET primitive. The production installer therefore makes the narrower claim that Windows reports the Authenticode signature and timestamp as trusted and that both required EKUs are present; it does not perform or claim an independent RFC 3161 binding proof. +- `signtool verify /pa /all /v` to accept the executable; +- `Get-AuthenticodeSignature` to report `Valid`; +- an exact match with `EXPECTED_SIGNER_SUBJECT`; +- the Code Signing and Time Stamping EKUs; +- one SHA-256 Authenticode signature and one timestamp; and +- PowerShell 7.2 or newer to decode the RFC 3161 token, bind it to the primary signature with `VerifySignatureForSignerInfo`, and match the returned TSA certificate to the Windows-trusted timestamp certificate. -There is no unsigned fallback. Missing credentials, a changed action/tool download, unexpected files, a wrong subject, a missing timestamp, a failed smoke test, a checksum mismatch, failed provenance, or a release API error stops publication. +Malformed, duplicated, unbound, legacy, or mismatched timestamp data stops the release. There is no unsigned fallback. ## Release procedure -1. Confirm CI is green on `main` and the worktree is clean. -2. Update `` in `src/PortCVE/PortCVE.csproj` to the exact intended SemVer and merge it to `main`. -3. Review every third-party action commit and the CodeSignTool archive hash. Update pins only in a dedicated reviewed change. -4. Create an annotated tag at the reviewed `main` commit. A maintainer with a configured signing key should prefer a cryptographically signed annotated tag: +1. Merge the intended version change to `main` and wait for CI and CodeQL. +2. Confirm the worktree is clean and the project version matches the intended tag. +3. Review changes to action pins, signing tools, or release scripts separately. +4. Create an annotated tag at the reviewed `main` commit. Use a signed Git tag when a maintainer signing key is configured: ```powershell git switch main @@ -77,95 +68,59 @@ There is no unsigned fallback. Missing credentials, a changed action/tool downlo git push origin v1.0.0 ``` - If signed Git tags are not yet configured, `git tag -a` satisfies the workflow's annotated-tag check, but the binary-signing and release controls remain the security boundary. - -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 clean separate Windows machine and run the checksum, signature, portable ZIP, and managed lifecycle checks below. Record install, update, explicit rollback, and uninstall results. +5. Approve the `release-signing` deployment only after checking the tag, commit, workflow diff, and expected publisher subject. +6. Confirm signature verification, signed smoke tests, checksums, provenance, and asset-digest checks all passed. +7. Download the release on a clean Windows machine and test the portable ZIP plus managed install, update, rollback, and uninstall paths. -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. +Stable tags use `vMAJOR.MINOR.PATCH`. Prereleases use forms such as `v1.0.0-rc.1`. The workflow and installer share the same tag parser. -## Consumer verification +## Verify downloaded assets -From a clean checkout of the exact release tag, with the downloaded release assets placed at the repository root: +From a clean checkout of the release tag, place the downloaded assets in the repository root and run: ```powershell -$expected = (Get-Content -LiteralPath .\SHA256SUMS.txt | ForEach-Object { +$expected = Get-Content -LiteralPath .\SHA256SUMS.txt | ForEach-Object { $hash, $name = $_ -split '\s+', 2 [pscustomobject]@{ Hash = $hash; Name = $name } -}) +} + foreach ($entry in $expected) { - $actual = (Get-FileHash -LiteralPath (Join-Path $PWD $entry.Name) -Algorithm SHA256).Hash.ToLowerInvariant() + $path = Join-Path $PWD $entry.Name + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -cne $entry.Hash) { throw "Checksum mismatch: $($entry.Name)" } } pwsh -NoProfile -File .\scripts\Verify-ReleaseSignature.ps1 ` -Path .\portcve.exe ` - -ExpectedSignerSubject 'PASTE THE EXACT PUBLISHED FULL X.500 SUBJECT' + -ExpectedSignerSubject 'PASTE THE PUBLISHED FULL X.500 SUBJECT' pwsh -NoProfile -File .\scripts\Verify-ReleaseSignature.ps1 ` -Path .\install.ps1 ` - -ExpectedSignerSubject 'PASTE THE EXACT PUBLISHED FULL X.500 SUBJECT' + -ExpectedSignerSubject 'PASTE THE PUBLISHED FULL X.500 SUBJECT' ``` -GitHub provenance can also be verified with GitHub CLI after authenticating: +If GitHub CLI is available, verify provenance as well: ```powershell 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`: +Then exercise the signed installer on a clean machine as described in [install.md](install.md). -```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: +## Before 1.0 -- 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 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 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. +Do not publish `v1.0.0` until all of these are true: -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. +- the publisher certificate and production eSigner credential are active; +- a controlled test signature established the reviewed full signer subject; +- the complete production workflow passed on a prerelease tag; +- both downloaded files passed SignTool, Authenticode, EKU, and RFC 3161 binding checks; +- the portable ZIP and standalone executable contain the same signed binary recorded in `SIGNING-METADATA.json`; +- checksums and GitHub provenance verify after download; +- a clean Windows machine completed install, update, rollback, and receipt-bound uninstall testing; and +- release notes describe SmartScreen and vulnerability results without promising warning-free execution or exploitability. -## 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 -``` +Record the release URL, workflow run, executable SHA-256, signer subject, and verification-machine details. Never record signing secrets or authentication logs. -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. +The old `v0.1.0-alpha.1` BindWitness release is historical and unsigned. It is not accepted by the PortCVE installer; see [CHANGELOG.md](../CHANGELOG.md). diff --git a/docs/remote-live-validation.md b/docs/remote-live-validation.md index 06507cf..a15f9be 100644 --- a/docs/remote-live-validation.md +++ b/docs/remote-live-validation.md @@ -64,7 +64,7 @@ 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 +## Limitations This validates loopback TCP discovery, adaptive HTTP fingerprinting on an OS-assigned nonstandard port, output privacy modes, and cleanup on the tested diff --git a/docs/threat-model.md b/docs/threat-model.md index 6f6a5c0..2dd2ab8 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -88,7 +88,7 @@ JSON snapshots are redacted by default, but they are not anonymous. Ports, scope Docker collection uses the local `\\.\pipe\docker_engine` IPC endpoint. It does not contact a TCP Docker endpoint, pull an image, start a container, or execute inside one. -The dated live fixture described in the README validated TCP and UDP echo, independent host-tuple observation, correlation by the then-named BindWitness build, complete container-image lock evidence, an unchanged pass, and `owner_changed` after host-owner replacement. That evidence supports the local integration path only; it does not reduce the external-reachability, guest-ownership, WSL/Kubernetes, or cross-version boundaries above. +The dated live fixture recorded in [validation.md](validation.md) validated TCP and UDP echo, independent host-tuple observation, container correlation, complete container-image lock evidence, an unchanged pass, and `owner_changed` after host-owner replacement. That result covers the tested local integration path only; it does not change the external-reachability, guest-ownership, WSL/Kubernetes, or cross-version limits above. 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`. diff --git a/docs/validation.md b/docs/validation.md index 8db8160..9563f6a 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -108,7 +108,7 @@ The performance harness passed its enforced budgets with ten local-inventory ite 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 +## Limitations 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: diff --git a/docs/versioning.md b/docs/versioning.md index 0108efb..71e7e72 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -28,3 +28,7 @@ Consumers must inspect `schema_version` before parsing. Snapshot consumers may h 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. + +The current v1 lock schema has an optional `allow_weak_owner` policy field. Absence means `false`, and false is omitted, so ordinary strong v1 output retains its previous shape. A weak-policy lock keeps objective `evidence.ownership: partial`; new readers can accept that state only when the stored policy is true and every selected listener is at least `name_only`. Older PortCVE readers ignore the optional field but still see partial ownership and remain fail-closed. A frozen older copy of the closed v1 JSON Schema uses `additionalProperties: false` and will reject the new field, so validators must use the schema shipped with the producing PortCVE version. This is a compatible PortCVE-reader extension under the pre-1.0 policy, not universal compatibility with stale schema files. + +V1 lockfile readers also enforce operational safety limits of 16 MiB and 50,000 listener records. Those bounds exceed realistic host baselines but intentionally reject oversized, potentially memory-exhausting input with the stable usage/schema exit code `2`. diff --git a/schema/portcve.lock.v1.schema.json b/schema/portcve.lock.v1.schema.json index 2608952..502fd45 100644 --- a/schema/portcve.lock.v1.schema.json +++ b/schema/portcve.lock.v1.schema.json @@ -26,6 +26,11 @@ "type": "boolean", "description": "Whether UDP binds are part of this baseline and must be included in later diff/check collection." }, + "allow_weak_owner": { + "type": "boolean", + "default": false, + "description": "Explicit policy opt-in permitting name_only owner identities to support check decisions. Missing means false. unknown owner identities remain incomplete, and objective ownership evidence remains partial when any name_only identity is present." + }, "selector": { "$ref": "#/$defs/selector" }, @@ -34,6 +39,8 @@ }, "listeners": { "type": "array", + "maxItems": 50000, + "description": "Normalized listener records. PortCVE also enforces a 16 MiB total lockfile input limit before deserialization.", "items": { "$ref": "#/$defs/listener" } diff --git a/src/PortCVE/Analysis/ListenerDiffEngine.cs b/src/PortCVE/Analysis/ListenerDiffEngine.cs index 5a941ed..ee45208 100644 --- a/src/PortCVE/Analysis/ListenerDiffEngine.cs +++ b/src/PortCVE/Analysis/ListenerDiffEngine.cs @@ -59,7 +59,7 @@ public static IReadOnlyList Compare( // Remove exact matches first so legitimate shared/reused UDP binds are compared as a multiset. for (var oldIndex = unmatchedOld.Count - 1; oldIndex >= 0; oldIndex--) { - var exactIndex = unmatchedNew.FindIndex(item => item == unmatchedOld[oldIndex]); + var exactIndex = unmatchedNew.FindIndex(item => StoredFieldsEqual(item, unmatchedOld[oldIndex])); if (exactIndex >= 0) { unmatchedOld.RemoveAt(oldIndex); @@ -71,7 +71,7 @@ public static IReadOnlyList Compare( { var oldItem = unmatchedOld[0]; var preferredIndex = unmatchedNew.FindIndex(item => - string.Equals(item.OwnerIdentity, oldItem.OwnerIdentity, StringComparison.OrdinalIgnoreCase)); + OwnersEquivalentForComparison(oldItem, item)); if (preferredIndex < 0) { preferredIndex = 0; @@ -112,7 +112,7 @@ private static IReadOnlyList CoalesceBindScopeChanges(List OwnerStrengthRank(oldItem.OwnerIdentityStrength)) + { + changes.Add(new(ListenerChangeKind.EvidenceImproved, key, oldItem, newItem, + $"owner evidence improved from {oldItem.OwnerIdentityStrength} to {newItem.OwnerIdentityStrength}")); + } if (oldItem.Scope != newItem.Scope && newItem.Scope == BindScope.Unknown) { @@ -238,6 +249,35 @@ private static void ComparePair( private static string StableIdentity(LockedListener item) => $"{item.OwnerIdentity}\0{item.OwnerIdentityStrength}\0{item.Scope}\0{item.HostPolicy}\0{item.HostPolicyConfidence}"; + private static bool StoredFieldsEqual(LockedListener left, LockedListener right) => + left.Key == right.Key + && left.Protocol == right.Protocol + && left.Family == right.Family + && left.Address == right.Address + && left.Port == right.Port + && left.Scope == right.Scope + && left.OwnerIdentity == right.OwnerIdentity + && left.OwnerIdentityStrength == right.OwnerIdentityStrength + && left.HostPolicyConfidence == right.HostPolicyConfidence + && left.HostPolicy == right.HostPolicy; + + private static bool OwnersEquivalentForComparison(LockedListener before, LockedListener after) + { + if (string.Equals(before.OwnerIdentity, after.OwnerIdentity, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return before.OwnerIdentityStrength == OwnerIdentityStrength.NameOnly + && after.OwnerIdentityStrength != OwnerIdentityStrength.ContainerImage + && OwnerStrengthRank(after.OwnerIdentityStrength) > OwnerStrengthRank(OwnerIdentityStrength.NameOnly) + && !string.IsNullOrWhiteSpace(after.ObservedOwnerNameIdentity) + && string.Equals( + before.OwnerIdentity, + after.ObservedOwnerNameIdentity, + StringComparison.OrdinalIgnoreCase); + } + private static void CompareEvidenceField( string name, EvidenceCompleteness before, diff --git a/src/PortCVE/Cli/CliApplication.cs b/src/PortCVE/Cli/CliApplication.cs index 20a9cb3..3fbc6f8 100644 --- a/src/PortCVE/Cli/CliApplication.cs +++ b/src/PortCVE/Cli/CliApplication.cs @@ -256,6 +256,9 @@ private async Task RunScanAsync( selector, selected, selected.Length == 0 ? null : sbomPath, + options.All + ? VulnerabilitySelectionMode.AllScanCapableSubjects + : VulnerabilitySelectionMode.ExactListeners, cancellationToken); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) @@ -280,10 +283,14 @@ private async Task RunScanAsync( { if (!options.Json) { - error.WriteLine($"error: no TCP listeners matched {selector}."); + error.WriteLine(options.All + ? "error: no scan-capable subjects were found among TCP listeners." + : $"error: no TCP listeners matched {selector}."); } - return ExitCodes.NegativeResult; + return options.All + ? ExitCodes.IncompleteEvidence + : ExitCodes.NegativeResult; } if (!report.HasSuccessfulScan) @@ -611,12 +618,6 @@ private async Task RunLockAsync( return ExitCodes.RuntimeFailure; } - if (options.Strict && !CoreEvidenceComplete(snapshot)) - { - TextRenderer.RenderDiagnostics(snapshot.Diagnostics, error); - return ExitCodes.IncompleteEvidence; - } - var lockListeners = ApplyFilters(snapshot.Listeners, options); if (!options.IncludeUdp && options.Protocol != TransportProtocol.Udp) { @@ -637,14 +638,23 @@ private async Task RunLockAsync( includesUdp, options.IncludeFirewall, selector, - includesContainerEvidence); + includesContainerEvidence, + options.AllowWeakOwner); + if (options.Strict && !CoreEvidenceCompleteForLock(snapshot, lockfile)) + { + TextRenderer.RenderDiagnostics(snapshot.Diagnostics, error); + return ExitCodes.IncompleteEvidence; + } + if (!lockfile.IsComplete && !options.AllowIncomplete) { error.WriteLine("error: refusing to write a baseline with incomplete owner, bind-scope, or requested host-policy evidence."); error.WriteLine( $"evidence: ownership={lockfile.Evidence.Ownership}, bind_scope={lockfile.Evidence.BindScope}, " + $"host_policy={lockfile.Evidence.HostPolicy}, containers={lockfile.Evidence.Containers}"); - error.WriteLine("Run elevated for stronger owner evidence, narrow the filters, or pass --allow-incomplete for a diff-only baseline."); + error.WriteLine( + "Run elevated for stronger owner evidence, narrow the filters, pass --allow-weak-owner to explicitly accept process-name identity, " + + "or pass --allow-incomplete for a diff-only baseline."); return ExitCodes.IncompleteEvidence; } try @@ -664,12 +674,17 @@ await output.WriteLineAsync(JsonOutput.Serialize(new path = options.IncludePrivate ? Path.GetFullPath(path) : Path.GetFileName(path), listener_count = lockfile.Listeners.Count, schema_version = lockfile.SchemaVersion, + allow_weak_owner = lockfile.AllowWeakOwner, evidence = lockfile.Evidence, })); } else { output.WriteLine($"Wrote {lockfile.Listeners.Count} normalized endpoints to {Path.GetFullPath(path)}."); + if (lockfile.AllowWeakOwner) + { + output.WriteLine("Owner policy: process-name identity is explicitly accepted; unknown owners remain incomplete."); + } } TextRenderer.RenderDiagnostics(snapshot.Diagnostics, error); @@ -744,7 +759,7 @@ private async Task RunDiffAsync( var baselineUsedFirewall = baseline.Evidence.HostPolicy != EvidenceCompleteness.NotCollected; var baselineUsedContainers = baseline.Evidence.Containers != EvidenceCompleteness.NotCollected; - var baselineNeedsHashes = baseline.Listeners.Any(static item => + var baselineNeedsHashes = baseline.AllowWeakOwner || baseline.Listeners.Any(static item => item.OwnerIdentityStrength is OwnerIdentityStrength.Sha256 or OwnerIdentityStrength.ContainerImage); var snapshot = await snapshotBuilder.CollectAsync( new( @@ -766,7 +781,8 @@ private async Task RunDiffAsync( baseline.IncludesUdp, baselineUsedFirewall, baseline.Selector, - baselineUsedContainers); + baselineUsedContainers, + baseline.AllowWeakOwner); var current = currentLockfile.Listeners; var changes = ListenerDiffEngine.Compare(baseline.Listeners, current) .Concat(ListenerDiffEngine.CompareEvidence(baseline.Evidence, currentLockfile.Evidence)) @@ -784,6 +800,7 @@ await output.WriteLineAsync(JsonOutput.Serialize(new baseline = options.IncludePrivate ? Path.GetFullPath(options.InputPath!) : Path.GetFileName(options.InputPath!), + allow_weak_owner = baseline.AllowWeakOwner, changed = changes.Length > 0, changes, diagnostics = jsonDiagnostics, @@ -791,6 +808,11 @@ await output.WriteLineAsync(JsonOutput.Serialize(new } else { + if (baseline.AllowWeakOwner) + { + output.WriteLine("Owner policy: process-name identity is accepted; unknown owners remain incomplete."); + } + TextRenderer.RenderChanges(changes, output); TextRenderer.RenderDiagnostics(snapshot.Diagnostics, error); } @@ -799,7 +821,7 @@ await output.WriteLineAsync(JsonOutput.Serialize(new { if (!baseline.IsComplete || !currentLockfile.IsComplete || changes.Any(static change => change.Kind == ListenerChangeKind.EvidenceRegressed) - || (options.Strict && !CoreEvidenceComplete(snapshot))) + || (options.Strict && !CoreEvidenceCompleteForLock(snapshot, currentLockfile))) { if (!options.Json) { @@ -825,7 +847,9 @@ await output.WriteLineAsync(JsonOutput.Serialize(new if (!options.Json) { output.WriteLine(); - output.WriteLine("PASS: no new, widened, or owner-changed listeners."); + output.WriteLine(baseline.AllowWeakOwner + ? "PASS: no new, widened, or owner-changed listeners (stored policy accepts process-name identity)." + : "PASS: no new, widened, or owner-changed listeners."); } } @@ -834,7 +858,7 @@ await output.WriteLineAsync(JsonOutput.Serialize(new && (!baseline.IsComplete || !currentLockfile.IsComplete || changes.Any(static change => change.Kind == ListenerChangeKind.EvidenceRegressed) - || !CoreEvidenceComplete(snapshot)) + || !CoreEvidenceCompleteForLock(snapshot, currentLockfile)) ? ExitCodes.IncompleteEvidence : ExitCodes.Success; } @@ -1111,6 +1135,17 @@ private static bool CoreEvidenceComplete(SystemSnapshot snapshot) .All(static report => report.Status == CollectorStatus.Complete); } + private static bool CoreEvidenceCompleteForLock(SystemSnapshot snapshot, ListenerLockfile lockfile) + { + return snapshot.Collectors + .Where(static report => report.Name != "docker") + .All(report => report.Status == CollectorStatus.Complete + || (report.Name == "process_owners" + && report.Status == CollectorStatus.Partial + && lockfile.AllowWeakOwner + && lockfile.HasSufficientOwnerEvidence)); + } + private static IReadOnlyList WatchListeners( IReadOnlyList listeners, CliOptions options) @@ -1283,7 +1318,7 @@ private static int WriteHelp(TextWriter output) output.WriteLine(" portcve diff listeners.lock Show drift from the live machine"); 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 scan --all Check every mapped Docker image ID"); 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"); @@ -1304,8 +1339,9 @@ private static int WriteHelp(TextWriter output) output.WriteLine(" --resolve-accounts Resolve SIDs; Windows may query domain services"); output.WriteLine(" --include-udp Include UDP binds in lock/watch workflows"); output.WriteLine(" --allow-incomplete Permit a diff-only baseline with weak evidence"); + output.WriteLine(" --allow-weak-owner Create a lock policy accepting process-name identity"); output.WriteLine(" --strict Exit 3 when core evidence is incomplete"); - output.WriteLine(" --all Select every TCP listener for scan"); + output.WriteLine(" --all Scan all mapped Docker image IDs; no guessing"); output.WriteLine(" --sbom Scan an explicitly supplied local SBOM"); output.WriteLine(" --fail-on Gate complete advisory evidence; remote use requires --online-advisories"); output.WriteLine(" --ports Select remote TCP ports for scan-host"); diff --git a/src/PortCVE/Cli/CliOptions.cs b/src/PortCVE/Cli/CliOptions.cs index 5ed308c..3818bd8 100644 --- a/src/PortCVE/Cli/CliOptions.cs +++ b/src/PortCVE/Cli/CliOptions.cs @@ -55,7 +55,8 @@ public sealed record CliOptions( TimeSpan? ConnectTimeout = null, TimeSpan? ReadTimeout = null, int? MaximumHosts = null, - RemoteImportFormat? ImportFormat = null); + RemoteImportFormat? ImportFormat = null, + bool AllowWeakOwner = false); public sealed class CliUsageException(string message) : Exception(message); diff --git a/src/PortCVE/Cli/CliParser.cs b/src/PortCVE/Cli/CliParser.cs index dc2101f..1c09db1 100644 --- a/src/PortCVE/Cli/CliParser.cs +++ b/src/PortCVE/Cli/CliParser.cs @@ -28,6 +28,7 @@ public static CliOptions Parse(IReadOnlyList arguments) var strict = false; var force = false; var allowIncomplete = false; + var allowWeakOwner = false; var includeUdp = false; var includePrivate = false; var resolveAccounts = false; @@ -146,6 +147,9 @@ public static CliOptions Parse(IReadOnlyList arguments) case "--allow-incomplete": allowIncomplete = true; break; + case "--allow-weak-owner": + allowWeakOwner = true; + break; case "--include-udp": includeUdp = true; break; @@ -396,6 +400,11 @@ public static CliOptions Parse(IReadOnlyList arguments) + "--read-timeout, and --max-hosts are available only with scan-host."); } + if (command != CommandKind.Lock && allowWeakOwner) + { + throw new CliUsageException("--allow-weak-owner is available only when creating a lockfile."); + } + if (command == CommandKind.Lock) { if (process is not null || scope is not null) @@ -448,7 +457,8 @@ public static CliOptions Parse(IReadOnlyList arguments) connectTimeout, readTimeout, maximumHosts, - importFormat); + importFormat, + allowWeakOwner); } private static string RequireValue(IReadOnlyList arguments, ref int index, string option) diff --git a/src/PortCVE/Collection/NetworkInterfaceCollector.cs b/src/PortCVE/Collection/NetworkInterfaceCollector.cs index 77bc4a9..c3629fe 100644 --- a/src/PortCVE/Collection/NetworkInterfaceCollector.cs +++ b/src/PortCVE/Collection/NetworkInterfaceCollector.cs @@ -115,7 +115,8 @@ private static async Task> CollectProfilesAsync( var result = await PowerShellJsonRunner.RunAsync( ProfileScript, TimeSpan.FromSeconds(8), - cancellationToken); + cancellationToken, + TrustedWindowsPowerShellModule.NetConnection); if (!result.Succeeded) { diagnostics.Add(new( diff --git a/src/PortCVE/Collection/PowerShellJsonRunner.cs b/src/PortCVE/Collection/PowerShellJsonRunner.cs index 69209f6..9f012e2 100644 --- a/src/PortCVE/Collection/PowerShellJsonRunner.cs +++ b/src/PortCVE/Collection/PowerShellJsonRunner.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Text; +using PortCVE.Vulnerabilities; namespace PortCVE.Collection; @@ -9,35 +11,122 @@ public sealed record PowerShellResult( int? ExitCode, bool TimedOut); +internal enum TrustedWindowsPowerShellModule +{ + NetConnection, + NetSecurity, +} + public static class PowerShellJsonRunner { - public static async Task RunAsync( + internal static async Task RunAsync( string script, TimeSpan timeout, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + params TrustedWindowsPowerShellModule[] trustedModules) { ArgumentException.ThrowIfNullOrWhiteSpace(script); + var systemDirectory = ResolveWindowsSystemDirectory(); + var executableValidation = ResolveWindowsPowerShellExecutable(systemDirectory); + if (!executableValidation.IsValid) + { + return new( + false, + string.Empty, + $"Windows PowerShell could not be resolved to its trusted system path: {executableValidation.Message}", + null, + false); + } + + var moduleRootValidation = ResolveWindowsPowerShellModuleRoot(systemDirectory); + if (!moduleRootValidation.IsValid) + { + return new( + false, + string.Empty, + $"Windows PowerShell's trusted module root is unavailable: {moduleRootValidation.Message}", + null, + false); + } + + var moduleValidations = trustedModules + .Distinct() + .Select(module => ResolveWindowsPowerShellModule(systemDirectory, module)) + .ToArray(); + var invalidModule = moduleValidations.FirstOrDefault(static validation => !validation.IsValid); + if (invalidModule is not null) + { + return new( + false, + string.Empty, + $"A required trusted Windows PowerShell module is unavailable: {invalidModule.Message}", + null, + false); + } + using var process = new Process { - StartInfo = new ProcessStartInfo - { - FileName = "powershell.exe", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }, + StartInfo = CreateStartInfo( + executableValidation.FullPath!, + systemDirectory!, + moduleRootValidation.FullPath!, + moduleValidations.Select(static validation => validation.FullPath!).ToArray(), + script), }; - process.StartInfo.ArgumentList.Add("-NoLogo"); - process.StartInfo.ArgumentList.Add("-NoProfile"); - process.StartInfo.ArgumentList.Add("-NonInteractive"); - process.StartInfo.ArgumentList.Add("-Command"); - process.StartInfo.ArgumentList.Add(script); - try { + var launchValidation = LocalPathPolicy.ValidateExistingWindowsPowerShellExecutable( + executableValidation.FullPath!); + if (!launchValidation.IsValid + || !string.Equals( + launchValidation.FullPath, + executableValidation.FullPath, + StringComparison.OrdinalIgnoreCase)) + { + return new( + false, + string.Empty, + "Windows PowerShell's trusted system path changed before launch.", + null, + false); + } + + var moduleRootLaunchValidation = ResolveWindowsPowerShellModuleRoot(systemDirectory); + if (!moduleRootLaunchValidation.IsValid + || !string.Equals( + moduleRootLaunchValidation.FullPath, + moduleRootValidation.FullPath, + StringComparison.OrdinalIgnoreCase)) + { + return new( + false, + string.Empty, + "Windows PowerShell's trusted module root changed before launch.", + null, + false); + } + + foreach (var moduleValidation in moduleValidations) + { + var launchModuleValidation = LocalPathPolicy.ValidateExistingWindowsPowerShellModule( + moduleValidation.FullPath!); + if (!launchModuleValidation.IsValid + || !string.Equals( + launchModuleValidation.FullPath, + moduleValidation.FullPath, + StringComparison.OrdinalIgnoreCase)) + { + return new( + false, + string.Empty, + "A trusted Windows PowerShell module changed before launch.", + null, + false); + } + } + if (!process.Start()) { return new(false, string.Empty, "PowerShell did not start.", null, false); @@ -74,6 +163,153 @@ public static async Task RunAsync( } } + internal static LocalPathValidation ResolveWindowsPowerShellExecutable() + { + return ResolveWindowsPowerShellExecutable(ResolveWindowsSystemDirectory()); + } + + internal static LocalPathValidation ResolveWindowsPowerShellExecutable(string? systemDirectory) + { + if (string.IsNullOrWhiteSpace(systemDirectory) + || !Path.IsPathFullyQualified(systemDirectory)) + { + return InvalidExecutable("The Windows system directory is not an absolute path."); + } + + string candidate; + try + { + candidate = Path.Combine( + systemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) + { + return InvalidExecutable($"The Windows system directory is invalid: {exception.Message}"); + } + + return LocalPathPolicy.ValidateExistingWindowsPowerShellExecutable(candidate); + } + + internal static LocalPathValidation ResolveWindowsPowerShellModule( + string? systemDirectory, + TrustedWindowsPowerShellModule module) + { + var rootValidation = ResolveWindowsPowerShellModuleRoot(systemDirectory); + if (!rootValidation.IsValid) + { + return rootValidation; + } + + var name = module switch + { + TrustedWindowsPowerShellModule.NetConnection => "NetConnection", + TrustedWindowsPowerShellModule.NetSecurity => "NetSecurity", + _ => throw new ArgumentOutOfRangeException(nameof(module)), + }; + var candidate = Path.Combine(rootValidation.FullPath!, name, $"{name}.psd1"); + return LocalPathPolicy.ValidateExistingWindowsPowerShellModule(candidate); + } + + internal static ProcessStartInfo CreateStartInfo( + string executable, + string systemDirectory, + string moduleRoot, + IReadOnlyList modulePaths, + string script) + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + ApplyLaunchPolicy(startInfo, systemDirectory, moduleRoot); + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-Command"); + startInfo.ArgumentList.Add(ComposeScript(script, modulePaths)); + return startInfo; + } + + internal static void ApplyLaunchPolicy( + ProcessStartInfo startInfo, + string systemDirectory, + string moduleRoot) + { + startInfo.WorkingDirectory = systemDirectory; + ApplyEnvironmentPolicy(startInfo.Environment, systemDirectory, moduleRoot); + } + + private static void ApplyEnvironmentPolicy( + IDictionary environment, + string systemDirectory, + string moduleRoot) + { + environment["PATH"] = systemDirectory; + environment["PSModulePath"] = moduleRoot; + } + + private static string? ResolveWindowsSystemDirectory() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + return Environment.GetFolderPath(Environment.SpecialFolder.System); + } + + private static LocalPathValidation ResolveWindowsPowerShellModuleRoot(string? systemDirectory) + { + if (string.IsNullOrWhiteSpace(systemDirectory) + || !Path.IsPathFullyQualified(systemDirectory)) + { + return new( + false, + null, + "powershell_module_invalid", + "The Windows system directory is not an absolute path."); + } + + var candidate = Path.Combine(systemDirectory, "WindowsPowerShell", "v1.0", "Modules"); + var validation = LocalPathPolicy.ValidateLocalDirectoryPath(candidate); + if (!validation.IsValid || !Directory.Exists(validation.FullPath)) + { + return new( + false, + null, + validation.IsValid ? "powershell_module_not_found" : validation.Code, + validation.IsValid ? "The trusted Windows PowerShell module root was not found." : validation.Message); + } + + return validation; + } + + private static string ComposeScript(string script, IReadOnlyList modulePaths) + { + var securedScript = new StringBuilder(); + securedScript.AppendLine("$ErrorActionPreference = 'Stop'"); + foreach (var modulePath in modulePaths) + { + securedScript + .Append("Import-Module -Name '") + .Append(modulePath.Replace("'", "''", StringComparison.Ordinal)) + .AppendLine("' -Force -ErrorAction Stop"); + } + + securedScript.Append(script); + return securedScript.ToString(); + } + + private static LocalPathValidation InvalidExecutable(string message) => + new(false, null, "powershell_executable_invalid", message); + private static void TryTerminate(Process process) { try diff --git a/src/PortCVE/Collection/WindowsFirewallCollector.cs b/src/PortCVE/Collection/WindowsFirewallCollector.cs index 0201973..d58992e 100644 --- a/src/PortCVE/Collection/WindowsFirewallCollector.cs +++ b/src/PortCVE/Collection/WindowsFirewallCollector.cs @@ -14,7 +14,6 @@ public sealed class WindowsFirewallCollector { private const string FirewallScript = """ $ErrorActionPreference = 'Stop' - Import-Module NetSecurity -ErrorAction Stop $rules = @( Get-NetFirewallRule -PolicyStore ActiveStore -Enabled True -Direction Inbound -ErrorAction Stop @@ -154,7 +153,8 @@ public async Task CollectAsync(CancellationToken cancellatio var result = await PowerShellJsonRunner.RunAsync( FirewallScript, TimeSpan.FromSeconds(30), - cancellationToken); + cancellationToken, + TrustedWindowsPowerShellModule.NetSecurity); stopwatch.Stop(); if (!result.Succeeded) diff --git a/src/PortCVE/Snapshots/LockfileModels.cs b/src/PortCVE/Snapshots/LockfileModels.cs index 73bd9e2..1195272 100644 --- a/src/PortCVE/Snapshots/LockfileModels.cs +++ b/src/PortCVE/Snapshots/LockfileModels.cs @@ -42,7 +42,11 @@ public sealed record LockedListener( string OwnerIdentity, OwnerIdentityStrength OwnerIdentityStrength, Confidence HostPolicyConfidence, - FirewallVerdict HostPolicy); + FirewallVerdict HostPolicy) +{ + [JsonIgnore] + internal string? ObservedOwnerNameIdentity { get; init; } +} public sealed record ListenerLockfile( int SchemaVersion, @@ -50,14 +54,34 @@ public sealed record ListenerLockfile( bool IncludesUdp, LockfileSelector Selector, LockfileEvidence Evidence, - IReadOnlyList Listeners) + IReadOnlyList Listeners, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + bool AllowWeakOwner = false) { public const int CurrentSchemaVersion = 1; + [JsonIgnore] + public bool HasSufficientOwnerEvidence => + (Evidence.Ownership == EvidenceCompleteness.Complete + && Listeners.All(static listener => IsStrongOwner(listener.OwnerIdentityStrength))) + || (AllowWeakOwner + && Evidence.Ownership == EvidenceCompleteness.Partial + && Listeners.Any(static listener => listener.OwnerIdentityStrength == OwnerIdentityStrength.NameOnly) + && Listeners.All(static listener => HasAtLeastNameOnlyOwner(listener.OwnerIdentityStrength))); + [JsonIgnore] public bool IsComplete => - Evidence.Ownership == EvidenceCompleteness.Complete + HasSufficientOwnerEvidence && Evidence.BindScope == EvidenceCompleteness.Complete && Evidence.HostPolicy is EvidenceCompleteness.Complete or EvidenceCompleteness.NotCollected && Evidence.Containers is EvidenceCompleteness.Complete or EvidenceCompleteness.NotCollected; + + private static bool IsStrongOwner(OwnerIdentityStrength strength) => strength is + OwnerIdentityStrength.Sha256 + or OwnerIdentityStrength.ContainerImage + or OwnerIdentityStrength.Service + or OwnerIdentityStrength.Kernel; + + private static bool HasAtLeastNameOnlyOwner(OwnerIdentityStrength strength) => + strength != OwnerIdentityStrength.Unknown; } diff --git a/src/PortCVE/Snapshots/LockfileService.cs b/src/PortCVE/Snapshots/LockfileService.cs index 722e04d..eabd1d6 100644 --- a/src/PortCVE/Snapshots/LockfileService.cs +++ b/src/PortCVE/Snapshots/LockfileService.cs @@ -8,6 +8,9 @@ namespace PortCVE.Snapshots; public sealed class LockfileService { + internal const int MaximumLockfileBytes = 16 * 1024 * 1024; + internal const int MaximumListenerCount = 50_000; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, @@ -21,7 +24,8 @@ public ListenerLockfile Create( bool includesUdp = false, bool includesHostPolicy = false, LockfileSelector? selector = null, - bool includesContainerEvidence = false) + bool includesContainerEvidence = false, + bool allowWeakOwner = false) { var listeners = snapshot.Listeners .Select(ToLockedListener) @@ -56,7 +60,8 @@ public ListenerLockfile Create( includesUdp, selector ?? new(null, null, null, null), evidence, - listeners); + listeners, + allowWeakOwner); } public async Task WriteAsync( @@ -107,7 +112,7 @@ public async Task WriteAsync( public async Task ReadAsync(string path, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(path); - await using var stream = File.OpenRead(Path.GetFullPath(path)); + using var stream = await ReadBoundedAsync(Path.GetFullPath(path), cancellationToken); var result = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) ?? throw new InvalidDataException("The lockfile is empty or invalid."); @@ -129,6 +134,12 @@ public async Task ReadAsync(string path, CancellationToken can private static void Validate(ListenerLockfile lockfile) { + if (lockfile.Listeners.Count > MaximumListenerCount) + { + throw new InvalidDataException( + $"Lockfile contains more than {MaximumListenerCount:N0} listeners."); + } + if (string.IsNullOrWhiteSpace(lockfile.CreatedBy)) { throw new InvalidDataException("Lockfile created_by must not be blank."); @@ -206,6 +217,54 @@ private static void Validate(ListenerLockfile lockfile) } } + private static async Task ReadBoundedAsync( + string path, + CancellationToken cancellationToken) + { + await using var source = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + if (source.Length > MaximumLockfileBytes) + { + throw new InvalidDataException("Lockfile exceeds the 16 MiB input limit."); + } + + var output = new MemoryStream((int)source.Length); + try + { + var buffer = new byte[81920]; + var total = 0; + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + break; + } + + total += read; + if (total > MaximumLockfileBytes) + { + throw new InvalidDataException("Lockfile exceeds the 16 MiB input limit."); + } + + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + + output.Position = 0; + return output; + } + catch + { + output.Dispose(); + throw; + } + } + public static LockedListener ToLockedListener(ListenerEvidence listener) { var (ownerIdentity, strength) = OwnerIdentity(listener); @@ -229,7 +288,10 @@ public static LockedListener ToLockedListener(ListenerEvidence listener) ownerIdentity, strength, listener.HostPolicy.Confidence, - listener.HostPolicy.Verdict); + listener.HostPolicy.Verdict) + { + ObservedOwnerNameIdentity = ProcessNameIdentity(listener.Owner), + }; } private static (string Identity, OwnerIdentityStrength Strength) OwnerIdentity(ListenerEvidence listener) @@ -272,5 +334,11 @@ private static (string Identity, OwnerIdentityStrength Strength) OwnerIdentity(L return ("unknown", OwnerIdentityStrength.Unknown); } + private static string? ProcessNameIdentity(OwnerEvidence owner) => + !string.IsNullOrWhiteSpace(owner.ImageName) + && !owner.ImageName.StartsWith("pid-", StringComparison.Ordinal) + ? $"process:{owner.ImageName.ToLowerInvariant()}" + : null; + public static JsonSerializerOptions SerializerOptions => JsonOptions; } diff --git a/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs b/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs index 47b1e6d..07fb5d3 100644 --- a/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs +++ b/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs @@ -53,6 +53,24 @@ public static LocalPathValidation ValidateExistingTrivyExecutable(string path) return ValidateLocalFile(path, requireExists: true, "trivy_executable", "Trivy executable"); } + public static LocalPathValidation ValidateExistingWindowsPowerShellExecutable(string path) + { + return ValidateLocalFile( + path, + requireExists: true, + "powershell_executable", + "Windows PowerShell executable"); + } + + public static LocalPathValidation ValidateExistingWindowsPowerShellModule(string path) + { + return ValidateLocalFile( + path, + requireExists: true, + "powershell_module", + "Windows PowerShell module"); + } + public static LocalPathValidation ValidateExistingTrivyDatabaseFile(string path) { return ValidateLocalFile(path, requireExists: true, "trivy_database", "Trivy database"); diff --git a/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs b/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs index 57653c8..885e961 100644 --- a/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs +++ b/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs @@ -26,17 +26,18 @@ internal sealed partial class TrivyVulnerabilityScanner : IVulnerabilityScanner "ALL_PROXY", ]; - private readonly string executable; + private readonly string? configuredExecutable; private readonly string cacheDirectory; private readonly IProcessRunner processRunner; private readonly TimeProvider timeProvider; private readonly TimeSpan scanTimeout; private readonly string tempRootDirectory; + private readonly Func executableLocator; private readonly Func cachePathValidator; public TrivyVulnerabilityScanner() : this( - Environment.GetEnvironmentVariable("PORTCVE_TRIVY_PATH") ?? "trivy.exe", + Environment.GetEnvironmentVariable("PORTCVE_TRIVY_PATH"), ResolveCacheDirectory(), new BoundedProcessRunner(), TimeProvider.System, @@ -46,21 +47,23 @@ public TrivyVulnerabilityScanner() } internal TrivyVulnerabilityScanner( - string executable, + string? executable, string cacheDirectory, IProcessRunner processRunner, TimeProvider timeProvider, TimeSpan scanTimeout, string? tempRootDirectory = null, - Func? cachePathValidator = null) + Func? cachePathValidator = null, + Func? executableLocator = null) { - this.executable = executable; + configuredExecutable = executable; this.cacheDirectory = Path.GetFullPath(cacheDirectory); this.processRunner = processRunner; this.timeProvider = timeProvider; this.scanTimeout = scanTimeout; this.tempRootDirectory = Path.GetFullPath(tempRootDirectory ?? ResolveTempRootDirectory()); this.cachePathValidator = cachePathValidator ?? LocalPathPolicy.ValidateLocalDirectoryPath; + this.executableLocator = executableLocator ?? TrivyDatabaseService.LocateExecutable; } public Task ScanContainerImageAsync( @@ -199,6 +202,12 @@ private async Task ScanAsync( IReadOnlyList scanArguments, CancellationToken cancellationToken) { + var executableValidation = ValidateExecutablePath(); + if (!executableValidation.IsValid) + { + return UnsafeExecutable(executableValidation, 0); + } + var cacheValidation = ValidateCachePaths(); if (!cacheValidation.IsValid) { @@ -216,7 +225,11 @@ private async Task ScanAsync( try { - return await ScanWithTempAsync(scanArguments, temp.Path, cancellationToken); + return await ScanWithTempAsync( + scanArguments, + temp.Path, + executableValidation.FullPath!, + cancellationToken); } finally { @@ -258,11 +271,18 @@ private LocalPathValidation ValidateCachePaths() private async Task ScanWithTempAsync( IReadOnlyList scanArguments, string tempDirectory, + string executablePath, CancellationToken cancellationToken) { + var executableValidation = ValidateExecutablePath(executablePath); + if (!executableValidation.IsValid) + { + return UnsafeExecutable(executableValidation, 0); + } + var versionResult = await processRunner.RunAsync( new( - executable, + executableValidation.FullPath!, ["--version"], TimeSpan.FromSeconds(10), 64 * 1024, @@ -288,6 +308,12 @@ private async Task ScanWithTempAsync( } var engineVersion = ParseEngineVersion(versionResult.StandardOutput); + executableValidation = ValidateExecutablePath(executablePath); + if (!executableValidation.IsValid) + { + return UnsafeExecutable(executableValidation, versionResult.DurationMs, engineVersion); + } + var cacheValidation = ValidateCachePaths(); if (!cacheValidation.IsValid) { @@ -318,9 +344,15 @@ private async Task ScanWithTempAsync( return UnsafeCache(cacheValidation, versionResult.DurationMs, engineVersion); } + executableValidation = ValidateExecutablePath(executablePath); + if (!executableValidation.IsValid) + { + return UnsafeExecutable(executableValidation, versionResult.DurationMs, engineVersion); + } + var scanResult = await processRunner.RunAsync( new( - executable, + executableValidation.FullPath!, scanArguments, scanTimeout, MaxScanOutputCharacters, @@ -380,6 +412,38 @@ private async Task ScanWithTempAsync( stale ? ["The vulnerability database is older than the configured freshness limit."] : []); } + private LocalPathValidation ValidateExecutablePath(string? expectedPath = null) + { + var validation = executableLocator(configuredExecutable); + if (!validation.IsValid) + { + return validation; + } + + if (string.IsNullOrWhiteSpace(validation.FullPath) + || !Path.IsPathFullyQualified(validation.FullPath) + || !Path.GetExtension(validation.FullPath).Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + return new( + false, + null, + "trivy_executable_invalid", + "Trivy did not resolve to an absolute Windows .exe path."); + } + + if (expectedPath is not null + && !string.Equals(validation.FullPath, expectedPath, StringComparison.OrdinalIgnoreCase)) + { + return new( + false, + null, + "trivy_executable_changed", + "The resolved Trivy executable path changed during the scan."); + } + + return validation; + } + private IReadOnlyList CommonArguments() => [ "--scanners", "vuln", @@ -554,6 +618,18 @@ private static VulnerabilityScanResult UnsafeCache( durationMs, engineVersion); + private static VulnerabilityScanResult UnsafeExecutable( + LocalPathValidation validation, + long durationMs, + string? engineVersion = null) => + Unavailable( + validation.Code.StartsWith("trivy_executable_", StringComparison.Ordinal) + ? validation.Code + : "trivy_executable_unsafe", + $"Trivy could not be resolved to a safe existing local executable: {validation.Message}", + durationMs, + engineVersion); + private static VulnerabilityScanResult Failed( string code, string message, diff --git a/src/PortCVE/Vulnerabilities/VulnerabilityAssessmentService.cs b/src/PortCVE/Vulnerabilities/VulnerabilityAssessmentService.cs index df5db92..67941f9 100644 --- a/src/PortCVE/Vulnerabilities/VulnerabilityAssessmentService.cs +++ b/src/PortCVE/Vulnerabilities/VulnerabilityAssessmentService.cs @@ -4,6 +4,12 @@ namespace PortCVE.Vulnerabilities; +internal enum VulnerabilitySelectionMode +{ + ExactListeners, + AllScanCapableSubjects, +} + internal sealed class VulnerabilityAssessmentService(IVulnerabilityScanner scanner) { public async Task AssessAsync( @@ -11,6 +17,7 @@ public async Task AssessAsync( string selector, IReadOnlyList listeners, string? sbomPath, + VulnerabilitySelectionMode selectionMode, CancellationToken cancellationToken) { var subjects = new List(); @@ -139,28 +146,39 @@ providerRun.Status is VulnerabilityProviderStatus.Failed or VulnerabilityProvide ref findingNumber); } - foreach (var listener in listeners - .Where(listener => !coveredListeners.Contains(listener)) - .OrderBy(static listener => listener.Key, StringComparer.Ordinal)) + if (selectionMode == VulnerabilitySelectionMode.ExactListeners) + { + foreach (var listener in listeners + .Where(listener => !coveredListeners.Contains(listener)) + .OrderBy(static listener => listener.Key, StringComparer.Ordinal)) + { + var subjectId = $"subject-{++subjectNumber:000}"; + const string limitation = + "No exact Docker image ID or explicitly supplied SBOM was available for this listener; native product inference is intentionally unsupported."; + subjects.Add(new( + subjectId, + VulnerabilitySubjectKind.HostProcess, + listener.Owner.ImageName, + listener.Owner.ImagePath, + listener.Owner.ImageSha256, + VulnerabilityIdentityConfidence.Unresolved, + [ToReference(listener)], + VulnerabilityScanStatus.NotSupported, + [limitation])); + diagnostics.Add(new( + "portcve", + VulnerabilityProviderStatus.Partial, + "exact_product_identity_unavailable", + $"{listener.Key}: {limitation}")); + } + } + else if (subjects.Count == 0) { - var subjectId = $"subject-{++subjectNumber:000}"; - const string limitation = - "No exact Docker image ID or explicitly supplied SBOM was available for this listener; native product inference is intentionally unsupported."; - subjects.Add(new( - subjectId, - VulnerabilitySubjectKind.HostProcess, - listener.Owner.ImageName, - listener.Owner.ImagePath, - listener.Owner.ImageSha256, - VulnerabilityIdentityConfidence.Unresolved, - [ToReference(listener)], - VulnerabilityScanStatus.NotSupported, - [limitation])); diagnostics.Add(new( "portcve", VulnerabilityProviderStatus.Partial, - "exact_product_identity_unavailable", - $"{listener.Key}: {limitation}")); + "no_scan_capable_subjects", + "No immutable Docker image IDs were available among the selected TCP listeners.")); } var completeSubjects = subjects.Count(static subject => diff --git a/tests/PortCVE.Tests/CliApplicationTests.cs b/tests/PortCVE.Tests/CliApplicationTests.cs index 7dd1df3..ddc59ad 100644 --- a/tests/PortCVE.Tests/CliApplicationTests.cs +++ b/tests/PortCVE.Tests/CliApplicationTests.cs @@ -57,6 +57,147 @@ public async Task CheckStrict_IncompleteCollectorNeverPrintsPass() } } + [Fact] + public async Task LockAndCheckStrict_ExplicitWeakOwnerPolicyAcceptsStableProcessName() + { + var snapshot = OwnerSnapshot("agent.exe", imageSha256: null, CollectorStatus.Partial); + var service = new LockfileService(); + var path = Path.Combine(Path.GetTempPath(), $"portcve-weak-owner-{Guid.NewGuid():N}.lock.json"); + try + { + var application = new CliApplication(new FixedSnapshotBuilder(snapshot), service); + using var lockOutput = new StringWriter(); + using var lockError = new StringWriter(); + + var lockExit = await application.RunAsync( + new( + CommandKind.Lock, + OutputPath: path, + Strict: true, + AllowWeakOwner: true), + lockOutput, + lockError, + CancellationToken.None); + + Assert.Equal(ExitCodes.Success, lockExit); + var baseline = await service.ReadAsync(path, CancellationToken.None); + Assert.True(baseline.AllowWeakOwner); + Assert.Equal(EvidenceCompleteness.Partial, baseline.Evidence.Ownership); + Assert.True(baseline.IsComplete); + + using var checkOutput = new StringWriter(); + using var checkError = new StringWriter(); + var checkExit = await application.RunAsync( + new(CommandKind.Check, InputPath: path, Strict: true), + checkOutput, + checkError, + CancellationToken.None); + + Assert.Equal(ExitCodes.Success, checkExit); + Assert.Contains("PASS", checkOutput.ToString(), StringComparison.Ordinal); + Assert.Contains("stored policy accepts process-name identity", checkOutput.ToString(), StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task Check_WeakOwnerPolicyRejectsUnknownCurrentOwnerAsIncomplete() + { + var result = await RunWeakOwnerCheckAsync( + OwnerSnapshot("pid-100", imageSha256: null, CollectorStatus.Partial)); + + Assert.Equal(ExitCodes.IncompleteEvidence, result.ExitCode); + Assert.Contains("INCOMPLETE", result.Output, StringComparison.Ordinal); + Assert.DoesNotContain("PASS", result.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task Check_WeakOwnerPolicyFailsChangedProcessName() + { + var result = await RunWeakOwnerCheckAsync( + OwnerSnapshot("other.exe", imageSha256: null, CollectorStatus.Partial)); + + Assert.Equal(ExitCodes.NegativeResult, result.ExitCode); + Assert.Contains("ownerchanged", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FAIL", result.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task Check_NameOnlyBaselineToShaForSameProcessPassesWithoutUpgradingBaseline() + { + var result = await RunWeakOwnerCheckAsync( + OwnerSnapshot("agent.exe", new string('a', 64), CollectorStatus.Complete)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Contains("evidenceimproved", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("ownerchanged", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PASS", result.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task Check_ShaBaselineToNameOnlyRemainsIncompleteEvenWhenPolicyAllowsWeakOwners() + { + var service = new LockfileService(); + var baseline = service.Create( + OwnerSnapshot("agent.exe", new string('a', 64), CollectorStatus.Complete), + allowWeakOwner: true); + var path = Path.Combine(Path.GetTempPath(), $"portcve-strong-owner-{Guid.NewGuid():N}.lock.json"); + try + { + await service.WriteAsync(path, baseline, overwrite: false, CancellationToken.None); + var application = new CliApplication( + new FixedSnapshotBuilder(OwnerSnapshot("agent.exe", imageSha256: null, CollectorStatus.Partial)), + service); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new(CommandKind.Check, InputPath: path, Json: true, Strict: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Contains("\"allow_weak_owner\": true", output.ToString(), StringComparison.Ordinal); + Assert.Contains("evidence_regressed", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task Check_OversizedLockfileReturnsStableUsageError() + { + var path = Path.Combine(Path.GetTempPath(), $"portcve-oversized-cli-{Guid.NewGuid():N}.lock.json"); + try + { + await File.WriteAllBytesAsync(path, new byte[LockfileService.MaximumLockfileBytes + 1]); + var application = new CliApplication( + new FixedSnapshotBuilder(EmptySnapshot(CollectorStatus.Unavailable)), + new LockfileService()); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new(CommandKind.Check, InputPath: path), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.UsageOrSchema, exitCode); + Assert.Contains("16 MiB", error.ToString(), StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } + [Theory] [InlineData(CommandKind.Doctor)] [InlineData(CommandKind.Watch)] @@ -117,7 +258,8 @@ public async Task Diff_ReportsRequiredContainerEvidenceRegression( var lockfileService = new LockfileService(); var baseline = lockfileService.Create( baselineSnapshot, - includesContainerEvidence: true); + includesContainerEvidence: true, + allowWeakOwner: true); var path = Path.Combine(Path.GetTempPath(), $"portcve-diff-{Guid.NewGuid():N}.lock.json"); try { @@ -230,6 +372,88 @@ private static SystemSnapshot IncompleteSnapshot() [diagnostic]); } + private static SystemSnapshot OwnerSnapshot( + string imageName, + string? imageSha256, + CollectorStatus ownerStatus) + { + var limitation = ownerStatus == CollectorStatus.Complete + ? Array.Empty() + : ["Only process-name owner identity was available."]; + var listener = new ListenerEvidence( + "tcp/ipv4/0.0.0.0/8443", + TransportProtocol.Tcp, + IpFamily.Ipv4, + "0.0.0.0", + 8443, + "LISTEN", + BindScope.Wildcard, + "all IPv4 interfaces", + new( + 100, + DateTimeOffset.UnixEpoch, + imageName, + imageSha256 is null ? null : $"C:\\Apps\\{imageName}", + imageSha256, + null, + null, + null, + null, + [], + false, + ownerStatus == CollectorStatus.Complete, + limitation), + [], + HostPolicyEvidence.NotEvaluated, + [], + limitation); + var diagnostics = ownerStatus == CollectorStatus.Complete + ? Array.Empty() + : [new("process_owners", ownerStatus, "owner_name_only", "Only process-name owner identity was available.")]; + return new( + 1, + "test", + DateTimeOffset.UnixEpoch, + 1, + "Windows", + [ + new("sockets", CollectorStatus.Complete, DateTimeOffset.UnixEpoch, 1, []), + new("process_owners", ownerStatus, DateTimeOffset.UnixEpoch, 1, diagnostics), + new("interfaces", CollectorStatus.Complete, DateTimeOffset.UnixEpoch, 1, []), + new("docker", CollectorStatus.Unavailable, DateTimeOffset.UnixEpoch, 1, []), + ], + [], + [listener], + diagnostics); + } + + private static async Task<(int ExitCode, string Output)> RunWeakOwnerCheckAsync( + SystemSnapshot currentSnapshot) + { + var service = new LockfileService(); + var baseline = service.Create( + OwnerSnapshot("agent.exe", imageSha256: null, CollectorStatus.Partial), + allowWeakOwner: true); + var path = Path.Combine(Path.GetTempPath(), $"portcve-weak-check-{Guid.NewGuid():N}.lock.json"); + try + { + await service.WriteAsync(path, baseline, overwrite: false, CancellationToken.None); + var application = new CliApplication(new FixedSnapshotBuilder(currentSnapshot), service); + using var output = new StringWriter(); + using var error = new StringWriter(); + var exitCode = await application.RunAsync( + new(CommandKind.Check, InputPath: path, Strict: true), + output, + error, + CancellationToken.None); + return (exitCode, output.ToString()); + } + finally + { + File.Delete(path); + } + } + private static SystemSnapshot EmptySnapshot(CollectorStatus dockerStatus) => new( 1, "test", diff --git a/tests/PortCVE.Tests/CliParserTests.cs b/tests/PortCVE.Tests/CliParserTests.cs index e240698..125eaec 100644 --- a/tests/PortCVE.Tests/CliParserTests.cs +++ b/tests/PortCVE.Tests/CliParserTests.cs @@ -82,12 +82,28 @@ public void Parse_LockRejectsMetadataSelectorsThatCouldFailOpen(string option, s public void Parse_PrivacyAndBaselineFlags_AreWired() { var result = CliParser.Parse( - ["lock", "--include-private", "--resolve-accounts", "--include-udp", "--allow-incomplete"]); + ["lock", "--include-private", "--resolve-accounts", "--include-udp", "--allow-incomplete", "--allow-weak-owner"]); Assert.True(result.IncludePrivate); Assert.True(result.ResolveAccounts); Assert.True(result.IncludeUdp); Assert.True(result.AllowIncomplete); + Assert.True(result.AllowWeakOwner); + } + + [Theory] + [InlineData("list")] + [InlineData("check", "baseline.json")] + [InlineData("diff", "baseline.json")] + [InlineData("scan", "tcp:443")] + public void Parse_AllowWeakOwnerIsRestrictedToLockCreation(params string[] command) + { + var arguments = command.Append("--allow-weak-owner").ToArray(); + + var error = Assert.Throws(() => CliParser.Parse(arguments)); + + Assert.Contains("only", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("lockfile", error.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -107,12 +123,17 @@ public void Parse_ScanExactTcpSubjectAndPolicyFlags_AreWired() } [Fact] - public void Parse_ScanAll_IsTcpOnly() + public void Parse_ScanAllWithGate_IsTcpOnlyAndWiresPolicy() { - var result = CliParser.Parse(["scan", "--all"]); + var result = CliParser.Parse(["scan", "--all", "--fail-on", "high", "--strict", "--json"]); Assert.True(result.All); Assert.Equal(TransportProtocol.Tcp, result.Protocol); + Assert.Equal(VulnerabilitySeverity.High, result.FailOn); + Assert.True(result.Strict); + Assert.True(result.Json); + Assert.Null(result.Port); + Assert.Null(result.SbomPath); } [Theory] diff --git a/tests/PortCVE.Tests/ListenerDiffEngineTests.cs b/tests/PortCVE.Tests/ListenerDiffEngineTests.cs index 0caf852..ba94094 100644 --- a/tests/PortCVE.Tests/ListenerDiffEngineTests.cs +++ b/tests/PortCVE.Tests/ListenerDiffEngineTests.cs @@ -86,6 +86,85 @@ public void Compare_RemovedContainerMappingIsOwnerChangeWhenDockerEvidenceIsComp Assert.Contains("container-backed", change.Summary, StringComparison.Ordinal); } + [Fact] + public void Compare_NameOnlyToShaForSameObservedProcessIsEvidenceImprovementNotOwnerChange() + { + var before = Listener("tcp/ipv4/0.0.0.0/8080", "process:web.exe", BindScope.Wildcard); + var after = before with + { + OwnerIdentity = $"sha256:{new string('a', 64)}", + OwnerIdentityStrength = OwnerIdentityStrength.Sha256, + ObservedOwnerNameIdentity = "process:web.exe", + }; + + var change = Assert.Single(ListenerDiffEngine.Compare([before], [after])); + + Assert.Equal(ListenerChangeKind.EvidenceImproved, change.Kind); + Assert.DoesNotContain("owner changed", change.Summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Compare_NameOnlyToShaForDifferentObservedProcessIsOwnerChange() + { + var before = Listener("tcp/ipv4/0.0.0.0/8080", "process:web.exe", BindScope.Wildcard); + var after = before with + { + OwnerIdentity = $"sha256:{new string('a', 64)}", + OwnerIdentityStrength = OwnerIdentityStrength.Sha256, + ObservedOwnerNameIdentity = "process:other.exe", + }; + + var change = Assert.Single(ListenerDiffEngine.Compare([before], [after])); + + Assert.Equal(ListenerChangeKind.OwnerChanged, change.Kind); + } + + [Fact] + public void Compare_ShaToNameOnlyRemainsEvidenceRegression() + { + var before = Listener("tcp/ipv4/0.0.0.0/8080", $"sha256:{new string('a', 64)}", BindScope.Wildcard) with + { + OwnerIdentityStrength = OwnerIdentityStrength.Sha256, + }; + var after = Listener("tcp/ipv4/0.0.0.0/8080", "process:web.exe", BindScope.Wildcard); + + var change = Assert.Single(ListenerDiffEngine.Compare([before], [after])); + + Assert.Equal(ListenerChangeKind.EvidenceRegressed, change.Kind); + } + + [Fact] + public void Compare_NameOnlyToContainerIdentityRemainsOwnerChange() + { + var before = Listener("tcp/ipv4/0.0.0.0/8080", "process:docker.exe", BindScope.Wildcard); + var after = before with + { + OwnerIdentity = "container-image-set:digest", + OwnerIdentityStrength = OwnerIdentityStrength.ContainerImage, + ObservedOwnerNameIdentity = "process:docker.exe", + }; + + var change = Assert.Single(ListenerDiffEngine.Compare([before], [after])); + + Assert.Equal(ListenerChangeKind.OwnerChanged, change.Kind); + Assert.Contains("container-backed", change.Summary, StringComparison.Ordinal); + } + + [Fact] + public void Compare_NameOnlyToShaWithNarrowerScopeDoesNotBecomeRemoveAndAdd() + { + var before = Listener("tcp/ipv4/0.0.0.0/8080", "process:web.exe", BindScope.Wildcard); + var after = Listener("tcp/ipv4/127.0.0.1/8080", $"sha256:{new string('a', 64)}", BindScope.Loopback) with + { + OwnerIdentityStrength = OwnerIdentityStrength.Sha256, + ObservedOwnerNameIdentity = "process:web.exe", + }; + + var change = Assert.Single(ListenerDiffEngine.Compare([before], [after])); + + Assert.Equal(ListenerChangeKind.ExposureNarrowed, change.Kind); + } + private static LockedListener Listener( string key, string owner, diff --git a/tests/PortCVE.Tests/LockfileServiceTests.cs b/tests/PortCVE.Tests/LockfileServiceTests.cs index 399b885..d62e1e7 100644 --- a/tests/PortCVE.Tests/LockfileServiceTests.cs +++ b/tests/PortCVE.Tests/LockfileServiceTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using PortCVE.Domain; using PortCVE.Output; using PortCVE.Snapshots; @@ -23,6 +24,146 @@ public void Create_IsNormalizedSortedAndOmitsRuntimeIdentity() Assert.DoesNotContain("\"pid\"", json, StringComparison.Ordinal); Assert.DoesNotContain("command", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("900", json, StringComparison.Ordinal); + Assert.DoesNotContain("allow_weak_owner", json, StringComparison.Ordinal); + } + + [Fact] + public void Create_ExplicitWeakOwnerPolicyAcceptsNameOnlyWithoutRelabelingEvidence() + { + var result = new LockfileService().Create( + Snapshot(Listener("tcp/ipv4/0.0.0.0/443", 443, 100, "server.exe")), + allowWeakOwner: true); + + var listener = Assert.Single(result.Listeners); + Assert.Equal(OwnerIdentityStrength.NameOnly, listener.OwnerIdentityStrength); + Assert.Equal(EvidenceCompleteness.Partial, result.Evidence.Ownership); + Assert.True(result.AllowWeakOwner); + Assert.True(result.HasSufficientOwnerEvidence); + Assert.True(result.IsComplete); + Assert.Contains("\"allow_weak_owner\": true", JsonOutput.Serialize(result), StringComparison.Ordinal); + } + + [Fact] + public void Create_ExplicitWeakOwnerPolicyNeverAcceptsUnknownOwner() + { + var listener = Listener("tcp/ipv4/0.0.0.0/443", 443, 100, "server.exe") with + { + Owner = Listener("tcp/ipv4/0.0.0.0/443", 443, 100, "server.exe").Owner with + { + ImageName = "pid-100", + ImagePath = null, + IsComplete = false, + }, + }; + + var result = new LockfileService().Create( + Snapshot(listener), + allowWeakOwner: true); + + Assert.Equal(OwnerIdentityStrength.Unknown, Assert.Single(result.Listeners).OwnerIdentityStrength); + Assert.False(result.HasSufficientOwnerEvidence); + Assert.False(result.IsComplete); + } + + [Fact] + public void WeakOwnerPolicyRejectsMixedUnknownAndHandAuthoredPartialWithoutNameOnly() + { + var weak = Listener("tcp/ipv4/0.0.0.0/443", 443, 100, "server.exe"); + var unknown = Listener("tcp/ipv4/0.0.0.0/8443", 8443, 101, "server.exe") with + { + Owner = weak.Owner with + { + Pid = 101, + ImageName = "pid-101", + ImagePath = null, + IsComplete = false, + }, + }; + var mixed = new LockfileService().Create( + Snapshot(weak, unknown), + allowWeakOwner: true); + + Assert.Contains(mixed.Listeners, listener => listener.OwnerIdentityStrength == OwnerIdentityStrength.NameOnly); + Assert.Contains(mixed.Listeners, listener => listener.OwnerIdentityStrength == OwnerIdentityStrength.Unknown); + Assert.False(mixed.HasSufficientOwnerEvidence); + Assert.False(mixed.IsComplete); + + var strong = new LockfileService().Create(Snapshot(weak with + { + Owner = weak.Owner with { ImageSha256 = new string('a', 64) }, + }), allowWeakOwner: true); + var handAuthoredPartial = strong with + { + Evidence = strong.Evidence with { Ownership = EvidenceCompleteness.Partial }, + }; + + Assert.False(handAuthoredPartial.HasSufficientOwnerEvidence); + Assert.False(handAuthoredPartial.IsComplete); + } + + [Fact] + public async Task ReadAsync_LegacyWeakLockWithoutPolicyRemainsIncomplete() + { + var service = new LockfileService(); + var path = Path.Combine(Path.GetTempPath(), $"portcve-legacy-weak-{Guid.NewGuid():N}.lock.json"); + try + { + var lockfile = service.Create( + Snapshot(Listener("tcp/ipv4/0.0.0.0/443", 443, 100, "server.exe")), + allowWeakOwner: true); + await service.WriteAsync(path, lockfile, overwrite: false, CancellationToken.None); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + Assert.True(document.Remove("allow_weak_owner")); + await File.WriteAllTextAsync(path, document.ToJsonString(LockfileService.SerializerOptions)); + + var legacy = await service.ReadAsync(path, CancellationToken.None); + + Assert.False(legacy.AllowWeakOwner); + Assert.Equal(EvidenceCompleteness.Partial, legacy.Evidence.Ownership); + Assert.False(legacy.IsComplete); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ReadAsync_RejectsInputAboveByteLimit() + { + var path = Path.Combine(Path.GetTempPath(), $"portcve-oversized-{Guid.NewGuid():N}.lock.json"); + try + { + await File.WriteAllBytesAsync(path, new byte[LockfileService.MaximumLockfileBytes + 1]); + + var error = await Assert.ThrowsAsync(() => + new LockfileService().ReadAsync(path, CancellationToken.None)); + + Assert.Contains("16 MiB", error.Message, StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task WriteAsync_RejectsListenerCountAboveLimitBeforeCreatingFile() + { + var service = new LockfileService(); + var template = service.Create( + Snapshot(Listener("tcp/ipv4/0.0.0.0/443", 443, 100, "server.exe"))).Listeners[0]; + var lockfile = service.Create(Snapshot()) with + { + Listeners = Enumerable.Repeat(template, LockfileService.MaximumListenerCount + 1).ToArray(), + }; + var path = Path.Combine(Path.GetTempPath(), $"portcve-too-many-{Guid.NewGuid():N}.lock.json"); + + var error = await Assert.ThrowsAsync(() => + service.WriteAsync(path, lockfile, overwrite: false, CancellationToken.None)); + + Assert.Contains("50,000 listeners", error.Message, StringComparison.Ordinal); + Assert.False(File.Exists(path)); } [Fact] diff --git a/tests/PortCVE.Tests/PowerShellJsonRunnerTests.cs b/tests/PortCVE.Tests/PowerShellJsonRunnerTests.cs new file mode 100644 index 0000000..d272148 --- /dev/null +++ b/tests/PortCVE.Tests/PowerShellJsonRunnerTests.cs @@ -0,0 +1,149 @@ +using PortCVE.Collection; + +namespace PortCVE.Tests; + +public sealed class PowerShellJsonRunnerTests +{ + [Fact] + public void ExecutableResolution_UsesExistingAbsoluteWindowsSystemPath() + { + var validation = PowerShellJsonRunner.ResolveWindowsPowerShellExecutable(); + + Assert.True(validation.IsValid, validation.Message); + Assert.True(Path.IsPathFullyQualified(validation.FullPath!)); + Assert.Equal("powershell.exe", Path.GetFileName(validation.FullPath)); + Assert.StartsWith( + Environment.GetFolderPath(Environment.SpecialFolder.System), + validation.FullPath!, + StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("powershell.exe")] + [InlineData("relative\\system32")] + [InlineData("\\\\server\\share\\system32")] + public void ExecutableResolution_RejectsUntrustedSystemDirectory(string systemDirectory) + { + var validation = PowerShellJsonRunner.ResolveWindowsPowerShellExecutable(systemDirectory); + + Assert.False(validation.IsValid); + Assert.Null(validation.FullPath); + } + + [Fact] + public void ExecutableResolution_RejectsMissingSystemPath() + { + var missing = Path.Combine(Path.GetTempPath(), $"portcve-missing-system-{Guid.NewGuid():N}"); + + var validation = PowerShellJsonRunner.ResolveWindowsPowerShellExecutable(missing); + + Assert.False(validation.IsValid); + Assert.Equal("powershell_executable_not_found", validation.Code); + } + + [Fact] + public void ExecutableResolution_RejectsReparseSystemPath() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-powershell-link-{Guid.NewGuid():N}"); + var target = Path.Combine(parent, "target"); + var link = Path.Combine(parent, "system32"); + var executable = Path.Combine(target, "WindowsPowerShell", "v1.0", "powershell.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(executable)!); + File.WriteAllBytes(executable, []); + Directory.CreateSymbolicLink(link, target); + try + { + var validation = PowerShellJsonRunner.ResolveWindowsPowerShellExecutable(link); + + Assert.False(validation.IsValid); + Assert.Equal("powershell_executable_reparse", validation.Code); + } + finally + { + Directory.Delete(link); + Directory.Delete(parent, recursive: true); + } + } + + [Fact] + public void ModuleResolution_RejectsReparseManifest() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-module-link-{Guid.NewGuid():N}"); + var systemDirectory = Path.Combine(parent, "system32"); + var moduleDirectory = Path.Combine( + systemDirectory, + "WindowsPowerShell", + "v1.0", + "Modules", + "NetSecurity"); + var target = Path.Combine(parent, "attacker.psd1"); + var link = Path.Combine(moduleDirectory, "NetSecurity.psd1"); + Directory.CreateDirectory(moduleDirectory); + File.WriteAllText(target, "throw 'attacker module executed'"); + File.CreateSymbolicLink(link, target); + try + { + var validation = PowerShellJsonRunner.ResolveWindowsPowerShellModule( + systemDirectory, + TrustedWindowsPowerShellModule.NetSecurity); + + Assert.False(validation.IsValid); + Assert.Equal("powershell_module_reparse", validation.Code); + } + finally + { + File.Delete(link); + Directory.Delete(parent, recursive: true); + } + } + + [Fact] + public void StartInfo_PinsExecutableWorkingDirectoryModulesAndSearchPaths() + { + var systemDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System); + var executable = PowerShellJsonRunner.ResolveWindowsPowerShellExecutable(systemDirectory); + var module = PowerShellJsonRunner.ResolveWindowsPowerShellModule( + systemDirectory, + TrustedWindowsPowerShellModule.NetSecurity); + var moduleRoot = Path.Combine(systemDirectory, "WindowsPowerShell", "v1.0", "Modules"); + Assert.True(executable.IsValid, executable.Message); + Assert.True(module.IsValid, module.Message); + + var startInfo = PowerShellJsonRunner.CreateStartInfo( + executable.FullPath!, + systemDirectory, + moduleRoot, + [module.FullPath!], + "Write-Output 'safe'"); + + Assert.Equal(executable.FullPath, startInfo.FileName); + Assert.Equal(systemDirectory, startInfo.WorkingDirectory); + Assert.False(startInfo.UseShellExecute); + Assert.Equal(systemDirectory, startInfo.Environment["PATH"]); + Assert.Equal(moduleRoot, startInfo.Environment["PSModulePath"]); + Assert.DoesNotContain("Import-Module NetSecurity", startInfo.ArgumentList[^1], StringComparison.Ordinal); + Assert.Contains(module.FullPath!, startInfo.ArgumentList[^1], StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LaunchPolicy_ReplacesHostileWorkingDirectoryPathAndModuleSearchValues() + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + WorkingDirectory = "C:\\attacker", + }; + startInfo.Environment["PATH"] = "C:\\attacker"; + startInfo.Environment["PSModulePath"] = "C:\\attacker\\modules"; + const string systemDirectory = "C:\\Windows\\System32"; + const string moduleRoot = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\Modules"; + + PowerShellJsonRunner.ApplyLaunchPolicy(startInfo, systemDirectory, moduleRoot); + + Assert.Equal(systemDirectory, startInfo.WorkingDirectory); + Assert.Equal(systemDirectory, startInfo.Environment["PATH"]); + Assert.Equal(moduleRoot, startInfo.Environment["PSModulePath"]); + Assert.DoesNotContain( + startInfo.Environment.Values, + static value => value?.Contains("attacker", StringComparison.OrdinalIgnoreCase) is true); + } +} diff --git a/tests/PortCVE.Tests/RemoteHostScannerTests.cs b/tests/PortCVE.Tests/RemoteHostScannerTests.cs index 3095d07..70c293d 100644 --- a/tests/PortCVE.Tests/RemoteHostScannerTests.cs +++ b/tests/PortCVE.Tests/RemoteHostScannerTests.cs @@ -712,11 +712,15 @@ public async Task ScanAsync_CallerCancellationInterruptsRateWait() new RecordingDnsResolver(IPAddress.Loopback), NoConventionalProbes(), _ => limiter); - using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + using var cancellation = new CancellationTokenSource(); - await Assert.ThrowsAnyAsync(() => scanner.ScanAsync( + var scan = scanner.ScanAsync( Options("cancel.test", 9), - cancellation.Token)); + cancellation.Token); + await limiter.Entered.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await scan); Assert.Equal(1, limiter.WaitCount); } @@ -1123,12 +1127,17 @@ public ValueTask WaitAsync(CancellationToken cancellationToken) private sealed class BlockingRateLimiter : IRemoteConnectionRateLimiter { private int waitCount; + private readonly TaskCompletionSource entered = new( + TaskCreationOptions.RunContinuationsAsynchronously); public int WaitCount => Volatile.Read(ref waitCount); + public Task Entered => entered.Task; + public async ValueTask WaitAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref waitCount); + entered.TrySetResult(); await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } } diff --git a/tests/PortCVE.Tests/SchemaContractTests.cs b/tests/PortCVE.Tests/SchemaContractTests.cs index a95e3e4..5d91018 100644 --- a/tests/PortCVE.Tests/SchemaContractTests.cs +++ b/tests/PortCVE.Tests/SchemaContractTests.cs @@ -27,6 +27,28 @@ public void GeneratedLockfileShapeMatchesPublishedSchema() "portcve.lock.v1.schema.json"); } + [Fact] + public void WeakOwnerPolicyLockfileShapeMatchesPublishedSchema() + { + var snapshot = Snapshot(); + var listener = snapshot.Listeners[0] with + { + Owner = snapshot.Listeners[0].Owner with { ImageSha256 = null }, + ContainerExposures = [], + }; + var lockfile = new LockfileService().Create( + snapshot with { Listeners = [listener] }, + includesHostPolicy: true, + allowWeakOwner: true); + var json = JsonOutput.Serialize(lockfile); + + Assert.Equal(OwnerIdentityStrength.NameOnly, Assert.Single(lockfile.Listeners).OwnerIdentityStrength); + Assert.Equal(EvidenceCompleteness.Partial, lockfile.Evidence.Ownership); + Assert.True(lockfile.IsComplete); + Assert.Contains("\"allow_weak_owner\": true", json, StringComparison.Ordinal); + AssertSerializedShape(json, "portcve.lock.v1.schema.json"); + } + [Fact] public void PrivateAndRedactedSnapshotShapesMatchPublishedSchema() { @@ -53,6 +75,32 @@ public void PrivateAndRedactedVulnerabilityReportShapesMatchPublishedSchema() "portcve.vulnerability.v1.schema.json"); } + [Fact] + public async Task ScanAllMixedHostPrivateAndRedactedShapesMatchPublishedSchema() + { + var imageId = $"sha256:{new string('a', 64)}"; + var report = await new VulnerabilityAssessmentService( + new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult(VulnerabilitySeverity.Medium))).AssessAsync( + "test", + "all_tcp_listeners", + [ + VulnerabilityAssessmentTests.Listener(8080, imageId, "web", "example/web:1"), + VulnerabilityAssessmentTests.Listener(9000, null, null, null), + ], + null, + VulnerabilitySelectionMode.AllScanCapableSubjects, + CancellationToken.None); + + Assert.True(report.Summary.IsComplete); + AssertSerializedShape( + JsonOutput.Serialize(report), + "portcve.vulnerability.v1.schema.json"); + AssertSerializedShape( + JsonOutput.Serialize(VulnerabilityReportRedactor.Redact(report)), + "portcve.vulnerability.v1.schema.json"); + } + [Fact] public void PrivateAndReducedDatabaseDocumentsMatchPublishedSchema() { diff --git a/tests/PortCVE.Tests/TrivyVulnerabilityScannerTests.cs b/tests/PortCVE.Tests/TrivyVulnerabilityScannerTests.cs index bada7d1..587bf1e 100644 --- a/tests/PortCVE.Tests/TrivyVulnerabilityScannerTests.cs +++ b/tests/PortCVE.Tests/TrivyVulnerabilityScannerTests.cs @@ -5,6 +5,8 @@ namespace PortCVE.Tests; public sealed class TrivyVulnerabilityScannerTests { private static readonly DateTimeOffset Now = new(2026, 8, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly string TrustedTestExecutable = Environment.ProcessPath + ?? throw new InvalidOperationException("The test process has no executable path."); [Fact] public void ProcessStartInfo_UsesArgumentListWithoutAShell() @@ -85,7 +87,7 @@ public async Task ImageScan_UsesImmutableIdAndOfflineArguments() new(true, 0, "Version: 0.66.0\n", string.Empty, 2), new(true, 0, FixtureJson(), string.Empty, 7)); var scanner = new TrivyVulnerabilityScanner( - "C:\\Tools\\trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -103,7 +105,7 @@ public async Task ImageScan_UsesImmutableIdAndOfflineArguments() version => Assert.Equal(["--version"], version.Arguments), scan => { - Assert.Equal("C:\\Tools\\trivy.exe", scan.FileName); + Assert.Equal(TrustedTestExecutable, scan.FileName); Assert.Equal("image", scan.Arguments[0]); Assert.Equal(imageId, scan.Arguments[^1]); AssertFlag(scan.Arguments, "--image-src", "docker"); @@ -135,7 +137,7 @@ public async Task InvalidImageId_IsRejectedBeforeLaunchingTrivy() { var runner = new RecordingProcessRunner(); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, Path.GetTempPath(), runner, new FixedTimeProvider(Now), @@ -148,6 +150,128 @@ public async Task InvalidImageId_IsRejectedBeforeLaunchingTrivy() Assert.Empty(runner.Invocations); } + [Theory] + [InlineData("trivy.exe", "trivy_executable_invalid")] + [InlineData("tools\\trivy.exe", "trivy_executable_invalid")] + [InlineData("\\\\server\\share\\trivy.exe", "trivy_executable_network")] + public async Task UnsafeExecutablePath_IsRejectedBeforeAnyProcessInvocation( + string executable, + string expectedCode) + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + executable, + Path.GetTempPath(), + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('7', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal(expectedCode, Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task MissingExecutable_IsRejectedBeforeAnyProcessInvocation() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-missing-trivy-{Guid.NewGuid():N}"); + var executable = Path.Combine(parent, "trivy.exe"); + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + executable, + Path.GetTempPath(), + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('8', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("trivy_executable_not_found", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task ReparseExecutable_IsRejectedBeforeAnyProcessInvocation() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-trivy-link-{Guid.NewGuid():N}"); + var target = Path.Combine(parent, "target.exe"); + var link = Path.Combine(parent, "trivy.exe"); + Directory.CreateDirectory(parent); + File.WriteAllBytes(target, []); + File.CreateSymbolicLink(link, target); + try + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + link, + Path.GetTempPath(), + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('9', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("trivy_executable_reparse", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + finally + { + File.Delete(link); + Directory.Delete(parent, recursive: true); + } + } + + [Fact] + public async Task ExecutablePolicy_IsRecheckedBeforeDatabaseMetadataRead() + { + var cache = CreateCache(Now.AddHours(-1)); + try + { + var runner = new RecordingProcessRunner( + new ProcessExecutionResult(true, 0, "Version: 0.73.0\n", string.Empty, 2)); + var safe = new LocalPathValidation( + true, + TrustedTestExecutable, + "ok", + "Test executable is local."); + var changed = new LocalPathValidation( + false, + null, + "trivy_executable_reparse", + "Test executable became a reparse point."); + var validations = new Queue([safe, safe, changed]); + var scanner = new TrivyVulnerabilityScanner( + TrustedTestExecutable, + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30), + executableLocator: _ => validations.Dequeue()); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('0', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("trivy_executable_reparse", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Single(runner.Invocations); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + [Fact] public async Task MissingDatabase_ReturnsClearUnavailableDiagnosticWithoutScanning() { @@ -158,7 +282,7 @@ public async Task MissingDatabase_ReturnsClearUnavailableDiagnosticWithoutScanni var runner = new RecordingProcessRunner( new ProcessExecutionResult(true, 0, "Version: 0.66.0\n", string.Empty, 2)); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -189,7 +313,7 @@ public async Task StaleDatabase_ScansButMarksEvidencePartial() new(true, 0, "Version: 0.66.0\n", string.Empty, 2), new(true, 0, "{\"SchemaVersion\":2,\"Results\":[]}", string.Empty, 7)); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -220,7 +344,7 @@ public async Task ScannerTempDirectory_ExistsForBothProcessesAndIsDeletedAfterSu new(true, 0, "Version: 0.73.0\n", string.Empty, 2), new(true, 0, "{\"SchemaVersion\":2,\"Results\":[]}", string.Empty, 7)); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -267,7 +391,7 @@ public async Task ScannerTempDirectory_IsDeletedAfterBoundedProcessFailure( TimedOut: timedOut, OutputLimitExceeded: outputLimitExceeded)); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -298,7 +422,7 @@ public async Task ScannerTempDirectory_IsDeletedAfterCancellation() { var runner = new CancellingProcessRunner(); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -351,7 +475,7 @@ public async Task MissingResultsArray_IsReportedAsInvalidScannerJson() new(true, 0, "Version: 0.73.0\n", string.Empty, 2), new(true, 0, "{\"SchemaVersion\":2}", string.Empty, 7)); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -395,7 +519,7 @@ public async Task UnsafeUncCache_IsRejectedBeforeAnyProcessInvocation() { var runner = new RecordingProcessRunner(); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, "\\\\127.0.0.1\\portcve-never\\cache", runner, new FixedTimeProvider(Now), @@ -422,7 +546,7 @@ public async Task ReparseCache_IsRejectedBeforeAnyProcessInvocation() { var runner = new RecordingProcessRunner(); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, link, runner, new FixedTimeProvider(Now), @@ -457,7 +581,7 @@ public async Task ReparseDatabaseChild_IsRejectedBeforeAnyProcessInvocation() { var runner = new RecordingProcessRunner(); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -493,7 +617,7 @@ public async Task ReparseMetadataFile_IsRejectedBeforeAnyProcessInvocation() { var runner = new RecordingProcessRunner(); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, cache, runner, new FixedTimeProvider(Now), @@ -521,7 +645,7 @@ public async Task CachePolicyRejection_IsUnavailableWithoutProcessInvocation(str { var runner = new RecordingProcessRunner(); var scanner = new TrivyVulnerabilityScanner( - "trivy.exe", + TrustedTestExecutable, Path.GetTempPath(), runner, new FixedTimeProvider(Now), diff --git a/tests/PortCVE.Tests/VulnerabilityAssessmentTests.cs b/tests/PortCVE.Tests/VulnerabilityAssessmentTests.cs index 99a00b0..e12ce02 100644 --- a/tests/PortCVE.Tests/VulnerabilityAssessmentTests.cs +++ b/tests/PortCVE.Tests/VulnerabilityAssessmentTests.cs @@ -10,11 +10,13 @@ public sealed class VulnerabilityAssessmentTests public async Task Assessment_DeduplicatesExactDockerImageIdsAndDoesNotGuessNativeProducts() { var imageId = $"sha256:{new string('a', 64)}"; + var secondImageId = $"sha256:{new string('b', 64)}"; var scanner = new FixedScanner(CompleteResult()); var listeners = new[] { Listener(8080, imageId, "private-web", "registry.example/private/web:1"), Listener(8081, imageId, "private-web-copy", "registry.example/private/web:1"), + Listener(8082, secondImageId, "private-api", "registry.example/private/api:1"), Listener(9000, null, null, null), }; @@ -23,19 +25,83 @@ public async Task Assessment_DeduplicatesExactDockerImageIdsAndDoesNotGuessNativ "all_tcp_listeners", listeners, null, + VulnerabilitySelectionMode.AllScanCapableSubjects, CancellationToken.None); - Assert.Equal([imageId], scanner.ImageIds); - Assert.Equal(3, report.Summary.SelectedListenerCount); + Assert.Equal([imageId, secondImageId], scanner.ImageIds); + Assert.Equal(4, report.Summary.SelectedListenerCount); Assert.Equal(2, report.Summary.SubjectCount); - var imageSubject = Assert.Single(report.Subjects, subject => - subject.Kind == VulnerabilitySubjectKind.ContainerImage); + Assert.All(report.Subjects, subject => Assert.Equal(VulnerabilitySubjectKind.ContainerImage, subject.Kind)); + var imageSubject = Assert.Single(report.Subjects, subject => subject.ArtifactReference == imageId); Assert.Equal(VulnerabilityIdentityConfidence.Exact, imageSubject.IdentityConfidence); Assert.Equal(2, imageSubject.Listeners.Count); - var native = Assert.Single(report.Subjects, subject => - subject.Kind == VulnerabilitySubjectKind.HostProcess); - Assert.Equal(VulnerabilityScanStatus.NotSupported, native.ScanStatus); - Assert.Contains("intentionally unsupported", Assert.Single(native.Limitations), StringComparison.Ordinal); + Assert.True(report.Summary.IsComplete); + Assert.DoesNotContain(report.Diagnostics, diagnostic => + diagnostic.Code == "exact_product_identity_unavailable"); + } + + [Fact] + public async Task Assessment_AllWithoutScanCapableSubject_IsIncompleteWithoutInvokingScanner() + { + var scanner = new FixedScanner(CompleteResult()); + + var report = await new VulnerabilityAssessmentService(scanner).AssessAsync( + "test", + "all_tcp_listeners", + [Listener(9000, null, null, null)], + null, + VulnerabilitySelectionMode.AllScanCapableSubjects, + CancellationToken.None); + + Assert.Empty(scanner.ImageIds); + Assert.Empty(report.Subjects); + Assert.False(report.HasSuccessfulScan); + Assert.False(report.Summary.IsComplete); + Assert.Contains(report.Diagnostics, diagnostic => + diagnostic.Code == "no_scan_capable_subjects"); + } + + [Fact] + public async Task Assessment_AllWithPartialScanCapableEvidence_RemainsIncomplete() + { + var imageId = $"sha256:{new string('a', 64)}"; + var complete = CompleteResult(); + var partial = complete with + { + Status = VulnerabilityScanStatus.Partial, + ProviderRun = complete.ProviderRun with { Status = VulnerabilityProviderStatus.Partial }, + }; + + var report = await new VulnerabilityAssessmentService(new FixedScanner(partial)).AssessAsync( + "test", + "all_tcp_listeners", + [Listener(8080, imageId, "web", "example/web:1"), Listener(9000, null, null, null)], + null, + VulnerabilitySelectionMode.AllScanCapableSubjects, + CancellationToken.None); + + Assert.True(report.HasSuccessfulScan); + Assert.False(report.Summary.IsComplete); + Assert.Equal(VulnerabilityScanStatus.Partial, Assert.Single(report.Subjects).ScanStatus); + } + + [Fact] + public async Task Assessment_ExactNativeListener_RemainsExplicitlyUnsupportedAndIncomplete() + { + var scanner = new FixedScanner(CompleteResult()); + + var report = await new VulnerabilityAssessmentService(scanner).AssessAsync( + "test", + "tcp:9000", + [Listener(9000, null, null, null)], + null, + VulnerabilitySelectionMode.ExactListeners, + CancellationToken.None); + + Assert.Empty(scanner.ImageIds); + var subject = Assert.Single(report.Subjects); + Assert.Equal(VulnerabilitySubjectKind.HostProcess, subject.Kind); + Assert.Equal(VulnerabilityScanStatus.NotSupported, subject.ScanStatus); Assert.False(report.Summary.IsComplete); } @@ -53,6 +119,7 @@ public async Task Assessment_ExplicitSbomUsesDeclaredIdentityAndStableFileHash() "tcp:8080", [Listener(8080, null, null, null)], path, + VulnerabilitySelectionMode.ExactListeners, CancellationToken.None); Assert.Equal([Path.GetFullPath(path)], scanner.SbomPaths); @@ -79,6 +146,7 @@ public async Task RedactedJson_RemovesLocalPathsContainerNamesAndExactAddresses( "tcp:8080", [Listener(8080, imageId, "customer-secret-container", "registry.example/customer/private:1")], null, + VulnerabilitySelectionMode.ExactListeners, CancellationToken.None); report = report with { @@ -104,6 +172,36 @@ public async Task RedactedJson_RemovesLocalPathsContainerNamesAndExactAddresses( Assert.Contains("PrivateUser", privateJson, StringComparison.Ordinal); } + [Fact] + public async Task ScanAllReport_DoesNotCarryUnrelatedNativeProcessDetails() + { + var imageId = $"sha256:{new string('a', 64)}"; + var native = Listener(9000, null, null, null); + native = native with + { + Owner = native.Owner with + { + ImageName = "customer-secret-agent.exe", + ImagePath = "C:\\Users\\PrivateUser\\customer-secret-agent.exe", + }, + }; + var report = await new VulnerabilityAssessmentService(new FixedScanner(CompleteResult())).AssessAsync( + "test", + "all_tcp_listeners", + [Listener(8080, imageId, "web", "example/web:1"), native], + null, + VulnerabilitySelectionMode.AllScanCapableSubjects, + CancellationToken.None); + + var privateJson = JsonOutput.Serialize(report); + var redactedJson = JsonOutput.Serialize(VulnerabilityReportRedactor.Redact(report)); + + Assert.DoesNotContain("customer-secret-agent", privateJson, StringComparison.Ordinal); + Assert.DoesNotContain("PrivateUser", privateJson, StringComparison.Ordinal); + Assert.DoesNotContain("customer-secret-agent", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("PrivateUser", redactedJson, StringComparison.Ordinal); + } + [Fact] public async Task ChangedSbom_DiscardsFindingsAndCannotBecomeSuccessfulPartialEvidence() { @@ -117,6 +215,7 @@ public async Task ChangedSbom_DiscardsFindingsAndCannotBecomeSuccessfulPartialEv "tcp:8080", [Listener(8080, null, null, null)], path, + VulnerabilitySelectionMode.ExactListeners, CancellationToken.None); var subject = Assert.Single(report.Subjects); @@ -156,6 +255,7 @@ public async Task ChangedSbom_DoesNotUpgradeFailedOrUnavailableScan( "tcp:8080", [Listener(8080, null, null, null)], path, + VulnerabilitySelectionMode.ExactListeners, CancellationToken.None); Assert.Equal(scanStatus, Assert.Single(report.Subjects).ScanStatus); diff --git a/tests/PortCVE.Tests/VulnerabilityCliTests.cs b/tests/PortCVE.Tests/VulnerabilityCliTests.cs index 4be86f9..49899e3 100644 --- a/tests/PortCVE.Tests/VulnerabilityCliTests.cs +++ b/tests/PortCVE.Tests/VulnerabilityCliTests.cs @@ -8,6 +8,129 @@ namespace PortCVE.Tests; public sealed class VulnerabilityCliTests { + [Theory] + [InlineData(VulnerabilitySeverity.Medium, ExitCodes.Success)] + [InlineData(VulnerabilitySeverity.High, ExitCodes.NegativeResult)] + public async Task ScanAllFailOnHigh_GatesAllExactImagesWithoutFailingOnNativeListeners( + VulnerabilitySeverity findingSeverity, + int expectedExitCode) + { + var imageId = $"sha256:{new string('a', 64)}"; + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult(findingSeverity)); + var application = Application( + SnapshotWithListeners( + VulnerabilityAssessmentTests.Listener(8080, imageId, "private-container", "private/image:1"), + VulnerabilityAssessmentTests.Listener(9000, null, null, null)), + scanner); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Protocol: TransportProtocol.Tcp, + All: true, + FailOn: VulnerabilitySeverity.High, + Strict: true), + output, + error, + CancellationToken.None); + + Assert.Equal(expectedExitCode, exitCode); + Assert.Single(scanner.ImageIds); + Assert.DoesNotContain("exact_product_identity_unavailable", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanAllFailOnHigh_PartialScanCapableEvidenceTakesPrecedenceOverFindingGate() + { + var imageId = $"sha256:{new string('a', 64)}"; + var complete = VulnerabilityAssessmentTests.CompleteResult(VulnerabilitySeverity.High); + var partial = complete with + { + Status = VulnerabilityScanStatus.Partial, + ProviderRun = complete.ProviderRun with + { + Status = VulnerabilityProviderStatus.Partial, + Diagnostics = + [ + new( + "trivy", + VulnerabilityProviderStatus.Partial, + "vulnerability_db_stale", + "Fixture database is stale."), + ], + }, + }; + var application = Application( + SnapshotWithListeners( + VulnerabilityAssessmentTests.Listener(8080, imageId, "private-container", "private/image:1"), + VulnerabilityAssessmentTests.Listener(9000, null, null, null)), + new VulnerabilityAssessmentTests.FixedScanner(partial)); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Protocol: TransportProtocol.Tcp, + All: true, + FailOn: VulnerabilitySeverity.High), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Contains("vulnerability_db_stale", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanAllWithoutExactSubject_ReturnsIncompleteInsteadOfCleanGate() + { + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult()); + var application = Application( + SnapshotWithListeners(VulnerabilityAssessmentTests.Listener(9000, null, null, null)), + scanner); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Protocol: TransportProtocol.Tcp, + All: true, + FailOn: VulnerabilitySeverity.High), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Empty(scanner.ImageIds); + Assert.Contains("no_scan_capable_subjects", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanAllWithNoTcpListeners_ReturnsIncompleteInsteadOfNoMatch() + { + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult()); + var application = Application(SnapshotWithListeners(), scanner); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new(CommandKind.Scan, Protocol: TransportProtocol.Tcp, All: true), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Empty(scanner.ImageIds); + Assert.Contains("no scan-capable subjects", error.ToString(), StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ScanFailOnHigh_ReturnsNegativeResultForKnownAdvisoryMatch() { @@ -252,6 +375,12 @@ private static CliApplication Application( private static SystemSnapshot SnapshotWithImage(int port) { var imageId = $"sha256:{new string('a', 64)}"; + return SnapshotWithListeners( + VulnerabilityAssessmentTests.Listener(port, imageId, "private-container", "private/image:1")); + } + + private static SystemSnapshot SnapshotWithListeners(params ListenerEvidence[] listeners) + { return new( 1, "test", @@ -265,7 +394,7 @@ private static SystemSnapshot SnapshotWithImage(int port) new("docker", CollectorStatus.Complete, DateTimeOffset.UnixEpoch, 1, []), ], [], - [VulnerabilityAssessmentTests.Listener(port, imageId, "private-container", "private/image:1")], + listeners, []); }