diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdd0be9..d55f1ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,43 +14,51 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 - - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.101 + - name: Verify installer security invariants on Windows PowerShell 5.1 + shell: powershell + run: ./scripts/tests/Test-Installer.ps1 + + - name: Verify RFC 3161 signer binding on PowerShell 7 + shell: pwsh + run: ./scripts/tests/Test-Rfc3161Timestamp.ps1 + - name: Restore - run: dotnet restore BindWitness.sln --locked-mode + run: dotnet restore PortCVE.sln --locked-mode - name: Verify formatting - run: dotnet format BindWitness.sln --verify-no-changes --no-restore + run: dotnet format PortCVE.sln --verify-no-changes --no-restore - name: Build - run: dotnet build BindWitness.sln -c Release --no-restore + run: dotnet build PortCVE.sln -c Release --no-restore - name: Test - run: dotnet test BindWitness.sln -c Release --no-build --logger "trx;LogFileName=tests.trx" --collect "XPlat Code Coverage" --results-directory TestResults + run: dotnet test PortCVE.sln -c Release --no-build --logger "trx;LogFileName=tests.trx" --collect "XPlat Code Coverage" --results-directory TestResults - name: Publish Windows x64 - run: dotnet publish src/BindWitness/BindWitness.csproj -c Release -r win-x64 --self-contained true --no-build -o artifacts/win-x64 + run: dotnet publish src/PortCVE/PortCVE.csproj -c Release -r win-x64 --self-contained true --no-build -o artifacts/win-x64 - name: Smoke test shell: pwsh run: | - ./artifacts/win-x64/bindwitness.exe --version - $json = ./artifacts/win-x64/bindwitness.exe snapshot --no-firewall 2>$null | ConvertFrom-Json + ./artifacts/win-x64/portcve.exe --version + $json = ./artifacts/win-x64/portcve.exe snapshot --no-firewall 2>$null | ConvertFrom-Json if ($json.schema_version -ne 1) { throw 'Unexpected snapshot schema.' } - name: Upload test results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: name: test-results path: TestResults/ - name: Upload smoke artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: - name: bindwitness-win-x64 - path: artifacts/win-x64/bindwitness.exe + name: portcve-win-x64 + path: artifacts/win-x64/portcve.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af6b821..ddd63f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,68 +2,535 @@ name: Release on: push: - tags: ['v*'] + tags: + - 'v*' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false permissions: - contents: write + contents: read jobs: - release: - runs-on: windows-latest - timeout-minutes: 20 + build: + name: Build and test unsigned candidate + runs-on: windows-2025 + timeout-minutes: 25 + outputs: + commit_sha: ${{ steps.policy.outputs.commit_sha }} + is_prerelease: ${{ steps.policy.outputs.is_prerelease }} + version: ${{ steps.policy.outputs.version }} steps: - - uses: actions/checkout@v7 + - name: Check out the release ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + fetch-depth: 0 + + - name: Enforce release tag policy + id: policy + shell: pwsh + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $tag = $env:RELEASE_TAG + $pattern = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*))*))?$' + if (-not [regex]::IsMatch($tag, $pattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { + throw "Release tag '$tag' is not policy-compatible SemVer (vMAJOR.MINOR.PATCH[-PRERELEASE]); prerelease identifiers must begin with an alphanumeric character and build metadata is not accepted." + } + + $tagObjectType = (git cat-file -t "refs/tags/$tag").Trim() + if ($LASTEXITCODE -ne 0 -or $tagObjectType -ne 'tag') { + throw "Release tag '$tag' must be an annotated tag." + } + + $commit = (git rev-list -n 1 "refs/tags/$tag").Trim() + if ($LASTEXITCODE -ne 0 -or $commit -notmatch '^[0-9a-f]{40}$') { + throw "Could not resolve release tag '$tag' to one commit." + } + + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + if ($LASTEXITCODE -ne 0) { + throw 'Could not fetch origin/main for the release ancestry check.' + } + git merge-base --is-ancestor $commit origin/main + if ($LASTEXITCODE -ne 0) { + throw "Release commit '$commit' is not reachable from origin/main." + } + + $versionLines = @(dotnet msbuild src/PortCVE/PortCVE.csproj -nologo -getProperty:Version) + if ($LASTEXITCODE -ne 0) { + throw 'Could not read the PortCVE project version.' + } + $projectVersion = ($versionLines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim() + $tagVersion = $tag.Substring(1) + if (-not [StringComparer]::Ordinal.Equals($projectVersion, $tagVersion)) { + throw "Tag version '$tagVersion' does not exactly match project version '$projectVersion'." + } - - uses: actions/setup-dotnet@v6 + "commit_sha=$commit" >> $env:GITHUB_OUTPUT + "is_prerelease=$($tagVersion.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "version=$tagVersion" >> $env:GITHUB_OUTPUT + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.101 - - name: Restore - run: dotnet restore BindWitness.sln --locked-mode + - name: Restore locked dependencies + run: dotnet restore PortCVE.sln --locked-mode + + - name: Verify formatting + run: dotnet format PortCVE.sln --verify-no-changes --no-restore + + - name: Build + run: dotnet build PortCVE.sln -c Release --no-restore + + - name: Test + run: dotnet test PortCVE.sln -c Release --no-build --logger "trx;LogFileName=release-tests.trx" --results-directory TestResults + + - name: Publish unsigned candidate + run: dotnet publish src/PortCVE/PortCVE.csproj -c Release -r win-x64 --self-contained true --no-build --no-restore -o artifacts/unsigned - - name: Verify tag matches project version + - name: Verify unsigned candidate boundary and smoke test shell: pwsh run: | - $expected = '${{ github.ref_name }}'.TrimStart('v') - $actual = dotnet msbuild src/BindWitness/BindWitness.csproj -getProperty:Version - if ($actual.Trim() -ne $expected) { - throw "Tag version '$expected' does not match project version '$($actual.Trim())'." + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $files = @(Get-ChildItem -LiteralPath artifacts/unsigned -Recurse -File) + if ($files.Count -ne 1 -or -not [StringComparer]::Ordinal.Equals($files[0].Name, 'portcve.exe')) { + throw "Unsigned publish output must contain only portcve.exe; found: $($files.FullName -join ', ')." } - - name: Test - run: dotnet test BindWitness.sln -c Release --no-restore + $signature = Get-AuthenticodeSignature -LiteralPath $files[0].FullName + if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { + throw "Build output unexpectedly arrived signed (status: $($signature.Status)). Signing is allowed only in the protected signing job." + } + + & $files[0].FullName --version + if ($LASTEXITCODE -ne 0) { throw 'Unsigned --version smoke test failed.' } + $snapshot = (& $files[0].FullName snapshot --no-firewall 2>$null | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0 -or $snapshot.schema_version -ne 1) { + throw 'Unsigned snapshot smoke test failed or returned an unexpected schema.' + } + + - name: Upload unsigned candidate for protected signing + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: portcve-unsigned-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/unsigned/portcve.exe + if-no-files-found: error + compression-level: 0 + retention-days: 1 - - name: Publish - run: dotnet publish src/BindWitness/BindWitness.csproj -c Release -r win-x64 --self-contained true --no-restore -o artifacts/publish + sign: + name: Sign and verify candidate + needs: build + runs-on: windows-2025 + timeout-minutes: 30 + environment: release-signing + permissions: + contents: read + env: + ES_USERNAME: ${{ secrets.ES_USERNAME }} + ES_PASSWORD: ${{ secrets.ES_PASSWORD }} + CREDENTIAL_ID: ${{ secrets.CREDENTIAL_ID }} + ES_TOTP_SECRET: ${{ secrets.ES_TOTP_SECRET }} + EXPECTED_SIGNER_SUBJECT: ${{ vars.EXPECTED_SIGNER_SUBJECT }} - - name: Verify public binary boundary + steps: + - name: Check out the verified release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + ref: ${{ needs.build.outputs.commit_sha }} + fetch-depth: 1 + + - name: Download unsigned candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 + with: + name: portcve-unsigned-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/unsigned + + - name: Fail closed on missing signing configuration shell: pwsh run: | - $files = @(Get-ChildItem artifacts/publish -File) - if ($files.Count -ne 1 -or $files[0].Name -ne 'bindwitness.exe') { - throw "Publish output must contain only bindwitness.exe before public files are added; found: $($files.Name -join ', ')." + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + foreach ($name in @('ES_USERNAME', 'ES_PASSWORD', 'CREDENTIAL_ID', 'ES_TOTP_SECRET', 'EXPECTED_SIGNER_SUBJECT')) { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Required protected signing value '$name' is missing." + } + } + if ($env:EXPECTED_SIGNER_SUBJECT -ne $env:EXPECTED_SIGNER_SUBJECT.Trim()) { + throw 'EXPECTED_SIGNER_SUBJECT must not contain leading or trailing whitespace.' + } + if ($env:EXPECTED_SIGNER_SUBJECT.Contains("`r") -or $env:EXPECTED_SIGNER_SUBJECT.Contains("`n") -or + $env:EXPECTED_SIGNER_SUBJECT.Contains('__PORTCVE_EXPECTED_SIGNER_SUBJECT__')) { + throw 'EXPECTED_SIGNER_SUBJECT is invalid or still contains a template placeholder.' + } + + $candidate = @(Get-ChildItem -LiteralPath artifacts/unsigned -Recurse -File) + if ($candidate.Count -ne 1 -or -not [StringComparer]::Ordinal.Equals($candidate[0].Name, 'portcve.exe')) { + throw 'The signing input must be exactly one file named portcve.exe.' + } + $signature = Get-AuthenticodeSignature -LiteralPath $candidate[0].FullName + if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { + throw 'The signing input is not the unsigned candidate produced by the build job.' } - - name: Package and checksum + - name: Finalize UTF-8 BOM installer for signing shell: pwsh run: | - New-Item -ItemType Directory -Force artifacts/release | Out-Null - Copy-Item README.md, CHANGELOG.md, LICENSE, SECURITY.md artifacts/publish/ - Copy-Item schema artifacts/publish/schema -Recurse - Compress-Archive -Path artifacts/publish/* -DestinationPath artifacts/release/bindwitness-${{ github.ref_name }}-win-x64.zip - $hash = (Get-FileHash artifacts/release/bindwitness-${{ github.ref_name }}-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash bindwitness-${{ github.ref_name }}-win-x64.zip" | Set-Content artifacts/release/SHA256SUMS.txt -Encoding ascii - - - name: Create GitHub release + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + ./scripts/Finalize-ReleaseInstaller.ps1 ` + -TemplatePath scripts/install.ps1 ` + -OutputPath artifacts/unsigned-installer/install.ps1 ` + -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + $installerPath = (Resolve-Path -LiteralPath artifacts/unsigned-installer/install.ps1).Path + $bytes = [IO.File]::ReadAllBytes($installerPath) + if ($bytes.Length -lt 3 -or $bytes[0] -ne 0xef -or $bytes[1] -ne 0xbb -or $bytes[2] -ne 0xbf) { + throw 'Finalized installer lost its required UTF-8 BOM.' + } + $signature = Get-AuthenticodeSignature -LiteralPath $installerPath + if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { + throw 'Finalized installer must be unsigned before entering the protected signing action.' + } + + - name: Set up Java for SSL.com CodeSignTool + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.0.0 + with: + distribution: temurin + java-version: '21' + + - name: Install integrity-pinned SSL.com CodeSignTool + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $archiveUri = 'https://github.com/SSLcom/CodeSignTool/releases/download/v1.3.0/CodeSignTool-v1.3.0-windows.zip' + $expectedHash = 'e22094505decbe622afe5b0c27abc618ed2ba179bd94f3450490352399d5ef2a' + $archive = Join-Path $env:RUNNER_TEMP 'CodeSignTool-v1.3.0-windows.zip' + $toolRoot = Join-Path $env:RUNNER_TEMP 'portcve-codesigntool-v1.3.0' + + Invoke-WebRequest -UseBasicParsing -Uri $archiveUri -OutFile $archive + $actualHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not [StringComparer]::Ordinal.Equals($actualHash, $expectedHash)) { + throw "SSL.com CodeSignTool archive hash mismatch: $actualHash." + } + + New-Item -ItemType Directory -Path $toolRoot | Out-Null + Expand-Archive -LiteralPath $archive -DestinationPath $toolRoot + foreach ($required in @('CodeSignTool.bat', 'jar/code_sign_tool-1.3.0.jar', 'conf/code_sign_tool.properties')) { + if (-not (Test-Path -LiteralPath (Join-Path $toolRoot $required) -PathType Leaf)) { + throw "SSL.com CodeSignTool archive is missing '$required'." + } + } + + "CODESIGNTOOL_PATH=$toolRoot" >> $env:GITHUB_ENV + 'JAVA_VERSION=21' >> $env:GITHUB_ENV + + - name: Sign portcve.exe with SSL.com eSigner + uses: SSLcom/esigner-codesign@b7f8ff36fc0de8690fbbab8e5b4421d29802f747 # reviewed 2025-06-23 commit; action 1.3.2 + with: + command: sign + username: ${{ secrets.ES_USERNAME }} + password: ${{ secrets.ES_PASSWORD }} + credential_id: ${{ secrets.CREDENTIAL_ID }} + totp_secret: ${{ secrets.ES_TOTP_SECRET }} + program_name: PortCVE + file_path: ${{ github.workspace }}\artifacts\unsigned\portcve.exe + output_path: ${{ github.workspace }}\artifacts\signed + malware_block: true + override: false + clean_logs: true + environment_name: PROD + jvm_max_memory: 1024M + signing_method: v2 + + - name: Sign finalized install.ps1 with SSL.com eSigner + uses: SSLcom/esigner-codesign@b7f8ff36fc0de8690fbbab8e5b4421d29802f747 # reviewed 2025-06-23 commit; action 1.3.2 + with: + command: sign + username: ${{ secrets.ES_USERNAME }} + password: ${{ secrets.ES_PASSWORD }} + credential_id: ${{ secrets.CREDENTIAL_ID }} + totp_secret: ${{ secrets.ES_TOTP_SECRET }} + program_name: PortCVE Installer + file_path: ${{ github.workspace }}\artifacts\unsigned-installer\install.ps1 + output_path: ${{ github.workspace }}\artifacts\signed + malware_block: true + override: false + clean_logs: true + environment_name: PROD + jvm_max_memory: 1024M + signing_method: v2 + + - name: Independently verify signed output + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $files = @(Get-ChildItem -LiteralPath artifacts/signed -Recurse -File) + $expectedNames = @('install.ps1', 'portcve.exe') + if ($files.Count -ne $expectedNames.Count -or (Compare-Object @($files.Name | Sort-Object) @($expectedNames | Sort-Object))) { + throw "Signing output must contain only install.ps1 and portcve.exe; found: $($files.FullName -join ', ')." + } + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/portcve.exe -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/install.ps1 -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + $installerBytes = [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath artifacts/signed/install.ps1)) + if ($installerBytes.Length -lt 3 -or $installerBytes[0] -ne 0xef -or $installerBytes[1] -ne 0xbb -or $installerBytes[2] -ne 0xbf) { + throw 'Signed installer lost its required UTF-8 BOM.' + } + $tokens = $null + $parseErrors = $null + $installerAst = [Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path -LiteralPath artifacts/signed/install.ps1).Path, + [ref]$tokens, + [ref]$parseErrors + ) + $subjectAssignments = @($installerAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + [StringComparer]::Ordinal.Equals($node.Left.Extent.Text, '$script:ExpectedSignerSubject') + }, $true)) + if ($parseErrors.Count -ne 0 -or $subjectAssignments.Count -ne 1 -or + $subjectAssignments[0].Right -isnot [Management.Automation.Language.CommandExpressionAst] -or + $subjectAssignments[0].Right.Expression -isnot [Management.Automation.Language.StringConstantExpressionAst] -or + -not [StringComparer]::Ordinal.Equals([string]$subjectAssignments[0].Right.Expression.Value, $env:EXPECTED_SIGNER_SUBJECT)) { + throw 'Signed installer does not contain the exact release signer subject.' + } + + - name: Smoke test signed executable + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $executable = (Resolve-Path -LiteralPath artifacts/signed/portcve.exe).Path + & $executable --version + if ($LASTEXITCODE -ne 0) { throw 'Signed --version smoke test failed.' } + $snapshot = (& $executable snapshot --no-firewall 2>$null | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0 -or $snapshot.schema_version -ne 1) { + throw 'Signed snapshot smoke test failed or returned an unexpected schema.' + } + + - name: Write signing metadata + shell: pwsh env: - GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ needs.build.outputs.commit_sha }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + ./scripts/Write-SigningMetadata.ps1 ` + -Path artifacts/signed/portcve.exe ` + -OutputPath artifacts/signed/SIGNING-METADATA.json ` + -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT ` + -Repository $env:GITHUB_REPOSITORY ` + -CommitSha $env:RELEASE_COMMIT ` + -Tag $env:RELEASE_TAG ` + -WorkflowRunId $env:GITHUB_RUN_ID ` + -WorkflowRunAttempt $env:GITHUB_RUN_ATTEMPT ` + -SigningActionCommit 'b7f8ff36fc0de8690fbbab8e5b4421d29802f747' ` + -CodeSignToolVersion '1.3.0' ` + -CodeSignToolArchiveSha256 'e22094505decbe622afe5b0c27abc618ed2ba179bd94f3450490352399d5ef2a' + + - name: Upload verified signed candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: portcve-signed-${{ github.run_id }}-${{ github.run_attempt }} + path: | + artifacts/signed/portcve.exe + artifacts/signed/install.ps1 + artifacts/signed/SIGNING-METADATA.json + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + package_publish: + name: Package, attest, and publish + needs: + - build + - sign + runs-on: windows-2025 + timeout-minutes: 25 + permissions: + contents: write + id-token: write + attestations: write + env: + EXPECTED_SIGNER_SUBJECT: ${{ vars.EXPECTED_SIGNER_SUBJECT }} + RELEASE_COMMIT: ${{ needs.build.outputs.commit_sha }} + RELEASE_TAG: ${{ github.ref_name }} + IS_PRERELEASE: ${{ needs.build.outputs.is_prerelease }} + + steps: + - name: Check out the verified release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + ref: ${{ needs.build.outputs.commit_sha }} + fetch-depth: 1 + + - name: Download verified signed candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 + with: + name: portcve-signed-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/signed + + - name: Reverify transferred signature and metadata + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $files = @(Get-ChildItem -LiteralPath artifacts/signed -Recurse -File) + $expectedNames = @('install.ps1', 'portcve.exe', 'SIGNING-METADATA.json') + if ($files.Count -ne $expectedNames.Count -or @($files.Name | Where-Object { $_ -notin $expectedNames }).Count -ne 0) { + throw "Signed transfer contains an unexpected file set: $($files.Name -join ', ')." + } + + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/portcve.exe -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/install.ps1 -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + $metadata = Get-Content -LiteralPath artifacts/signed/SIGNING-METADATA.json -Raw | ConvertFrom-Json + $actualHash = (Get-FileHash -LiteralPath artifacts/signed/portcve.exe -Algorithm SHA256).Hash.ToLowerInvariant() + if ($metadata.schema_version -ne 1 -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.artifact.sha256, $actualHash) -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.signature.signer.subject, $env:EXPECTED_SIGNER_SUBJECT) -or + $metadata.signature.timestamp.binding_verified -ne $true -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.signature.timestamp.binding_method, 'Rfc3161TimestampToken.VerifySignatureForSignerInfo') -or + [string]::IsNullOrWhiteSpace([string]$metadata.signature.timestamp.timestamp_utc) -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.source.repository, $env:GITHUB_REPOSITORY) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals([string]$metadata.source.commit_sha, $env:RELEASE_COMMIT) -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.source.tag, $env:RELEASE_TAG)) { + throw 'Signing metadata does not bind the downloaded executable to this exact release context.' + } + + - name: Package signed release assets and checksums shell: pwsh run: | - $releaseArgs = @('release', 'create', '${{ github.ref_name }}') - $releaseArgs += @(Get-ChildItem artifacts/release -File | ForEach-Object FullName) - $releaseArgs += @('--generate-notes', '--verify-tag') - if ('${{ github.ref_name }}'.Contains('-')) { - $releaseArgs += '--prerelease' + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $packageRoot = Join-Path $PWD 'artifacts/package' + $releaseRoot = Join-Path $PWD 'artifacts/release' + New-Item -ItemType Directory -Path $packageRoot, $releaseRoot | Out-Null + + Copy-Item -LiteralPath artifacts/signed/portcve.exe -Destination $packageRoot + Copy-Item -LiteralPath artifacts/signed/SIGNING-METADATA.json -Destination $packageRoot + foreach ($document in @('README.md', 'CHANGELOG.md', 'LICENSE', 'SECURITY.md')) { + Copy-Item -LiteralPath $document -Destination $packageRoot + } + Copy-Item -LiteralPath schema -Destination (Join-Path $packageRoot 'schema') -Recurse + + $zipName = "portcve-$($env:RELEASE_TAG)-win-x64.zip" + Compress-Archive -Path (Join-Path $packageRoot '*') -DestinationPath (Join-Path $releaseRoot $zipName) + Copy-Item -LiteralPath artifacts/signed/portcve.exe -Destination $releaseRoot + Copy-Item -LiteralPath artifacts/signed/install.ps1 -Destination $releaseRoot + Copy-Item -LiteralPath artifacts/signed/SIGNING-METADATA.json -Destination $releaseRoot + + $checksumTargets = @(Get-ChildItem -LiteralPath $releaseRoot -File | Sort-Object Name) + $checksumLines = @($checksumTargets | ForEach-Object { + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($_.Name)" + }) + [IO.File]::WriteAllLines((Join-Path $releaseRoot 'SHA256SUMS.txt'), $checksumLines, [Text.Encoding]::ASCII) + + ./scripts/Verify-ReleaseSignature.ps1 -Path (Join-Path $releaseRoot 'portcve.exe') -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + ./scripts/Verify-ReleaseSignature.ps1 -Path (Join-Path $releaseRoot 'install.ps1') -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + - name: Attest release asset provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: artifacts/release/* + + - name: Create verified draft release, then publish + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $immutableJson = & gh api "repos/$env:GITHUB_REPOSITORY/immutable-releases" 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Could not verify immutable-releases configuration: $($immutableJson -join [Environment]::NewLine)" + } + $immutable = ($immutableJson -join [Environment]::NewLine) | ConvertFrom-Json + if ($immutable.enabled -ne $true) { + throw 'Immutable releases must be enabled before PortCVE can publish a release.' + } + + $existing = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" 2>&1 + $existingExit = $LASTEXITCODE + if ($existingExit -eq 0) { + throw "Release '$env:RELEASE_TAG' already exists; refusing to overwrite it." + } + if (($existing -join [Environment]::NewLine) -notmatch '(?i)\b404\b|not found') { + throw "Could not prove that the release tag is unused: $($existing -join [Environment]::NewLine)" + } + + $assets = @(Get-ChildItem -LiteralPath artifacts/release -File | Sort-Object Name) + $expectedAssetNames = @('install.ps1', 'portcve.exe', "portcve-$($env:RELEASE_TAG)-win-x64.zip", 'SHA256SUMS.txt', 'SIGNING-METADATA.json') | Sort-Object + if ($assets.Count -ne $expectedAssetNames.Count -or (Compare-Object @($assets.Name | Sort-Object) $expectedAssetNames)) { + throw "Release asset boundary is invalid: $($assets.Name -join ', ')." + } + + $createArgs = @('release', 'create', $env:RELEASE_TAG) + $createArgs += @($assets.FullName) + $createArgs += @('--draft', '--generate-notes', '--verify-tag', '--title', "PortCVE $env:RELEASE_TAG") + & gh @createArgs + if ($LASTEXITCODE -ne 0) { throw 'Creating the draft GitHub release failed.' } + + $draftJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" + if ($LASTEXITCODE -ne 0) { throw 'Could not read back the draft release.' } + $draft = ($draftJson -join [Environment]::NewLine) | ConvertFrom-Json + if ($draft.draft -ne $true) { throw 'The release was not created as a draft.' } + + $localDigests = @{} + foreach ($asset in $assets) { + $localDigests[$asset.Name] = "sha256:$((Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant())" + } + + $assetsVerified = $false + for ($attempt = 1; $attempt -le 5; $attempt++) { + $remoteJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/$($draft.id)/assets?per_page=100" + if ($LASTEXITCODE -ne 0) { throw 'Could not read back draft release assets.' } + $remoteAssets = @(($remoteJson -join [Environment]::NewLine) | ConvertFrom-Json) + $assetsVerified = $remoteAssets.Count -eq $assets.Count + foreach ($remote in $remoteAssets) { + if (-not $localDigests.ContainsKey([string]$remote.name) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals([string]$remote.digest, [string]$localDigests[[string]$remote.name])) { + $assetsVerified = $false + } + } + if ($assetsVerified) { break } + Start-Sleep -Seconds 2 + } + if (-not $assetsVerified) { + throw 'GitHub did not report an exact SHA-256 digest match for every draft release asset.' + } + + if ([bool]::Parse($env:IS_PRERELEASE)) { + & gh release edit $env:RELEASE_TAG --draft=false --prerelease + } else { + & gh release edit $env:RELEASE_TAG --draft=false --latest + } + if ($LASTEXITCODE -ne 0) { throw 'Publishing the verified draft release failed.' } + + $publishedJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" + if ($LASTEXITCODE -ne 0) { throw 'Could not verify the published release state.' } + $published = ($publishedJson -join [Environment]::NewLine) | ConvertFrom-Json + $expectedPrerelease = [bool]::Parse($env:IS_PRERELEASE) + if ($published.draft -ne $false -or $published.prerelease -ne $expectedPrerelease) { + throw 'Published release state does not match the verified release policy.' } - gh @releaseArgs diff --git a/CHANGELOG.md b/CHANGELOG.md index 049c224..b27c62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,16 @@ All notable changes will be documented here. The project follows semantic versioning after `1.0`; alpha schemas may still change with an explicit version bump. -## 0.1.0-alpha.1 - unreleased +## Unreleased + +- Renamed the project, executable, namespaces, schemas, scripts, and release artifacts from BindWitness (`bindwitness`) to PortCVE (`portcve`); no behavior changed as part of the rename. +- Added `scan` for offline known-advisory matching against immutable local Docker image IDs and explicit local SBOMs, with a versioned JSON schema, redaction, database-freshness evidence, and `--strict`/`--fail-on` exit gates. +- Hardened the Trivy boundary with local non-reparse cache/SBOM/temp validation, inherited environment scrubbing, strict result parsing, bounded process termination, and guarded cleanup. +- Added a file-backed, self-verifying PowerShell installer template and a fail-closed release workflow that signs and independently verifies both `portcve.exe` and `install.ps1`. +- Added cryptographic RFC 3161 token decoding, signer-info imprint binding, trusted TSA matching, full-SHA GitHub Actions pinning, release checksums, metadata, and provenance attestation. +- Live-validated Docker TCP/UDP correlation and the offline vulnerability path; see `docs/validation.md` for dated evidence and claim boundaries. + +## 0.1.0-alpha.1 - 2026-08-09 - Native Windows TCP/UDP endpoint collection with IPv4 and IPv6 ownership - Process, parent, account, and Windows service attribution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4dee08d..14a70ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to BindWitness +# Contributing to PortCVE Thanks for helping make local exposure evidence more trustworthy. @@ -13,10 +13,10 @@ Small bug fixes and tests can go directly to a pull request. Use Windows x64 and the .NET 10 SDK: ```powershell -dotnet restore BindWitness.sln --locked-mode -dotnet format BindWitness.sln --verify-no-changes --no-restore -dotnet build BindWitness.sln -c Release --no-restore -dotnet test BindWitness.sln -c Release --no-build +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 ``` NuGet lockfiles are committed. Keep them synchronized with intentional package changes; `--locked-mode` should fail unexpected dependency-resolution drift. diff --git a/BindWitness.sln b/PortCVE.sln similarity index 90% rename from BindWitness.sln rename to PortCVE.sln index 15a7137..8d5c292 100644 --- a/BindWitness.sln +++ b/PortCVE.sln @@ -1,15 +1,15 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BindWitness", "src\BindWitness\BindWitness.csproj", "{175B3219-0CEA-4F04-95A8-AE070700B51C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PortCVE", "src\PortCVE\PortCVE.csproj", "{175B3219-0CEA-4F04-95A8-AE070700B51C}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BindWitness.Tests", "tests\BindWitness.Tests\BindWitness.Tests.csproj", "{BECAB928-315D-402B-AA27-8D20793808EB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PortCVE.Tests", "tests\PortCVE.Tests\PortCVE.Tests.csproj", "{BECAB928-315D-402B-AA27-8D20793808EB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/README.md b/README.md index 31d75d4..1e9d30f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,29 @@ -# BindWitness +# PortCVE -**Explain local ports. Lock the ones you expect.** +**Explain local ports. Check what backs them. Lock what you expect.** -BindWitness is a read-only Windows CLI that connects the facts other port tools leave separate: +PortCVE is a read-only Windows CLI that connects the facts other port tools leave separate: - which TCP listeners and UDP endpoints exist; - which process or Windows service owns each bind; - which local Docker Engine publication maps a container port to that observed host bind; +- which known vulnerability advisories match packages in an exactly identified local Docker image or explicitly supplied SBOM; - 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. -BindWitness does not call a wildcard bind “Internet exposed.” It reports observed host facts, host-policy inference, confidence, and limitations separately. +PortCVE does not call a wildcard bind “Internet exposed” or an advisory match “exploitable.” It reports observed host facts, known-advisory evidence, confidence, and limitations separately. > Status: `0.1.0-alpha.1`. Windows x64 is the only supported release target today. The CLI and JSON schemas can still change before `1.0`. > -> Naming status: **BindWitness is the public project name.** Exact-name checks on 2026-08-09 found no obvious software collision across GitHub, PyPI, npm, crates.io, or NuGet. This screening is not formal trademark clearance. +> 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. ## Why this exists -`netstat`, TCPView, and PowerShell can show sockets and owners. BindWitness is for the next question: +`netstat`, TCPView, and PowerShell can show sockets and owners. PortCVE is for the next question: -> What opened this port, where can it receive traffic, what does the host firewall say, and is this new? +> What opened this port, where can it receive traffic, what does the host firewall say, do its exact packages match known advisories, and is this new? It is designed for developers, defenders, incident responders, lab machines, and Windows hardening checks—not remote scanning. @@ -31,7 +32,7 @@ It is designed for developers, defenders, incident responders, lab machines, and Illustrative Docker-published port: ```text -PS> bindwitness tcp:8080 --evidence +PS> portcve tcp:8080 --evidence TCP4 0.0.0.0:8080 LISTEN @@ -64,11 +65,11 @@ LIMITATIONS the host socket may be owned by a Docker Desktop forwarding process. ``` -BindWitness 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. +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 BindWitness kept the observed owner `com.docker.backend.exe` and attached both container publications with the Docker collector `complete`. +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. @@ -82,11 +83,25 @@ The script is intentionally mutating: it may pull `alpine:3.22`, creates and rem 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). + ## Install -### Release binary +### Signed installer + +For finalized signed releases, download, checksum, Authenticode-verify, inspect, and run the release's file-backed `install.ps1`. It refuses piped or in-memory execution, verifies its own signer before network or filesystem activity, installs without administrator rights to `%LOCALAPPDATA%\Programs\PortCVE`, verifies the versioned ZIP and signed executable, and updates the user `PATH` with rollback protection. See the complete [installer instructions and trust checks](docs/install.md). + +The checked-in `scripts/install.ps1` is an unsigned, unfinalized template and deliberately refuses to run. Production installation requires the separately downloaded and signed release asset; pipe-to-execution installation is refused. + +The installer never permits an unsigned production install. The historical `v0.1.0-alpha.1` release is unsigned and is intentionally rejected. + +### Manual release binary -Download the Windows x64 ZIP from the repository's Releases page, verify its SHA-256 file, extract `bindwitness.exe`, and place it somewhere on your `PATH`. +Download the Windows x64 ZIP from the repository's Releases page, verify its SHA-256 file, extract `portcve.exe`, and place it somewhere on your `PATH`. Release binaries are self-contained; the .NET runtime is not required. Alpha binaries are not yet code-signed, so verify checksums before running them. @@ -95,39 +110,41 @@ Release binaries are self-contained; the .NET runtime is not required. Alpha bin Install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0), then: ```powershell -cd bindwitness -dotnet restore BindWitness.sln --locked-mode -dotnet test BindWitness.sln -c Release --no-restore -dotnet publish src\BindWitness\BindWitness.csproj -c Release -r win-x64 --self-contained true --no-restore -o artifacts\win-x64 +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 ``` ## Commands ```powershell -bindwitness # fast local inventory -bindwitness 8080 # explain TCP and UDP binds on a port -bindwitness tcp:8080 --evidence # protocol-specific deep explanation -bindwitness list --scope non-loopback # filter likely remote-facing binds -bindwitness list --process node.exe # filter by process or service -bindwitness snapshot --json # full versioned evidence document -bindwitness lock -o listeners.lock.json # normalized baseline, no PID or raw args -bindwitness lock --include-udp # opt into noisier UDP baseline tracking -bindwitness diff listeners.lock.json # report all current drift -bindwitness check listeners.lock.json # CI-friendly security drift gate -bindwitness watch --json # stream changes as JSONL -bindwitness doctor # collection coverage and privacy mode +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 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 ``` 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 `bindwitness 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). +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). ## Baseline workflow Create a baseline when the machine is in a known-good state: ```powershell -bindwitness lock -o listeners.lock.json +portcve lock -o listeners.lock.json ``` 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. @@ -135,13 +152,13 @@ Lockfiles are TCP-only by default. Use `--include-udp` only when UDP bind drift Review all drift: ```powershell -bindwitness diff listeners.lock.json +portcve diff listeners.lock.json ``` Gate a build, image, kiosk, or lab host: ```powershell -bindwitness check listeners.lock.json +portcve check listeners.lock.json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ``` @@ -155,12 +172,13 @@ Container evidence has its own completeness field. If the local Docker Engine re ## Evidence model -BindWitness keeps four layers separate: +PortCVE keeps five layers separate: 1. **Observed bind:** address, port, protocol, owning PID, executable/service, and bind scope. 2. **Local runtime correlation:** Docker Engine published-port metadata joined to an observed bind by protocol, host address, and host port, always with medium confidence and a tuple-correlation limitation. 3. **Static host-policy inference:** merged Windows Firewall profiles and matching inbound-rule evidence, with `allow`, `block`, `mixed`, or `unknown` plus confidence and limitations. -4. **External path:** always `not tested` in the current release. +4. **Known-advisory match:** Trivy results for an immutable local Docker image ID or explicit local SBOM, with database identity/freshness and no exploitability claim. +5. **External path:** always `not tested` in the current release. `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. @@ -182,8 +200,8 @@ The native socket row is direct point-in-time evidence from the host, although c The default contract is deliberately small: - read-only—no process killing or firewall changes; -- local/offline by default—collection uses local OS APIs and, when available, the local Docker Engine named pipe; it performs no telemetry, DNS resolution, reputation lookup, image pull, or sample upload; -- no process environment-variable reads; +- local/offline by default—collection uses local OS APIs and, when available, the local Docker Engine named pipe; vulnerability scans use a separately installed Trivy executable and pre-populated local database with online/update/telemetry paths disabled; PortCVE performs no reputation lookup, image pull, or sample upload; +- no target-process environment-variable reads; - no command-line collection; - no remote scan or external reachability probe; and - diagnostics go to stderr so JSON stdout remains machine-readable. @@ -198,10 +216,11 @@ For Docker correlations, default JSON replaces container IDs, container names, a ## Machine-readable output -All JSON uses snake_case, stable enum strings, a mandatory `schema_version`, deterministic listener ordering, and diagnostics for partial collectors. Schema identifiers are stable URNs (`urn:bindwitness:schema:snapshot:v1` and `urn:bindwitness:schema:lock:v1`); they do not depend on a project website. +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. -- [Snapshot schema v1](schema/bindwitness.snapshot.v1.schema.json) -- [Lockfile schema v1](schema/bindwitness.lock.v1.schema.json) +- [Snapshot schema v1](schema/portcve.snapshot.v1.schema.json) +- [Lockfile schema v1](schema/portcve.lock.v1.schema.json) +- [Vulnerability report schema v1](schema/portcve.vulnerability.v1.schema.json) Human-readable output is not a compatibility API. JSON and lockfile schema changes follow the policy in [docs/versioning.md](docs/versioning.md). @@ -214,8 +233,9 @@ Included now: - loopback/interface/wildcard classification; - active Windows network-profile mapping; - local Docker Engine named-pipe collection and medium-confidence published-port correlation; +- offline known-advisory matching for immutable local Docker image IDs and explicit local SBOMs, with database freshness and CI exit gates; - opt-in static Windows Firewall correlation; -- list, inspect, snapshot, lock, diff, check, watch, and doctor workflows; +- list, inspect, scan, snapshot, lock, diff, check, watch, and doctor workflows; - text, JSON, and JSONL output; and - standard-user degradation with explicit diagnostics. @@ -226,6 +246,7 @@ Not included: - 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. @@ -234,10 +255,10 @@ See [ROADMAP.md](ROADMAP.md) for the deliberately staged follow-up work. ## Development ```powershell -dotnet restore BindWitness.sln --locked-mode -dotnet build BindWitness.sln -c Release --no-restore -dotnet test BindWitness.sln -c Release --no-build -dotnet run --project src\BindWitness -- doctor +dotnet restore PortCVE.sln --locked-mode +dotnet build PortCVE.sln -c Release --no-restore +dotnet test PortCVE.sln -c Release --no-build +dotnet run --project src\PortCVE -- doctor ``` 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. diff --git a/ROADMAP.md b/ROADMAP.md index f184405..a08a57b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,7 +30,7 @@ The order matters: correctness and evidence quality come before more platforms o - Linux collector backend using native socket/process and nftables evidence - Event-driven collectors where the platform offers reliable ownership events -- Signed provenance and an SBOM for release artifacts +- CycloneDX SBOM generation for release artifacts ## Explicitly not planned for v1 diff --git a/SECURITY.md b/SECURITY.md index ba3ab4e..3e8849e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported versions -BindWitness is currently alpha software. Only the latest tagged release receives security fixes. +PortCVE is currently alpha software. Only the latest tagged release receives security fixes. ## Report a vulnerability @@ -12,7 +12,7 @@ Include the affected version, Windows version, privilege level, reproduction ste ## Security boundaries -BindWitness parses local OS data that may change while it is being read. Its output is evidence, not an authorization decision or guarantee of network reachability. +PortCVE parses local OS data that may change while it is being read. Its output is evidence, not an authorization decision or guarantee of network reachability. The current release: @@ -23,4 +23,4 @@ The current release: - invokes Windows PowerShell only with bundled constant scripts and no user-controlled script interpolation; and - may return partial metadata for protected or rapidly exiting processes. -Do not run a binary from an untrusted source merely to inspect it. BindWitness inspects running local endpoints; it is not a malware sandbox. +Do not run a binary from an untrusted source merely to inspect it. PortCVE inspects running local endpoints; it is not a malware sandbox. diff --git a/docs/architecture.md b/docs/architecture.md index 65a4dee..718d3e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -BindWitness is a collection-and-correlation CLI. It does not sniff packets and does not execute untrusted code. +PortCVE is a collection-and-correlation CLI. It does not sniff packets and does not execute untrusted code. ## Pipeline @@ -11,13 +11,14 @@ BindWitness is a collection-and-correlation CLI. It does not sniff packets and d 5. The Docker collector probes the local `\\.\pipe\docker_engine` named pipe, negotiates the Engine API version, reads running-container publications, and correlates them to observed endpoints by protocol, host address, and host port. 6. The optional firewall collector reads the merged `ActiveStore` through structured NetSecurity CIM objects and joins rule filters by stable rule ID. 7. The evaluator separates exact matches from conditional or unsupported rules. Unresolved predicates lower confidence and can produce `mixed` or `unknown`. -8. Renderers emit human text, versioned JSON, JSONL events, or normalized lockfiles. +8. For `scan`, the vulnerability layer selects only immutable correlated Docker image IDs or an explicitly supplied local SBOM, invokes a separately installed Trivy process in offline mode, and records database freshness and provider completeness. +9. Renderers emit human text, versioned JSON, JSONL events, normalized lockfiles, or vulnerability reports. 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 -BindWitness labels evidence by source because the sources have different semantics: +PortCVE labels evidence by source because the sources have different semantics: | Evidence | Source | Claim boundary | | --- | --- | --- | @@ -26,13 +27,22 @@ BindWitness labels evidence by source because the sources have different semanti | Adapter address and state | Local .NET network-interface APIs | Local adapter configuration observed during the collection window. | | Network profile | Local `Get-NetConnectionProfile` | Structured CIM configuration used to map an adapter to a Windows profile. | | Docker published-port metadata | Local Docker Engine `/version` and negotiated `/containers/json` over `\\.\pipe\docker_engine` | Runtime-declared mapping for a running container; correlated to a host socket by tuple with medium confidence, not proof that the container owns the Windows socket. | -| Firewall profile/rules/filters | Local NetSecurity commands against `ActiveStore` | Static configuration evidence consumed by BindWitness's evaluator, not a live WFP packet-classification result. | +| Firewall profile/rules/filters | Local NetSecurity commands against `ActiveStore` | Static configuration evidence consumed by PortCVE's evaluator, not a live WFP packet-classification result. | +| Package advisory matches | Separately installed Trivy, immutable local Docker image ID or explicit local SBOM, and pre-populated local database | Known-advisory match for an observed package version; not proof of reachability, exploitability, or compromise. | The PowerShell scripts are bundled constants and do not interpolate CLI input. They run locally, but `--resolve-accounts` separately calls Windows account lookup APIs; Windows can contact domain services when a SID is not local or cached. ## Data boundaries -The core model contains platform-neutral listeners, owners, interfaces, container publications, policy evidence, diagnostics, and collector status. Win32 and Docker transport/parser structures remain in their collection layers. +The core model contains platform-neutral listeners, owners, interfaces, container publications, policy evidence, vulnerability subjects/findings/provider runs, diagnostics, and evidence status. Win32, Docker transport, and Trivy parser structures remain in their collection layers. + +## Offline vulnerability assessment + +`scan` begins from the same point-in-time listener snapshot used by the rest of the CLI. It never guesses a product from a native process name, executable metadata, banner, or port number. Automatic Docker association requires a correlated immutable `sha256:` image ID; an SBOM association exists only when the user supplies that local file for an exact TCP-port query. + +Before Trivy starts, PortCVE validates the cache, database, SBOM, and per-invocation temp paths as local-drive paths without reparse traversal. It removes every inherited `TRIVY_*` setting case-insensitively, then sets a small offline allowlist. The child process runs without a shell, with bounded stdout/stderr, a timeout, bounded post-kill waiting, and guarded temp cleanup. Missing or malformed evidence is unavailable, stale evidence is partial, and malformed Trivy result structures fail closed. + +Findings retain the advisory ID, package/version, fixes, source severity, aliases, references, database time, and subject identity confidence. The report explicitly sets exploitability and network reachability to `not_assessed`. A zero-finding result means only that no known matches were present in the named database snapshot. Each collector reports: @@ -50,7 +60,7 @@ Each Engine publication is matched against Windows IP Helper evidence using tran When a lockfile includes complete container evidence and every correlated publication supplies an image ID, the normalized owner is `container-image-set:` with strength `container_image`. The digest is computed over the sorted distinct image-ID set, so container names and restart-specific IDs are excluded while an image-set change remains detectable. `evidence.containers` distinguishes `complete`, `partial`, and `not_collected`; a baseline that used container evidence requires comparable evidence during `diff` and `check`. Dimension-level loss is emitted as `evidence_regressed`; strict diff and check return exit code `3` instead of presenting an evidence gap as no drift. -The integrated path was validated on 2026-08-09 against Docker Desktop client/server 28.3.2 on Windows NT 10.0.26200.0 with the `desktop-linux` WSL2 context. An official `alpine:3.22` fixture published one loopback TCP tuple and one wildcard UDP tuple; both echoed real payloads, an independent Windows CIM check observed the exact tuples, and BindWitness correlated both while retaining `com.docker.backend.exe` as the Windows owner. A container-image lock passed unchanged, then reported `owner_changed` with exit code 1 when PowerShell replaced the same TCP endpoint. This is validation of the local collection/correlation/gating path on that environment, not a claim about external reachability, guest-process ownership, Linux hosts, or every Docker version. +The integrated path was validated on 2026-08-09 against Docker Desktop client/server 28.3.2 on Windows NT 10.0.26200.0 with the `desktop-linux` WSL2 context. An official `alpine:3.22` fixture published one loopback TCP tuple and one wildcard UDP tuple; both echoed real payloads, an independent Windows CIM check observed the exact tuples, and the then-named BindWitness build correlated both while retaining `com.docker.backend.exe` as the Windows owner. A container-image lock passed unchanged, then reported `owner_changed` with exit code 1 when PowerShell replaced the same TCP endpoint. This is validation of the local collection/correlation/gating path on that environment, not a claim about external reachability, guest-process ownership, Linux hosts, or every Docker version. ## Listener identity diff --git a/docs/cli.md b/docs/cli.md index 818ea51..6a7a46f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,27 +1,45 @@ # CLI reference -BindWitness is read-only. Commands observe local Windows state, write JSON or a lockfile when requested, and never kill a process, close a socket, edit firewall policy, or probe a remote host. +PortCVE is read-only. Commands observe local Windows state, write JSON or a lockfile when requested, and never kill a process, close a socket, edit firewall policy, or probe a remote host. ## Commands ```text -bindwitness List all collected TCP listeners and UDP binds -bindwitness Inspect TCP and UDP binds on a port -bindwitness : Inspect one protocol on a port -bindwitness list List and filter current endpoints -bindwitness snapshot [--output ] Emit a versioned snapshot -bindwitness lock [--output ] Write a normalized baseline -bindwitness diff Show current drift from a baseline -bindwitness check Gate security-relevant drift -bindwitness watch Poll and report endpoint changes -bindwitness doctor Report collector coverage -bindwitness help Show the concise built-in reference -bindwitness version Print the tool version +portcve List all collected TCP listeners and UDP binds +portcve Inspect TCP and UDP binds on a port +portcve : Inspect one protocol on a port +portcve list List and filter current endpoints +portcve snapshot [--output ] Emit a versioned snapshot +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 watch Poll and report endpoint changes +portcve doctor Report collector coverage +portcve help Show the concise built-in reference +portcve version Print the tool version ``` Direct inspection and `doctor` collect Windows Firewall evidence unless `--no-firewall` is supplied. `list`, `snapshot`, `lock`, and `watch` skip that slower collector unless `--firewall` is supplied. -Every live collection also performs a bounded probe of the local Docker Engine named pipe (`\\.\pipe\docker_engine`). When Docker is running, BindWitness reads running-container published ports and correlates them to observed Windows endpoints by protocol, host address, and host port. The result is medium-confidence runtime correlation, not direct guest-process ownership. An absent pipe is recorded as `docker: unavailable` and returns quickly without starting Docker Desktop, pulling an image, or starting a container. There is no Docker enablement flag. +Every live collection also performs a bounded probe of the local Docker Engine named pipe (`\\.\pipe\docker_engine`). When Docker is running, PortCVE reads running-container published ports and correlates them to observed Windows endpoints by protocol, host address, and host port. The result is medium-confidence runtime correlation, not direct guest-process ownership. An absent pipe is recorded as `docker: unavailable` and returns quickly without starting Docker Desktop, pulling an image, or starting a container. There is no Docker enablement flag. + +## Offline vulnerability scans + +`scan` maps selected TCP listeners only to immutable Docker `sha256:` image IDs. Native Windows process names and paths are not guessed into products or CPEs. For one exact TCP port, `--sbom ` adds an explicitly declared local SBOM subject; it cannot be combined with `--all`. + +The scanner launches a separately installed Trivy executable without a shell, selects the local Docker daemon only, and supplies update, telemetry, version-check, VEX-update, and online dependency-resolution disable flags. It never downloads Trivy or a database. Set `PORTCVE_TRIVY_PATH` for a trusted local non-default executable and `PORTCVE_TRIVY_CACHE_DIR` for a non-default cache. The executable path (or the caller's `PATH` lookup when unset) is an explicit trust boundary and must not resolve through UNC storage or a reparse point. The cache, its database directory, metadata, and database file must resolve on an allowed local drive without reparse traversal before Trivy starts. The expected database metadata is `\db\metadata.json`; a missing or invalid database makes the subject unavailable, while a database older than 72 hours makes evidence partial. + +| Option | Behavior | +| --- | --- | +| `--all` | Select every observed TCP listener and deduplicate exact Docker subjects by immutable image ID. | +| `--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. | +| `--strict` | Exit `3` if any selected subject is unsupported, unavailable, failed, or partial. | + +Human output and vulnerability JSON say `known_advisory_match`: they do not claim the package is reachable or exploitable. JSON uses `schema/portcve.vulnerability.v1.schema.json` and is redacted unless `--include-private` is supplied. If Trivy cannot run or no subject produces scan evidence, `scan` exits `3`; a selector with no matching TCP listener exits `1`. ## Filters and collection @@ -81,7 +99,7 @@ Watch is TCP-only unless `--include-udp` or a UDP protocol filter is supplied. I | `0` | Success, matching inspection, or passing check. An empty unfiltered list is still successful. | | `1` | No matching inspected endpoint or a failed security drift check. | | `2` | Invalid usage, schema, lockfile, or non-overwrite request. | -| `3` | Evidence is incomplete for the requested strict or gating operation. | +| `3` | Evidence is incomplete for the requested strict or gating operation, or no vulnerability subject could be scanned. | | `4` | Required collection or runtime operation failed. | | `130` | Interrupted. | diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..7734efa --- /dev/null +++ b/docs/install.md @@ -0,0 +1,67 @@ +# Installing PortCVE on Windows + +The production installer requires 64-bit Windows and PowerShell 5.1 or newer. It is itself Authenticode-signed, must run from a downloaded `install.ps1` file, installs for the current user at `%LOCALAPPDATA%\Programs\PortCVE`, and adds that directory to the user `PATH`; administrator rights are not required. + +The checked-in [`scripts/install.ps1`](../scripts/install.ps1) file is a release template. It deliberately refuses to run until the trusted release workflow embeds the exact expected Authenticode signer subject. Download `install.ps1` from a signed GitHub Release, not from the repository source tree. + +## Recommended: download, verify, inspect, then run + +This example downloads the latest stable installer and its checksum with `curl.exe`, verifies the exact `install.ps1` entry and Windows trust result, leaves the script available for inspection, and then runs that file: + +```powershell +$base = 'https://github.com/Labeeb2339/PortCVE/releases/latest/download' +$dir = Join-Path $env:TEMP 'portcve-installer' +New-Item -ItemType Directory -Force $dir | Out-Null + +curl.exe --fail --location --proto '=https' --tlsv1.2 "$base/install.ps1" --output "$dir/install.ps1" +curl.exe --fail --location --proto '=https' --tlsv1.2 "$base/SHA256SUMS.txt" --output "$dir/SHA256SUMS.txt" + +$lines = Get-Content "$dir/SHA256SUMS.txt" +$entry = @($lines | Where-Object { $_ -match '^(?[0-9a-fA-F]{64})\s+\*?install\.ps1$' }) +if ($entry.Count -ne 1) { throw 'Expected exactly one install.ps1 checksum.' } +$entry[0] -match '^(?[0-9a-fA-F]{64})' | Out-Null +$expected = $Matches.hash.ToLowerInvariant() +$actual = (Get-FileHash "$dir/install.ps1" -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -cne $expected) { throw 'Installer checksum mismatch.' } + +$signature = Get-AuthenticodeSignature -LiteralPath "$dir/install.ps1" +if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or $null -eq $signature.TimeStamperCertificate) { + throw "Installer Authenticode verification failed: $($signature.StatusMessage)" +} +$signature.SignerCertificate.Subject # compare with the documented PortCVE publisher + +Get-Content "$dir/install.ps1" # inspect before execution +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$dir/install.ps1" +``` + +The no-argument installer selects GitHub's latest stable release. To install an explicit release, including a release candidate, pass its exact tag: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$dir/install.ps1" -Version v1.0.0 +``` + +An optional per-user destination can be selected with `-InstallDirectory`. The installer refuses dangerous roots, reparse-point targets, and directories containing files it does not manage. Piped, dot-generated, or other in-memory installation is not supported: download and invoke the signed file. + +## What the installer verifies + +The installer has no unsigned or signature-bypass mode. Before changing the installation it: + +1. requires file-backed execution and requires Windows to report its own Authenticode signature and timestamp as trusted, with the exact embedded signer subject plus the Code Signing and Time Stamping EKUs, before any network or install-directory mutation; +2. resolves only `Labeeb2339/PortCVE` through GitHub's HTTPS API; +3. downloads the exact versioned Windows x64 ZIP and `SHA256SUMS.txt` with fixed size and timeout limits; +4. requires one exact checksum entry and verifies the complete ZIP with SHA-256; +5. extracts only the root `portcve.exe` through traversal-safe ZIP handling; +6. requires Windows to report a valid trusted Authenticode signature chain; +7. compares the executable's full signer certificate subject exactly with the same release-embedded identity; +8. requires the Code Signing EKU and a Windows-validated timestamp certificate with the Time Stamping EKU; and +9. verifies the copied executable again before installation. + +Files are prepared in bounded, uniquely named staging directories on the target volume. Updates move the prior installation to a guarded backup, atomically move the staged directory into place, update only the user `PATH`, and restore the prior directory and `PATH` if a later step fails. Cleanup is limited to validated installer-owned temporary, staging, backup, or failed-install paths. + +The installer sends no telemetry. Its only network requests are release metadata and assets from GitHub. An installation receipt records the release tag, ZIP checksum, signer subject, timestamp subject, and installation time locally. + +Windows PowerShell 5.1 does not expose .NET's `Rfc3161TimestampToken.VerifySignatureForSignerInfo` primitive. The installer therefore relies on Windows Authenticode trust for its timestamp and does not claim to independently prove RFC 3161 message-imprint binding. The release workflow performs that separate proof for both published signed files under PowerShell 7 and refuses publication if decoding, signature binding, or trusted TSA matching fails. + +## Current unsigned alpha + +`v0.1.0-alpha.1` was published before code signing was configured. The production installer intentionally cannot install that unsigned artifact. Build it from source or manually verify the historical checksum only if you explicitly accept that alpha's unsigned status. diff --git a/docs/release-signing.md b/docs/release-signing.md new file mode 100644 index 0000000..e36c01d --- /dev/null +++ b/docs/release-signing.md @@ -0,0 +1,135 @@ +# PortCVE 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 document is an operator runbook, not evidence that the current repository or certificate account is already configured. Repository settings and SSL.com identity validation must be completed by a maintainer before the first signed release. + +## Malaysia signing route + +For a maintainer or organization based in Malaysia, SSL.com eSigner is the practical route currently wired into the workflow. The certificate holder must complete SSL.com's identity validation and obtain a code-signing credential that can be used with eSigner automation. The Windows publisher shown to users will be the validated legal subject in the certificate; it cannot honestly be made an arbitrary project nickname. + +SSL.com currently lists an Individual Validation code-signing certificate from USD 129 per year and eSigner Tier 1 from USD 180 per year, before tax, with the first 30 days of eSigner included for new code-signing orders. Pricing and eligibility can change, so confirm them before purchase: + +- [SSL.com 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/) + +Expect government-ID, address, and liveness checks for an individual, or company-registration and authorized-representative checks for an organization. Keep the eSigner automation credential dedicated to PortCVE releases and grant only the access it needs. + +Azure Artifact Signing is not a fallback for a Malaysian individual or Malaysia-incorporated organization under Microsoft's current country eligibility. Microsoft currently supports individual accounts only in the United States and Canada, and its organization-country list does not include Malaysia. Recheck the official [Artifact Signing prerequisites](https://learn.microsoft.com/en-us/azure/artifact-signing/quickstart) if Microsoft expands availability. + +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: + +1. Create an environment named exactly `release-signing`. +2. Add required reviewers, prevent self-review, restrict deployments to release tags, and do not allow administrators to bypass the protection. GitHub documents these controls under [deployment environments](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments). +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: + + ```powershell + (Get-AuthenticodeSignature -LiteralPath .\portcve.exe).SignerCertificate.Subject + ``` + +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. + +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. + +## What the workflow enforces + +The workflow in `.github/workflows/release.yml` has three security boundaries: + +- `build` has read-only repository access and no signing secrets. It restores locked dependencies, checks formatting, builds, tests, publishes exactly one unsigned `portcve.exe`, and smoke-tests it. +- `sign` runs only after approval in `release-signing`. It fails if any secret or the expected full signer subject is missing. It finalizes `install.ps1` with a UTF-8 BOM, verifies the exact SHA-256 of SSL.com CodeSignTool 1.3.0, and invokes the pinned SSL.com action separately for the exact `portcve.exe` and `install.ps1` paths. +- `package_publish` has the release and attestation permissions. It downloads only the two verified signed files, repeats signature verification, creates the ZIP, writes a checksum for every public asset other than the checksum file itself, generates GitHub provenance attestations, creates a draft, checks GitHub's recorded asset digests, and only then publishes it. + +Signature verification requires all of the following: + +- 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. + +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. + +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. + +## 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: + + ```powershell + git switch main + git pull --ff-only + git tag -s v1.0.0 -m "PortCVE v1.0.0" + 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 separate Windows machine and run the consumer checks below. + +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. + +## Consumer verification + +From a clean checkout of the exact release tag, with the downloaded release assets placed at the repository root: + +```powershell +$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() + 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' + +pwsh -NoProfile -File .\scripts\Verify-ReleaseSignature.ps1 ` + -Path .\install.ps1 ` + -ExpectedSignerSubject 'PASTE THE EXACT PUBLISHED FULL X.500 SUBJECT' +``` + +GitHub provenance can also be verified with GitHub CLI after authenticating: + +```powershell +gh attestation verify .\portcve.exe --repo Labeeb2339/PortCVE +gh attestation verify .\portcve-v1.0.0-win-x64.zip --repo Labeeb2339/PortCVE +``` + +## Pre-1.0 release gate + +Do not call a build `1.0.0` until every item is evidenced: + +- SSL.com validation and the production eSigner credential are active. +- `EXPECTED_SIGNER_SUBJECT` was copied from a controlled test signature and independently reviewed. +- `release-signing` has required reviewers, self-review prevention, and release-tag restrictions. +- `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. +- `portcve.exe --version` and a no-firewall snapshot smoke test passed after signing and after download. +- Every file in `SHA256SUMS.txt` matched, the installer rejected a tampered ZIP/executable, and GitHub provenance verification passed. +- The ZIP contains the same signed executable hash recorded in `SIGNING-METADATA.json`. +- Release notes, license, security policy, schema files, and vulnerability-data limitations are accurate for 1.0. +- Defender/SmartScreen behavior was observed on a clean Windows machine and described honestly, without promising reputation or warning-free execution. + +Record the test tag, release URL, workflow run ID, executable SHA-256, signer subject, and verification machine details in the release evidence. Never record the four eSigner secrets or authentication logs. diff --git a/docs/threat-model.md b/docs/threat-model.md index 237da59..e7ae35c 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -28,6 +28,8 @@ Lockfiles are user-provided data. Their schema is validated before comparison, b - Accidental storage of PIDs, timestamps, usernames, paths, or arguments in privacy-reduced lockfiles - Accidental disclosure of local addresses, process/container identities, image references, paths, firewall-rule details, or diagnostic text in redacted snapshots - Unexpected domain/network lookup when resolving account SIDs +- Argument injection, hangs, unbounded output, or child-process escape in the external vulnerability scanner +- Stale, missing, malformed, or changing local vulnerability evidence ## Out of scope @@ -39,6 +41,9 @@ Lockfiles are user-provided data. Their schema is validated before comparison, b - Router, NAT, VPN gateway, cloud security group, or remote-host behavior - Proving that a port is reachable from the Internet - Safely executing an untrusted binary +- Proving a known advisory is exploitable, reachable through the selected port, or applicable to code loaded at runtime +- Inferring a product or CPE from a native process name, executable metadata, port number, or banner +- Downloading or updating Trivy or its vulnerability database ## Safe failure rules @@ -50,6 +55,13 @@ Lockfiles are user-provided data. Their schema is validated before comparison, b - An absent Docker pipe degrades quickly to optional `unavailable` evidence and never starts Docker Desktop or a container. Access denial, timeout, or failed Engine collection cannot become complete container baseline evidence. - Watch does not report removals from a failed endpoint snapshot. - V1 never kills a process, closes a socket, changes a firewall rule, or sends a probe. +- Vulnerability subjects are limited to exact immutable Docker image IDs and explicitly supplied SBOMs. Unresolved native processes are `not_supported`, never silently clean. +- Trivy is launched directly with an argument list, bounded time and output, process-tree termination on cancellation or limits, and offline/update/telemetry flags. Post-kill waiting also has a fixed grace period, so failed termination cannot hang PortCVE indefinitely. PortCVE does not invoke a shell or fall back from the local Docker image source to a registry. +- Every inherited `TRIVY_*` variable is removed case-insensitively before PortCVE sets its small offline allowlist. Each scan gets a validated local temp directory through `TMP` and `TEMP`; cleanup is limited to the exact generated child and runs for success, failure, timeout, output overflow, or cancellation. +- The Trivy executable is an explicit user trust boundary: PortCVE executes `PORTCVE_TRIVY_PATH`, or `trivy.exe` as resolved by the caller's `PATH`. Operators must provide a trusted local executable and must not point either setting at a UNC path or reparse point; the offline flags cannot make an untrusted executable safe. +- Missing or invalid database metadata is `unavailable`. A database older than 72 hours is `partial`; `--strict` returns exit code `3`. +- SBOMs must be local regular files. UNC paths, mapped network drives, and reparse-point traversal are rejected before collection. Files are hashed before and after scanning; changed input findings are discarded and the scan cannot become successful partial evidence. +- A zero-match result is qualified by database date and completeness. A finding is a package/advisory match, not proof of exploitability or reachability. ## Privacy modes @@ -59,6 +71,8 @@ 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, BindWitness correlation, 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 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. Account-name resolution is off by default. `--resolve-accounts` uses Windows `LookupAccountSid`, which can contact a domain controller or global catalog when data is not available locally. This opt-in weakens the otherwise local/offline collection boundary and is documented separately from `--include-private`. + +Vulnerability JSON is redacted by default. It retains advisory IDs, package names and versions, severities, fix metadata, selected ports, bind scope, and database freshness because those are the report's operational content. It replaces Docker image references and SBOM names, omits artifact IDs/hashes, normalizes listener keys, and sanitizes free-form limitations and diagnostics. `--include-private` can expose local SBOM paths, immutable image IDs, image references, and detailed scanner diagnostics; review it before sharing. diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..7ef6019 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,80 @@ +# Validation evidence + +This file records dated release-candidate evidence. It is not a guarantee about other hosts, artifacts, databases, or future versions. + +## Host and Docker path — 2026-08-09 + +- Windows NT `10.0.26200.0`, Windows x64. +- Docker Desktop client/server `28.3.2`, `desktop-linux` WSL2 context. +- Official `alpine:3.22` fixture, image ID `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`. +- Real TCP and UDP echo payloads succeeded through temporary published host ports. +- Independent Windows CIM observation found the exact host tuples. +- PortCVE retained the Windows forwarding-process owner and attached both Docker publications by protocol/address/port with collector status `complete`. +- Default JSON redaction, private mapping evidence, container-image lock identity, unchanged `check`, and owner-change failure were exercised. + +The reproducible harness is: + +```powershell +.\scripts\Test-DockerIntegration.ps1 -ValidateLockCheck +``` + +The script creates and removes a uniquely labelled local container. Its safe default publishes only loopback host ports; wildcard UDP requires the explicit `-AllowWildcardUdp` option. + +## Offline known-advisory path — 2026-08-09 + +Scanner integrity: + +- Official Trivy release: [`v0.73.0`](https://github.com/aquasecurity/trivy/releases/tag/v0.73.0), published 2026-08-03. +- Downloaded checksum-manifest SHA-256: `36890275ffdff13025e9bd9fe039724c6e36bf58e698499856b801f619046fe2`. +- Published and observed Windows x64 ZIP SHA-256: `d2d3ad5292aae470a03eb6506db86fce81b1894592b8451cadaf60eaa22f2025`. +- Extracted `trivy.exe` SHA-256: `3f8d0a3f4306a628cccb0704ab5f9ab6589a8ff17f89d943722d551cdf8940ef`. +- The official executable was not Authenticode-signed; integrity for this validation was established by the matching checksum from the official GitHub release manifest. + +Database evidence: + +- Schema version `2`. +- `UpdatedAt`: `2026-08-09T07:04:26.972846897Z`. +- Database SHA-256: `5b9a2be561c5d1788c1df9e5974654bda5780f3e511eb20ae3a50945302ed502`. +- PortCVE freshness limit: 72 hours. + +Pinned target: + +- Local immutable Docker image ID: `sha256:c4d56c24da4f009ecf8352146b43497fe78953edb4c679b841732beb97e588b0`. +- Observed OS: Alpine `3.22.1`. +- PortCVE passed only the immutable image ID to Trivy; it did not pass a registry tag or pull the image. + +Observed report: + +| Severity | Matches | +| --- | ---: | +| Critical | 3 | +| High | 17 | +| Medium | 26 | +| Low | 41 | +| Total | 87 | + +Representative database matches included `CVE-2025-58050` for `pcre2` `10.43-r1` (fixed in `10.46-r0`) and `CVE-2026-31789` for `libssl3`/`libcrypto3` `3.5.1-r0` (fixed in `3.5.6-r0`). These are advisory/package-version matches from the named database snapshot, not exploitability determinations. + +Behavioral gates: + +| Scenario | Expected exit | Observed | +| --- | ---: | ---: | +| Fresh database with `--strict` | `0` | `0` | +| `--fail-on critical` | `1` | `1` | +| `--fail-on high` | `1` | `1` | +| Missing database with `--strict` | `3` | `3` (`vulnerability_db_missing`) | +| Database aged to 96 hours with `--strict` | `3` | `3` (`vulnerability_db_stale`, all 87 findings retained) | + +Seven live JSON variants and a hostile-environment rerun validated against the Draft 2020-12 vulnerability schema. Default JSON omitted the immutable image ID, artifact hash/reference, and raw listener address; private JSON retained the exact image ID. Both modes retained the same 87 operational findings. + +The hostile environment set remote/suppressive `TRIVY_*` values. PortCVE removed them before setting its offline allowlist and still returned the full report. Docker event/inventory checks showed zero pulls and no image-inventory change. Per-invocation scanner temp directories and test containers were absent after completion. + +## Claim boundary + +The evidence above supports the tested Windows collection, Docker tuple-correlation, Trivy adapter, parser, redaction, schema, cleanup, and exit-policy paths. It does not prove: + +- that a wildcard bind is reachable from a LAN or the Internet; +- that a matched package is reachable, exploitable, or compromised; +- that no unreported vulnerability exists; +- that a zero-finding result is safe beyond the named database snapshot; or +- compatibility with every Windows, Docker, Trivy, image, SBOM, or firewall configuration. diff --git a/docs/versioning.md b/docs/versioning.md index 13f6c94..f20353e 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -4,10 +4,13 @@ Every JSON document and lockfile starts with `schema_version`. Published schema documents use location-independent URNs: -- `urn:bindwitness:schema:snapshot:v1` -- `urn:bindwitness:schema:lock:v1` +- `urn:portcve:schema:snapshot:v1` +- `urn:portcve:schema:lock:v1` +- `urn:portcve:schema:vulnerability:v1` -These identifiers do not imply that a `bindwitness.dev` website or schema host exists. +These identifiers do not imply that a `portcve.dev` website or schema host exists. + +The `v0.1.0-alpha.1` release used `bindwitness.*.v1.schema.json` filenames, `urn:bindwitness:schema:*:v1` identifiers, and a `bindwitness/` `created_by` value. The pre-1.0 PortCVE rename changes those brand identifiers without changing the JSON instance shape or `schema_version: 1`. Existing alpha lockfiles remain readable because compatibility is determined from `schema_version` and the document fields; consumers that pinned an old schema filename or `$id` must update that reference. Before `1.0`, incompatible schema changes require a schema-version increment and a changelog entry. Readers reject unknown lockfile schema versions instead of guessing. diff --git a/schema/bindwitness.lock.v1.schema.json b/schema/portcve.lock.v1.schema.json similarity index 96% rename from schema/bindwitness.lock.v1.schema.json rename to schema/portcve.lock.v1.schema.json index 941007c..2608952 100644 --- a/schema/bindwitness.lock.v1.schema.json +++ b/schema/portcve.lock.v1.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:bindwitness:schema:lock:v1", - "title": "BindWitness listener lockfile v1", + "$id": "urn:portcve:schema:lock:v1", + "title": "PortCVE listener lockfile v1", "description": "A deterministic, normalized, privacy-reduced baseline of local endpoints. Volatile process data and raw private process/container evidence are not stored, but normalized owner identities, including image-set hashes, can still fingerprint installed or running software.", "type": "object", "additionalProperties": false, @@ -20,7 +20,7 @@ "created_by": { "type": "string", "minLength": 1, - "description": "The BindWitness tool and version that wrote the lockfile." + "description": "The PortCVE tool and version that wrote the lockfile." }, "includes_udp": { "type": "boolean", diff --git a/schema/bindwitness.snapshot.v1.schema.json b/schema/portcve.snapshot.v1.schema.json similarity index 99% rename from schema/bindwitness.snapshot.v1.schema.json rename to schema/portcve.snapshot.v1.schema.json index c6b9b6c..f5b2454 100644 --- a/schema/bindwitness.snapshot.v1.schema.json +++ b/schema/portcve.snapshot.v1.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:bindwitness:schema:snapshot:v1", - "title": "BindWitness system snapshot v1", + "$id": "urn:portcve:schema:snapshot:v1", + "title": "PortCVE system snapshot v1", "description": "A point-in-time local endpoint snapshot. The same shape is used for default-redacted and --include-private output; sensitive process, network, firewall, and Docker container/image fields are omitted or replaced in redacted output.", "type": "object", "additionalProperties": false, diff --git a/schema/portcve.vulnerability.v1.schema.json b/schema/portcve.vulnerability.v1.schema.json new file mode 100644 index 0000000..67fa4ff --- /dev/null +++ b/schema/portcve.vulnerability.v1.schema.json @@ -0,0 +1,209 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:portcve:schema:vulnerability:v1", + "title": "PortCVE vulnerability report v1", + "description": "Offline known-advisory matches for exact Docker image IDs or an explicitly supplied SBOM. Findings do not establish exploitability or network reachability.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "tool_version", + "generated_at", + "selector", + "subjects", + "provider_runs", + "findings", + "summary", + "diagnostics" + ], + "properties": { + "schema_version": { "const": 1 }, + "tool_version": { "type": "string", "minLength": 1 }, + "generated_at": { "type": "string", "format": "date-time" }, + "selector": { "type": "string", "minLength": 1 }, + "subjects": { + "type": "array", + "items": { "$ref": "#/$defs/subject" } + }, + "provider_runs": { + "type": "array", + "items": { "$ref": "#/$defs/provider_run" } + }, + "findings": { + "type": "array", + "items": { "$ref": "#/$defs/finding" } + }, + "summary": { "$ref": "#/$defs/summary" }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/diagnostic" } + } + }, + "$defs": { + "subject_kind": { + "enum": ["container_image", "sbom", "host_process"] + }, + "identity_confidence": { + "enum": ["exact", "declared", "unresolved"] + }, + "scan_status": { + "enum": ["complete", "partial", "unavailable", "failed", "not_supported"] + }, + "provider_status": { + "enum": ["complete", "partial", "unavailable", "failed"] + }, + "severity": { + "enum": ["unknown", "low", "medium", "high", "critical"] + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": [ + "subject_id", + "kind", + "display_name", + "identity_confidence", + "listeners", + "scan_status", + "limitations" + ], + "properties": { + "subject_id": { "type": "string", "minLength": 1 }, + "kind": { "$ref": "#/$defs/subject_kind" }, + "display_name": { "type": "string", "minLength": 1 }, + "artifact_reference": { "type": "string", "minLength": 1 }, + "artifact_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "identity_confidence": { "$ref": "#/$defs/identity_confidence" }, + "listeners": { + "type": "array", + "items": { "$ref": "#/$defs/listener_reference" } + }, + "scan_status": { "$ref": "#/$defs/scan_status" }, + "limitations": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "listener_reference": { + "type": "object", + "additionalProperties": false, + "required": ["key", "protocol", "family", "bind_scope", "local_port"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "protocol": { "const": "tcp" }, + "family": { "enum": ["ipv4", "ipv6"] }, + "bind_scope": { "enum": ["loopback", "interface", "wildcard", "unknown"] }, + "local_port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "provider_run": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "network_mode", "status", "duration_ms", "diagnostics"], + "properties": { + "provider": { "const": "trivy" }, + "engine_version": { "type": "string", "minLength": 1 }, + "database_updated_at": { "type": "string", "format": "date-time" }, + "database_age_seconds": { "type": "integer", "minimum": 0 }, + "network_mode": { "const": "offline" }, + "status": { "$ref": "#/$defs/provider_status" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/diagnostic" } + } + } + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "status", "code", "message"], + "properties": { + "provider": { "type": "string", "minLength": 1 }, + "status": { "$ref": "#/$defs/provider_status" }, + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + }, + "package": { + "type": "object", + "additionalProperties": false, + "required": ["name", "installed_version", "fixed_versions"], + "properties": { + "ecosystem": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "installed_version": { "type": "string", "minLength": 1 }, + "fixed_versions": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": [ + "finding_id", + "subject_id", + "type", + "advisory_id", + "aliases", + "package", + "match_method", + "match_confidence", + "severity", + "fix_state", + "exploitability", + "network_reachability", + "references" + ], + "properties": { + "finding_id": { "type": "string", "minLength": 1 }, + "subject_id": { "type": "string", "minLength": 1 }, + "type": { "const": "known_advisory_match" }, + "advisory_id": { "type": "string", "minLength": 1 }, + "aliases": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "package": { "$ref": "#/$defs/package" }, + "match_method": { "enum": ["vendor_package_version", "sbom_package_version"] }, + "match_confidence": { "$ref": "#/$defs/identity_confidence" }, + "severity": { "$ref": "#/$defs/severity" }, + "severity_source": { "type": "string", "minLength": 1 }, + "fix_state": { "enum": ["fixed_version_available", "no_fixed_version", "unknown"] }, + "exploitability": { "const": "not_assessed" }, + "network_reachability": { "const": "not_assessed" }, + "title": { "type": "string", "minLength": 1 }, + "primary_url": { "type": "string", "minLength": 1 }, + "references": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "selected_listener_count", + "subject_count", + "complete_subject_count", + "finding_count", + "critical_count", + "high_count", + "is_complete" + ], + "properties": { + "selected_listener_count": { "type": "integer", "minimum": 0 }, + "subject_count": { "type": "integer", "minimum": 0 }, + "complete_subject_count": { "type": "integer", "minimum": 0 }, + "finding_count": { "type": "integer", "minimum": 0 }, + "critical_count": { "type": "integer", "minimum": 0 }, + "high_count": { "type": "integer", "minimum": 0 }, + "is_complete": { "type": "boolean" } + } + } + } +} diff --git a/scripts/Finalize-ReleaseInstaller.ps1 b/scripts/Finalize-ReleaseInstaller.ps1 new file mode 100644 index 0000000..51fe67e --- /dev/null +++ b/scripts/Finalize-ReleaseInstaller.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$TemplatePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ExpectedSignerSubject +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($ExpectedSignerSubject) -or + $ExpectedSignerSubject -ne $ExpectedSignerSubject.Trim() -or + $ExpectedSignerSubject.Contains("`r") -or $ExpectedSignerSubject.Contains("`n")) { + throw 'Expected signer subject is missing or malformed.' +} + +$resolvedTemplate = (Resolve-Path -LiteralPath $TemplatePath -ErrorAction Stop).Path +if (-not (Test-Path -LiteralPath $resolvedTemplate -PathType Leaf)) { + throw "Installer template is not a file: $resolvedTemplate" +} +if (-not [StringComparer]::Ordinal.Equals([IO.Path]::GetFileName($resolvedTemplate), 'install.ps1')) { + throw "Installer template must be named exactly 'install.ps1'." +} +if (-not [StringComparer]::Ordinal.Equals([IO.Path]::GetFileName($OutputPath), 'install.ps1')) { + throw "Finalized installer must be named exactly 'install.ps1'." +} + +$placeholder = '__PORTCVE_EXPECTED_SIGNER_SUBJECT__' +$installer = [IO.File]::ReadAllText($resolvedTemplate) +$placeholderCount = [regex]::Matches($installer, [regex]::Escape($placeholder)).Count +if ($placeholderCount -ne 1) { + throw "Installer template must contain exactly one signer-subject placeholder; found $placeholderCount." +} + +# The placeholder is inside a single-quoted PowerShell literal. Doubling an +# apostrophe is the only escaping needed for a literal single-quoted string. +$escapedSignerSubject = $ExpectedSignerSubject.Replace("'", "''") +$installer = $installer.Replace($placeholder, $escapedSignerSubject) +if ($installer.Contains($placeholder)) { + throw 'Installer signer-subject placeholder replacement failed.' +} + +$outputParent = Split-Path -Parent $OutputPath +if ([string]::IsNullOrWhiteSpace($outputParent)) { + $outputParent = $PWD.Path +} +if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) { + New-Item -ItemType Directory -Path $outputParent | Out-Null +} +$resolvedOutputParent = (Resolve-Path -LiteralPath $outputParent).Path +$resolvedOutput = Join-Path $resolvedOutputParent (Split-Path -Leaf $OutputPath) +if (Test-Path -LiteralPath $resolvedOutput) { + throw "Refusing to overwrite existing finalized installer: $resolvedOutput" +} + +# Windows PowerShell 5.1 treats BOM-less script files as the active ANSI code +# page. The BOM is required so non-ASCII X.500 subjects survive exact matching. +[IO.File]::WriteAllText($resolvedOutput, $installer, [Text.UTF8Encoding]::new($true)) + +$bytes = [IO.File]::ReadAllBytes($resolvedOutput) +if ($bytes.Length -lt 3 -or $bytes[0] -ne 0xef -or $bytes[1] -ne 0xbb -or $bytes[2] -ne 0xbf) { + throw 'Finalized installer is not UTF-8 with BOM.' +} + +$tokens = $null +$parseErrors = $null +$installerAst = [Management.Automation.Language.Parser]::ParseFile( + $resolvedOutput, + [ref]$tokens, + [ref]$parseErrors +) +if ($parseErrors.Count -ne 0) { + throw "Finalized installer is not valid PowerShell: $($parseErrors.Message -join '; ')" +} + +$subjectAssignments = @($installerAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + [StringComparer]::Ordinal.Equals($node.Left.Extent.Text, '$script:ExpectedSignerSubject') +}, $true)) +if ($subjectAssignments.Count -ne 1 -or + $subjectAssignments[0].Right -isnot [Management.Automation.Language.CommandExpressionAst] -or + $subjectAssignments[0].Right.Expression -isnot [Management.Automation.Language.StringConstantExpressionAst] -or + -not [StringComparer]::Ordinal.Equals( + [string]$subjectAssignments[0].Right.Expression.Value, + $ExpectedSignerSubject + )) { + throw 'Finalized installer does not evaluate to the exact expected signer subject.' +} + +Get-Item -LiteralPath $resolvedOutput diff --git a/scripts/Rfc3161TimestampValidation.ps1 b/scripts/Rfc3161TimestampValidation.ps1 new file mode 100644 index 0000000..93b80db --- /dev/null +++ b/scripts/Rfc3161TimestampValidation.ps1 @@ -0,0 +1,228 @@ +#requires -Version 7.2 + +Set-StrictMode -Version Latest + +$script:Rfc3161TimestampTokenOid = '1.3.6.1.4.1.311.3.3.1' +$script:LegacyAuthenticodeTimestampOid = '1.2.840.113549.1.9.6' + +function Get-PowerShellSignatureContent { + param([Parameter(Mandatory)][string]$ScriptPath) + + $lines = [IO.File]::ReadAllLines($ScriptPath) + $beginIndexes = @() + $endIndexes = @() + for ($index = 0; $index -lt $lines.Length; $index++) { + if ([StringComparer]::Ordinal.Equals($lines[$index], '# SIG # Begin signature block')) { $beginIndexes += $index } + if ([StringComparer]::Ordinal.Equals($lines[$index], '# SIG # End signature block')) { $endIndexes += $index } + } + if ($beginIndexes.Count -ne 1 -or $endIndexes.Count -ne 1 -or $endIndexes[0] -le ($beginIndexes[0] + 1)) { + throw 'PowerShell Authenticode signature block is missing or ambiguous.' + } + + $segments = New-Object 'Collections.Generic.List[string]' + for ($index = $beginIndexes[0] + 1; $index -lt $endIndexes[0]; $index++) { + if ($lines[$index] -notmatch '^# (?[0-9A-Za-z+/=]+)$') { + throw 'PowerShell Authenticode signature block contains a malformed line.' + } + $segments.Add($Matches.data) + } + return ,([Convert]::FromBase64String(($segments -join ''))) +} + +function Get-PeSignatureContent { + param([Parameter(Mandatory)][string]$ExecutablePath) + + $bytes = [IO.File]::ReadAllBytes($ExecutablePath) + if ($bytes.Length -lt 256 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + throw 'Signed executable is not a valid PE file.' + } + + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ($peOffset -lt 0 -or $peOffset + 256 -gt $bytes.Length -or + $bytes[$peOffset] -ne 0x50 -or $bytes[$peOffset + 1] -ne 0x45 -or + $bytes[$peOffset + 2] -ne 0 -or $bytes[$peOffset + 3] -ne 0) { + throw 'Signed executable has an invalid PE header.' + } + + $optionalHeaderOffset = $peOffset + 24 + $optionalMagic = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset) + $dataDirectoryOffset = switch ($optionalMagic) { + 0x10b { 96 } + 0x20b { 112 } + default { throw "Unsupported PE optional-header magic: 0x$($optionalMagic.ToString('x'))." } + } + $securityDirectoryOffset = $optionalHeaderOffset + $dataDirectoryOffset + (4 * 8) + if ($securityDirectoryOffset + 8 -gt $bytes.Length) { + throw 'PE security directory lies outside the executable.' + } + + $certificateOffset = [BitConverter]::ToUInt32($bytes, $securityDirectoryOffset) + $certificateTableSize = [BitConverter]::ToUInt32($bytes, $securityDirectoryOffset + 4) + if ($certificateOffset -eq 0 -or $certificateTableSize -lt 8 -or + [uint64]$certificateOffset + [uint64]$certificateTableSize -gt [uint64]$bytes.Length) { + throw 'PE security directory is missing or invalid.' + } + + $winCertificateLength = [BitConverter]::ToUInt32($bytes, [int]$certificateOffset) + $winCertificateRevision = [BitConverter]::ToUInt16($bytes, [int]$certificateOffset + 4) + $winCertificateType = [BitConverter]::ToUInt16($bytes, [int]$certificateOffset + 6) + if ($winCertificateRevision -ne 0x0200 -or $winCertificateType -ne 0x0002 -or + $winCertificateLength -le 8 -or $winCertificateLength -gt $certificateTableSize) { + throw 'PE certificate table does not contain a valid PKCS#7 WIN_CERTIFICATE entry.' + } + + $content = New-Object byte[] ([int]$winCertificateLength - 8) + [Array]::Copy($bytes, [int]$certificateOffset + 8, $content, 0, $content.Length) + return ,$content +} + +function Assert-BoundRfc3161TimestampToken { + param( + [Parameter(Mandatory)][Security.Cryptography.Pkcs.SignerInfo]$SignerInfo, + [Parameter(Mandatory)][byte[]]$TokenBytes, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2Collection]$ExtraCandidates, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2]$ExpectedTimestampCertificate + ) + + $timestampType = 'System.Security.Cryptography.Pkcs.Rfc3161TimestampToken' -as [type] + if ($null -eq $timestampType -or + $null -eq $timestampType.GetMethod('VerifySignatureForSignerInfo')) { + throw 'The required Rfc3161TimestampToken.VerifySignatureForSignerInfo platform primitive is unavailable. Run release verification with supported PowerShell 7.' + } + if ($TokenBytes.Length -eq 0) { + throw 'RFC 3161 timestamp token is empty.' + } + + [Security.Cryptography.Pkcs.Rfc3161TimestampToken]$timestampToken = $null + $bytesConsumed = 0 + $memory = [ReadOnlyMemory[byte]]::new($TokenBytes) + try { + $decoded = [Security.Cryptography.Pkcs.Rfc3161TimestampToken]::TryDecode( + $memory, + [ref]$timestampToken, + [ref]$bytesConsumed) + } + catch { + throw "RFC 3161 timestamp token decoding failed: $($_.Exception.Message)" + } + if (-not $decoded -or $null -eq $timestampToken -or $bytesConsumed -ne $TokenBytes.Length) { + throw 'RFC 3161 timestamp token is malformed or contains trailing data.' + } + + $tokenCms = $timestampToken.AsSignedCms() + if ($tokenCms.SignerInfos.Count -ne 1) { + throw "RFC 3161 timestamp token must contain exactly one signer; found $($tokenCms.SignerInfos.Count)." + } + + [Security.Cryptography.X509Certificates.X509Certificate2]$timestampSigner = $null + try { + $isBound = $timestampToken.VerifySignatureForSignerInfo( + $SignerInfo, + [ref]$timestampSigner, + $ExtraCandidates) + } + catch { + throw "RFC 3161 timestamp token verification failed: $($_.Exception.Message)" + } + if (-not $isBound -or $null -eq $timestampSigner) { + throw 'RFC 3161 timestamp token is not cryptographically valid and bound to the primary Authenticode SignerInfo.' + } + + $actualCertificate = [Convert]::ToBase64String($timestampSigner.RawData) + $expectedCertificate = [Convert]::ToBase64String($ExpectedTimestampCertificate.RawData) + if (-not [StringComparer]::Ordinal.Equals($actualCertificate, $expectedCertificate)) { + throw 'Cryptographically verified RFC 3161 signer does not match the trusted Authenticode timestamp certificate.' + } + + return [pscustomobject]@{ + Token = $timestampToken + TimestampSignerCertificate = $timestampSigner + Timestamp = $timestampToken.TokenInfo.Timestamp + } +} + +function Assert-Rfc3161SignerInfo { + param( + [Parameter(Mandatory)][Security.Cryptography.Pkcs.SignerInfo]$SignerInfo, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2Collection]$ExtraCandidates, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2]$ExpectedTimestampCertificate + ) + + $rfc3161Attributes = @($SignerInfo.UnsignedAttributes | Where-Object { + [StringComparer]::Ordinal.Equals($_.Oid.Value, $script:Rfc3161TimestampTokenOid) + }) + $legacyAttributes = @($SignerInfo.UnsignedAttributes | Where-Object { + [StringComparer]::Ordinal.Equals($_.Oid.Value, $script:LegacyAuthenticodeTimestampOid) + }) + if ($legacyAttributes.Count -ne 0) { + throw 'Legacy Authenticode countersignatures are not accepted as an RFC 3161 timestamp.' + } + if ($rfc3161Attributes.Count -ne 1 -or $rfc3161Attributes[0].Values.Count -ne 1) { + throw 'Authenticode SignerInfo must contain exactly one RFC 3161 timestamp attribute with exactly one token.' + } + + return Assert-BoundRfc3161TimestampToken ` + -SignerInfo $SignerInfo ` + -TokenBytes $rfc3161Attributes[0].Values[0].RawData ` + -ExtraCandidates $ExtraCandidates ` + -ExpectedTimestampCertificate $ExpectedTimestampCertificate +} + +function Assert-Rfc3161TimestampContent { + param( + [Parameter(Mandatory)][byte[]]$Content, + [Parameter(Mandatory)][string]$ExpectedSubject, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2]$ExpectedSignerCertificate, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2]$ExpectedTimestampCertificate + ) + + if ($null -eq ('System.Security.Cryptography.Pkcs.SignedCms' -as [type])) { + throw 'System.Security.Cryptography.Pkcs is unavailable. Run release verification with supported PowerShell 7.' + } + + $cms = [Security.Cryptography.Pkcs.SignedCms]::new() + $cms.Decode($Content) + $cms.CheckSignature($true) + if ($cms.SignerInfos.Count -ne 1 -or $null -eq $cms.SignerInfos[0].Certificate) { + throw "Expected exactly one embedded Authenticode signer, found $($cms.SignerInfos.Count)." + } + if (-not [StringComparer]::Ordinal.Equals($cms.SignerInfos[0].Certificate.Subject, $ExpectedSubject)) { + throw "Embedded Authenticode signer subject does not match EXPECTED_SIGNER_SUBJECT. Actual: '$($cms.SignerInfos[0].Certificate.Subject)'." + } + if (-not [StringComparer]::Ordinal.Equals( + [Convert]::ToBase64String($cms.SignerInfos[0].Certificate.RawData), + [Convert]::ToBase64String($ExpectedSignerCertificate.RawData))) { + throw 'Embedded Authenticode signer certificate does not match the Windows-trusted signer certificate.' + } + + return Assert-Rfc3161SignerInfo ` + -SignerInfo $cms.SignerInfos[0] ` + -ExtraCandidates $cms.Certificates ` + -ExpectedTimestampCertificate $ExpectedTimestampCertificate +} + +function Assert-Rfc3161Timestamp { + param( + [Parameter(Mandatory)][string]$ArtifactPath, + [Parameter(Mandatory)][string]$ExpectedSubject, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2]$ExpectedSignerCertificate, + [Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2]$ExpectedTimestampCertificate + ) + + $extension = [IO.Path]::GetExtension($ArtifactPath) + $cmsBytes = if ([StringComparer]::OrdinalIgnoreCase.Equals($extension, '.exe')) { + Get-PeSignatureContent -ExecutablePath $ArtifactPath + } + elseif ([StringComparer]::OrdinalIgnoreCase.Equals($extension, '.ps1')) { + Get-PowerShellSignatureContent -ScriptPath $ArtifactPath + } + else { + throw "Unsupported signed release artifact extension '$extension'." + } + + return Assert-Rfc3161TimestampContent ` + -Content $cmsBytes ` + -ExpectedSubject $ExpectedSubject ` + -ExpectedSignerCertificate $ExpectedSignerCertificate ` + -ExpectedTimestampCertificate $ExpectedTimestampCertificate +} diff --git a/scripts/Test-DockerIntegration.ps1 b/scripts/Test-DockerIntegration.ps1 index 5ff8e36..4a92b4b 100644 --- a/scripts/Test-DockerIntegration.ps1 +++ b/scripts/Test-DockerIntegration.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS -Runs a mutating, local Docker integration check for BindWitness. +Runs a mutating, local Docker integration check for PortCVE. .DESCRIPTION This script may pull alpine:3.22, creates and starts one uniquely named and @@ -12,7 +12,7 @@ to the local network. #> [CmdletBinding()] param( - [string]$BindWitnessPath, + [string]$PortCVEPath, [switch]$ValidateLockCheck, [switch]$AllowWildcardUdp, [ValidateRange(5, 120)] @@ -25,9 +25,9 @@ $ErrorActionPreference = 'Stop' $dockerImage = 'alpine:3.22' $tcpContainerPort = 18080 $udpContainerPort = 18081 -$labelName = 'io.bindwitness.integration-id' +$labelName = 'io.portcve.integration-id' $runId = [Guid]::NewGuid().ToString('N') -$containerName = 'bindwitness-it-{0}' -f $runId.Substring(0, 12) +$containerName = 'portcve-it-{0}' -f $runId.Substring(0, 12) $containerId = $null $containerCreateAttempted = $false $commandCounter = 0 @@ -38,18 +38,18 @@ if ($AllowWildcardUdp) { } $repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) -if ([string]::IsNullOrWhiteSpace($BindWitnessPath)) { - $BindWitnessPath = Join-Path $repositoryRoot 'src\BindWitness\bin\Release\net10.0\win-x64\bindwitness.exe' +if ([string]::IsNullOrWhiteSpace($PortCVEPath)) { + $PortCVEPath = Join-Path $repositoryRoot 'src\PortCVE\bin\Release\net10.0\win-x64\portcve.exe' } -elseif (-not [IO.Path]::IsPathRooted($BindWitnessPath)) { - $BindWitnessPath = [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $BindWitnessPath)) +elseif (-not [IO.Path]::IsPathRooted($PortCVEPath)) { + $PortCVEPath = [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $PortCVEPath)) } else { - $BindWitnessPath = [IO.Path]::GetFullPath($BindWitnessPath) + $PortCVEPath = [IO.Path]::GetFullPath($PortCVEPath) } -if (-not (Test-Path -LiteralPath $BindWitnessPath -PathType Leaf)) { - throw "BindWitness Release executable was not found at '$BindWitnessPath'. Build or publish Release first, or pass -BindWitnessPath." +if (-not (Test-Path -LiteralPath $PortCVEPath -PathType Leaf)) { + throw "PortCVE Release executable was not found at '$PortCVEPath'. Build or publish Release first, or pass -PortCVEPath." } $dockerCommand = Get-Command docker.exe -CommandType Application -ErrorAction Stop @@ -57,7 +57,7 @@ $dockerPath = $dockerCommand.Path $temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) $temporaryPrefix = $temporaryRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar -$integrationTempDirectory = [IO.Path]::GetFullPath((Join-Path $temporaryRoot ("bindwitness-it-$runId"))) +$integrationTempDirectory = [IO.Path]::GetFullPath((Join-Path $temporaryRoot ("portcve-it-$runId"))) if (-not $integrationTempDirectory.StartsWith($temporaryPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to use an integration temporary directory outside '$temporaryRoot'." } @@ -260,7 +260,7 @@ function Wait-ForEchoes { throw "TCP and UDP echo services did not both respond within $Timeout seconds (tcp=$tcpPassed, udp=$udpPassed)." } -function Get-BindWitnessSnapshot { +function Get-PortCVESnapshot { param([switch]$IncludePrivate) $arguments = @('list', '--json') @@ -268,8 +268,8 @@ function Get-BindWitnessSnapshot { $arguments += '--include-private' } - $description = if ($IncludePrivate) { 'BindWitness private JSON collection' } else { 'BindWitness default JSON collection' } - $capture = Invoke-CapturedCommand -FilePath $script:BindWitnessPath -ArgumentList $arguments -Description $description + $description = if ($IncludePrivate) { 'PortCVE private JSON collection' } else { 'PortCVE default JSON collection' } + $capture = Invoke-CapturedCommand -FilePath $script:PortCVEPath -ArgumentList $arguments -Description $description $snapshot = ConvertFrom-CapturedJson -Json $capture.StdOut -Description $description return [pscustomobject]@{ Raw = $capture.StdOut @@ -324,7 +324,7 @@ function Find-ContainerMappings { return $matches.ToArray() } -function Wait-ForBindWitnessMappings { +function Wait-ForPortCVEMappings { param( [int]$TcpHostPort, [int]$UdpHostPort, @@ -336,7 +336,7 @@ function Wait-ForBindWitnessMappings { $lastReason = 'No snapshot was collected.' do { try { - $capture = Get-BindWitnessSnapshot -IncludePrivate:$IncludePrivate + $capture = Get-PortCVESnapshot -IncludePrivate:$IncludePrivate $context = if ($IncludePrivate) { 'Private JSON' } else { 'Default JSON' } Assert-DockerCollectorComplete -Snapshot $capture.Snapshot -Context $context $tcpMatches = @(Find-ContainerMappings -Snapshot $capture.Snapshot -HostPort $TcpHostPort -ContainerPort $script:tcpContainerPort -Protocol 'tcp') @@ -359,7 +359,7 @@ function Wait-ForBindWitnessMappings { Start-Sleep -Milliseconds 300 } while ([DateTime]::UtcNow -lt $deadline) - throw "BindWitness did not report both Docker mappings within $Timeout seconds. Last result: $lastReason" + throw "PortCVE did not report both Docker mappings within $Timeout seconds. Last result: $lastReason" } function Assert-PrivateMapping { @@ -415,12 +415,12 @@ function Test-LockCheckRoundTrip { $arguments += '--include-udp' } - $lockCapture = Invoke-CapturedCommand -FilePath $script:BindWitnessPath -ArgumentList $arguments -Description "BindWitness $Protocol lock" - $lockResult = ConvertFrom-CapturedJson -Json $lockCapture.StdOut -Description "BindWitness $Protocol lock" + $lockCapture = Invoke-CapturedCommand -FilePath $script:PortCVEPath -ArgumentList $arguments -Description "PortCVE $Protocol lock" + $lockResult = ConvertFrom-CapturedJson -Json $lockCapture.StdOut -Description "PortCVE $Protocol lock" Assert-Condition ([int]$lockResult.listener_count -gt 0) "$Protocol lock contained no listeners." Assert-Condition ($lockResult.evidence.containers -eq 'complete') "$Protocol lock container evidence was not complete." - $lockfile = ConvertFrom-CapturedJson -Json ([IO.File]::ReadAllText($lockPath)) -Description "BindWitness $Protocol lockfile" + $lockfile = ConvertFrom-CapturedJson -Json ([IO.File]::ReadAllText($lockPath)) -Description "PortCVE $Protocol lockfile" Assert-Condition ([int]$lockfile.selector.port -eq $Port) "$Protocol lockfile stored the wrong port selector." Assert-Condition ($lockfile.selector.protocol -eq $Protocol) "$Protocol lockfile stored the wrong protocol selector." $lockedListeners = @($lockfile.listeners) @@ -429,8 +429,8 @@ function Test-LockCheckRoundTrip { @($lockedListeners | Where-Object { $_.owner_identity_strength -eq 'container_image' }).Count -eq $lockedListeners.Count ) "$Protocol lockfile did not use container_image identity for every selected listener." - $checkCapture = Invoke-CapturedCommand -FilePath $script:BindWitnessPath -ArgumentList @('check', $lockPath, '--json') -Description "BindWitness $Protocol unchanged check" - $checkResult = ConvertFrom-CapturedJson -Json $checkCapture.StdOut -Description "BindWitness $Protocol unchanged check" + $checkCapture = Invoke-CapturedCommand -FilePath $script:PortCVEPath -ArgumentList @('check', $lockPath, '--json') -Description "PortCVE $Protocol unchanged check" + $checkResult = ConvertFrom-CapturedJson -Json $checkCapture.StdOut -Description "PortCVE $Protocol unchanged check" Assert-Condition ($checkResult.changed -eq $false) "$Protocol lock/check reported endpoint drift immediately after capture." return [pscustomobject]@{ @@ -488,8 +488,8 @@ try { Assert-Condition ($inspectObjects[0].Config.Labels.$labelName -eq $runId) 'Docker inspect returned the wrong integration label.' Assert-Condition ($containerImageId -match '^sha256:[0-9a-f]{64}$') 'Docker inspect did not return a canonical image ID.' - $tcpPayload = "bindwitness-tcp-$runId" - $udpPayload = "bindwitness-udp-$runId" + $tcpPayload = "portcve-tcp-$runId" + $udpPayload = "portcve-udp-$runId" Wait-ForEchoes -TcpPort $tcpHostPort -UdpPort $udpHostPort -TcpPayload $tcpPayload -UdpPayload $udpPayload -Timeout $TimeoutSeconds $tcpCim = @(Get-NetTCPConnection -State Listen -LocalPort $tcpHostPort -ErrorAction SilentlyContinue | @@ -499,11 +499,11 @@ try { Assert-Condition ($tcpCim.Count -gt 0) 'Windows CIM did not observe the published TCP host tuple.' Assert-Condition ($udpCim.Count -gt 0) 'Windows CIM did not observe the published UDP host tuple.' - $privateCapture = Wait-ForBindWitnessMappings -TcpHostPort $tcpHostPort -UdpHostPort $udpHostPort -Timeout $TimeoutSeconds -IncludePrivate + $privateCapture = Wait-ForPortCVEMappings -TcpHostPort $tcpHostPort -UdpHostPort $udpHostPort -Timeout $TimeoutSeconds -IncludePrivate Assert-PrivateMapping -Matches $privateCapture.TcpMatches -Protocol 'tcp' -HostPort $tcpHostPort -ContainerPort $tcpContainerPort -HostAddress '127.0.0.1' -ExpectedContainerId $containerId -ExpectedContainerName $containerName -ExpectedImageId $containerImageId Assert-PrivateMapping -Matches $privateCapture.UdpMatches -Protocol 'udp' -HostPort $udpHostPort -ContainerPort $udpContainerPort -HostAddress $udpHostAddress -ExpectedContainerId $containerId -ExpectedContainerName $containerName -ExpectedImageId $containerImageId - $defaultCapture = Wait-ForBindWitnessMappings -TcpHostPort $tcpHostPort -UdpHostPort $udpHostPort -Timeout $TimeoutSeconds + $defaultCapture = Wait-ForPortCVEMappings -TcpHostPort $tcpHostPort -UdpHostPort $udpHostPort -Timeout $TimeoutSeconds Assert-RawValueAbsent -Json $defaultCapture.Raw -Value $containerId -Description 'container ID' Assert-RawValueAbsent -Json $defaultCapture.Raw -Value $containerId.Substring(0, 12) -Description 'short container ID' Assert-RawValueAbsent -Json $defaultCapture.Raw -Value $containerName -Description 'container name' @@ -519,7 +519,7 @@ try { $result = [ordered]@{ status = 'passed' docker_server_version = $serverVersion - bindwitness_path = $BindWitnessPath + portcve_path = $PortCVEPath container_name = $containerName tcp = [ordered]@{ host_address = '127.0.0.1' diff --git a/scripts/Verify-ReleaseSignature.ps1 b/scripts/Verify-ReleaseSignature.ps1 new file mode 100644 index 0000000..bd49f5f --- /dev/null +++ b/scripts/Verify-ReleaseSignature.ps1 @@ -0,0 +1,153 @@ +#requires -Version 7.2 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Path, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ExpectedSignerSubject, + + [Parameter()] + [string]$SignToolPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'Rfc3161TimestampValidation.ps1') + +function Resolve-SignTool { + param([string]$ExplicitPath) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + $resolvedExplicit = (Resolve-Path -LiteralPath $ExplicitPath -ErrorAction Stop).Path + if (-not (Test-Path -LiteralPath $resolvedExplicit -PathType Leaf)) { + throw "SignTool path is not a file: $resolvedExplicit" + } + return $resolvedExplicit + } + + $command = Get-Command signtool.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $command) { + return $command.Source + } + + $candidateRoots = @() + if (-not [string]::IsNullOrWhiteSpace(${env:ProgramFiles(x86)})) { + $candidateRoots += Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + } + if (-not [string]::IsNullOrWhiteSpace($env:ProgramFiles)) { + $candidateRoots += Join-Path $env:ProgramFiles 'Windows Kits\10\bin' + } + + $candidates = foreach ($root in $candidateRoots | Select-Object -Unique) { + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + continue + } + Get-ChildItem -Path (Join-Path $root '*\x64\signtool.exe') -File -ErrorAction SilentlyContinue | + ForEach-Object { + $version = [version]'0.0' + [void][version]::TryParse($_.Directory.Parent.Name, [ref]$version) + [pscustomobject]@{ Path = $_.FullName; Version = $version } + } + } + + $selected = $candidates | Sort-Object Version -Descending | Select-Object -First 1 + if ($null -eq $selected) { + throw 'signtool.exe was not found. Install the Windows SDK before verifying a release signature.' + } + return $selected.Path +} + +$resolvedArtifact = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path +if (-not (Test-Path -LiteralPath $resolvedArtifact -PathType Leaf)) { + throw "Release artifact is not a file: $resolvedArtifact" +} +$artifactName = [IO.Path]::GetFileName($resolvedArtifact) +if (-not [StringComparer]::Ordinal.Equals($artifactName, 'portcve.exe') -and + -not [StringComparer]::Ordinal.Equals($artifactName, 'install.ps1')) { + throw "Release signature verification is restricted to 'portcve.exe' and 'install.ps1'." +} +if ([string]::IsNullOrWhiteSpace($ExpectedSignerSubject) -or + $ExpectedSignerSubject -ne $ExpectedSignerSubject.Trim() -or + $ExpectedSignerSubject.Contains("`r") -or $ExpectedSignerSubject.Contains("`n")) { + throw 'Expected signer subject is missing or malformed.' +} + +if ([StringComparer]::Ordinal.Equals($artifactName, 'portcve.exe')) { + $resolvedSignTool = Resolve-SignTool -ExplicitPath $SignToolPath + $signToolLines = @(& $resolvedSignTool verify /pa /all /v $resolvedArtifact 2>&1) + $signToolExitCode = $LASTEXITCODE + $signToolText = $signToolLines -join [Environment]::NewLine + $signToolText | Write-Host + + if ($signToolExitCode -ne 0) { + throw "signtool.exe rejected the Authenticode signature (exit $signToolExitCode)." + } + foreach ($requiredPattern in @( + '(?im)^Hash of file \(sha256\):\s*[0-9A-F]{64}\s*$', + '(?im)^The signature is timestamped:\s*.+$', + '(?im)^Timestamp Verified by:\s*$', + '(?im)^Successfully verified:\s*.+$', + '(?im)^Number of signatures successfully Verified:\s*1\s*$', + '(?im)^Number of warnings:\s*0\s*$', + '(?im)^Number of errors:\s*0\s*$' + )) { + if ($signToolText -notmatch $requiredPattern) { + throw "signtool.exe output did not satisfy required verification pattern: $requiredPattern" + } + } +} + +$authenticode = Get-AuthenticodeSignature -LiteralPath $resolvedArtifact +if ($authenticode.Status -ne [Management.Automation.SignatureStatus]::Valid) { + throw "Get-AuthenticodeSignature rejected the signature: $($authenticode.Status) - $($authenticode.StatusMessage)" +} +if (-not [StringComparer]::Ordinal.Equals([string]$authenticode.SignatureType, 'Authenticode')) { + throw "Expected an embedded Authenticode signature; Windows selected '$($authenticode.SignatureType)'." +} +if ($null -eq $authenticode.SignerCertificate) { + throw 'Authenticode signature has no signer certificate.' +} +if (-not [StringComparer]::Ordinal.Equals($authenticode.SignerCertificate.Subject, $ExpectedSignerSubject)) { + throw "Signer subject does not exactly match EXPECTED_SIGNER_SUBJECT. Actual: '$($authenticode.SignerCertificate.Subject)'." +} + +$ekuExtension = $authenticode.SignerCertificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } | Select-Object -First 1 +$hasCodeSigningEku = $null -ne $ekuExtension -and @($ekuExtension.EnhancedKeyUsages | Where-Object { + $_.Value -eq '1.3.6.1.5.5.7.3.3' +}).Count -gt 0 +if (-not $hasCodeSigningEku) { + throw 'Signer certificate does not contain the Code Signing enhanced key usage OID.' +} +if ($null -eq $authenticode.TimeStamperCertificate) { + throw 'Authenticode signature has no validated timestamp certificate.' +} +$timestampEkuExtension = $authenticode.TimeStamperCertificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } | Select-Object -First 1 +$hasTimestampEku = $null -ne $timestampEkuExtension -and @($timestampEkuExtension.EnhancedKeyUsages | Where-Object { + $_.Value -eq '1.3.6.1.5.5.7.3.8' +}).Count -gt 0 +if (-not $hasTimestampEku) { + throw 'Timestamp certificate does not contain the Time Stamping enhanced key usage OID.' +} + +$rfc3161 = Assert-Rfc3161Timestamp ` + -ArtifactPath $resolvedArtifact ` + -ExpectedSubject $ExpectedSignerSubject ` + -ExpectedSignerCertificate $authenticode.SignerCertificate ` + -ExpectedTimestampCertificate $authenticode.TimeStamperCertificate + +[pscustomobject]@{ + Path = $resolvedArtifact + Sha256 = (Get-FileHash -LiteralPath $resolvedArtifact -Algorithm SHA256).Hash.ToLowerInvariant() + SignerSubject = $authenticode.SignerCertificate.Subject + SignerThumbprint = $authenticode.SignerCertificate.Thumbprint + TimestampFormat = 'RFC3161' + TimestampSignerSubject = $authenticode.TimeStamperCertificate.Subject + TimestampUtc = $rfc3161.Timestamp.ToUniversalTime().ToString('o') + TimestampBinding = 'Rfc3161TimestampToken.VerifySignatureForSignerInfo' + Verification = 'Valid' +} diff --git a/scripts/Write-SigningMetadata.ps1 b/scripts/Write-SigningMetadata.ps1 new file mode 100644 index 0000000..ff25eca --- /dev/null +++ b/scripts/Write-SigningMetadata.ps1 @@ -0,0 +1,128 @@ +#requires -Version 7.2 + +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Path, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$OutputPath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ExpectedSignerSubject, + [Parameter(Mandatory)][ValidatePattern('^[^/]+/[^/]+$')][string]$Repository, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$CommitSha, + [Parameter(Mandatory)][ValidatePattern('^v.+$')][string]$Tag, + [Parameter(Mandatory)][ValidatePattern('^\d+$')][string]$WorkflowRunId, + [Parameter(Mandatory)][ValidatePattern('^\d+$')][string]$WorkflowRunAttempt, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{40}$')][string]$SigningActionCommit, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodeSignToolVersion, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{64}$')][string]$CodeSignToolArchiveSha256, + [Parameter()][string]$SignToolPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CertificateSha256 { + param([Parameter(Mandatory)][Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) + + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha256.ComputeHash($Certificate.RawData))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + } +} + +$resolvedArtifact = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path +if (-not [StringComparer]::Ordinal.Equals([IO.Path]::GetFileName($resolvedArtifact), 'portcve.exe')) { + throw "Signing metadata can be generated only for the exact file name 'portcve.exe'." +} + +$verifyScript = Join-Path $PSScriptRoot 'Verify-ReleaseSignature.ps1' +$verificationParameters = @{ + Path = $resolvedArtifact + ExpectedSignerSubject = $ExpectedSignerSubject +} +if (-not [string]::IsNullOrWhiteSpace($SignToolPath)) { + $verificationParameters.SignToolPath = $SignToolPath +} +$verification = & $verifyScript @verificationParameters + +$signature = Get-AuthenticodeSignature -LiteralPath $resolvedArtifact +$signer = $signature.SignerCertificate +$timestampSigner = $signature.TimeStamperCertificate +if ($null -eq $signer -or $null -eq $timestampSigner) { + throw 'Verified signature certificates disappeared before metadata generation.' +} + +$artifactInfo = Get-Item -LiteralPath $resolvedArtifact +$metadata = [ordered]@{ + schema_version = 1 + generated_at_utc = [DateTimeOffset]::UtcNow.ToString('o') + artifact = [ordered]@{ + name = $artifactInfo.Name + size_bytes = $artifactInfo.Length + sha256 = [string]$verification.Sha256 + } + signature = [ordered]@{ + status = [string]$signature.Status + type = [string]$signature.SignatureType + file_digest_algorithm = 'SHA256' + signer = [ordered]@{ + subject = $signer.Subject + issuer = $signer.Issuer + serial_number = $signer.SerialNumber + thumbprint_sha1 = $signer.Thumbprint.ToLowerInvariant() + certificate_sha256 = Get-CertificateSha256 -Certificate $signer + not_before_utc = $signer.NotBefore.ToUniversalTime().ToString('o') + not_after_utc = $signer.NotAfter.ToUniversalTime().ToString('o') + code_signing_eku_oid = '1.3.6.1.5.5.7.3.3' + } + timestamp = [ordered]@{ + format = 'RFC3161' + validated = $true + binding_verified = $true + binding_method = [string]$verification.TimestampBinding + timestamp_utc = [string]$verification.TimestampUtc + subject = $timestampSigner.Subject + issuer = $timestampSigner.Issuer + serial_number = $timestampSigner.SerialNumber + thumbprint_sha1 = $timestampSigner.Thumbprint.ToLowerInvariant() + certificate_sha256 = Get-CertificateSha256 -Certificate $timestampSigner + not_before_utc = $timestampSigner.NotBefore.ToUniversalTime().ToString('o') + not_after_utc = $timestampSigner.NotAfter.ToUniversalTime().ToString('o') + } + } + source = [ordered]@{ + repository = $Repository + commit_sha = $CommitSha.ToLowerInvariant() + tag = $Tag + } + workflow = [ordered]@{ + file = '.github/workflows/release.yml' + run_id = $WorkflowRunId + run_attempt = $WorkflowRunAttempt + } + signing_service = [ordered]@{ + provider = 'SSL.com eSigner' + action = 'SSLcom/esigner-codesign' + action_commit = $SigningActionCommit.ToLowerInvariant() + codesign_tool_version = $CodeSignToolVersion + codesign_tool_archive_sha256 = $CodeSignToolArchiveSha256.ToLowerInvariant() + } +} + +$outputParent = Split-Path -Parent $OutputPath +if ([string]::IsNullOrWhiteSpace($outputParent)) { + $outputParent = $PWD.Path +} +if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) { + New-Item -ItemType Directory -Path $outputParent | Out-Null +} +$resolvedOutputParent = (Resolve-Path -LiteralPath $outputParent).Path +$resolvedOutput = Join-Path $resolvedOutputParent (Split-Path -Leaf $OutputPath) +[IO.File]::WriteAllText( + $resolvedOutput, + ($metadata | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false) +) + +Get-Item -LiteralPath $resolvedOutput diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..1eb2596 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,541 @@ +#requires -Version 5.1 + +<# +.SYNOPSIS +Installs a signed PortCVE release for the current Windows user. + +.DESCRIPTION +Downloads a versioned ZIP and SHA256SUMS.txt from the official +Labeeb2339/PortCVE GitHub release, verifies the ZIP checksum, then requires a +trusted Authenticode signature, the release-bound signer subject, the Code +Signing EKU, and a trusted timestamp before installing portcve.exe. + +This script has no unsigned, local-asset, or signature-bypass mode. +#> +[CmdletBinding()] +param( + [string]$Version, + [string]$InstallDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$script:Repository = 'Labeeb2339/PortCVE' +$script:ExpectedSignerSubject = '__PORTCVE_EXPECTED_SIGNER_SUBJECT__' +$script:ReleaseTagPattern = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*))*))?$' +$script:InstallerUserAgent = 'PortCVE-Installer/1.0' +$script:ApiLimitBytes = 2MB +$script:ChecksumLimitBytes = 128KB +$script:ZipLimitBytes = 256MB +$script:ExecutableLimitBytes = 256MB +$script:MaximumArchiveEntries = 256 +$script:MaximumExpandedBytes = 512MB +$script:ConnectTimeoutMilliseconds = 30000 +$script:ReadTimeoutMilliseconds = 30000 +$script:MaximumDownloadSeconds = 300 + +function Test-AllowedGitHubHost { + param([Parameter(Mandatory = $true)][string]$HostName) + + return $HostName.Equals('github.com', [StringComparison]::OrdinalIgnoreCase) ` + -or $HostName.Equals('api.github.com', [StringComparison]::OrdinalIgnoreCase) ` + -or $HostName.EndsWith('.githubusercontent.com', [StringComparison]::OrdinalIgnoreCase) +} + +function Save-BoundedHttpsFile { + param( + [Parameter(Mandatory = $true)][Uri]$Uri, + [Parameter(Mandatory = $true)][string]$Destination, + [Parameter(Mandatory = $true)][long]$MaximumBytes + ) + + if ($Uri.Scheme -cne 'https' -or -not (Test-AllowedGitHubHost $Uri.DnsSafeHost)) { + throw "Refusing non-GitHub HTTPS download URI '$Uri'." + } + + $request = [Net.HttpWebRequest]::Create($Uri) + $request.Method = 'GET' + $request.UserAgent = $script:InstallerUserAgent + $request.Accept = 'application/vnd.github+json, application/octet-stream' + $request.AllowAutoRedirect = $true + $request.MaximumAutomaticRedirections = 5 + $request.Timeout = $script:ConnectTimeoutMilliseconds + $request.ReadWriteTimeout = $script:ReadTimeoutMilliseconds + + $response = $null + $inputStream = $null + $outputStream = $null + try { + $response = [Net.HttpWebResponse]$request.GetResponse() + if (-not (Test-AllowedGitHubHost $response.ResponseUri.DnsSafeHost)) { + throw "GitHub redirected the download to an unapproved host '$($response.ResponseUri.DnsSafeHost)'." + } + + if ($response.ContentLength -gt $MaximumBytes) { + throw "Download '$Uri' declares $($response.ContentLength) bytes; limit is $MaximumBytes bytes." + } + + $inputStream = $response.GetResponseStream() + $outputStream = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + $buffer = New-Object byte[] 65536 + $total = 0L + $timer = [Diagnostics.Stopwatch]::StartNew() + while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $total += $read + if ($total -gt $MaximumBytes) { + throw "Download '$Uri' exceeded the $MaximumBytes-byte limit." + } + if ($timer.Elapsed.TotalSeconds -gt $script:MaximumDownloadSeconds) { + throw "Download '$Uri' exceeded the $($script:MaximumDownloadSeconds)-second limit." + } + $outputStream.Write($buffer, 0, $read) + } + $outputStream.Flush() + } + finally { + if ($null -ne $outputStream) { $outputStream.Dispose() } + if ($null -ne $inputStream) { $inputStream.Dispose() } + if ($null -ne $response) { $response.Dispose() } + } +} + +function Read-BoundedUtf8File { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][long]$MaximumBytes + ) + + $file = Get-Item -LiteralPath $Path + if ($file.Length -gt $MaximumBytes) { + throw "File '$Path' exceeds the $MaximumBytes-byte limit." + } + return [IO.File]::ReadAllText($file.FullName, [Text.UTF8Encoding]::new($false, $true)) +} + +function Resolve-Release { + param( + [string]$RequestedVersion, + [Parameter(Mandatory = $true)][string]$WorkingDirectory + ) + + $tag = $null + if (-not [string]::IsNullOrWhiteSpace($RequestedVersion)) { + $tag = $RequestedVersion.Trim() + if (-not $tag.StartsWith('v', [StringComparison]::Ordinal)) { $tag = "v$tag" } + if (-not [regex]::IsMatch($tag, $script:ReleaseTagPattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { + throw "Version '$RequestedVersion' is not a supported semantic release tag." + } + $apiUri = [Uri]("https://api.github.com/repos/{0}/releases/tags/{1}" -f $script:Repository, [Uri]::EscapeDataString($tag)) + } + else { + $apiUri = [Uri]("https://api.github.com/repos/{0}/releases/latest" -f $script:Repository) + } + + $metadataPath = Join-Path $WorkingDirectory 'release.json' + Save-BoundedHttpsFile -Uri $apiUri -Destination $metadataPath -MaximumBytes $script:ApiLimitBytes + $release = Read-BoundedUtf8File -Path $metadataPath -MaximumBytes $script:ApiLimitBytes | ConvertFrom-Json + $resolvedTag = [string]$release.tag_name + if (-not [regex]::IsMatch($resolvedTag, $script:ReleaseTagPattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { + throw "GitHub returned unsupported release tag '$resolvedTag'." + } + if ($null -ne $tag -and $resolvedTag -cne $tag) { + throw "GitHub returned tag '$resolvedTag' when '$tag' was requested." + } + if ([bool]$release.draft) { throw "Refusing draft release '$resolvedTag'." } + + $zipName = "portcve-$resolvedTag-win-x64.zip" + $assets = @($release.assets) + $zipAssets = @($assets | Where-Object { $null -ne $_ -and [string]::Equals([string]$_.name, $zipName, [StringComparison]::Ordinal) }) + $sumAssets = @($assets | Where-Object { $null -ne $_ -and [string]::Equals([string]$_.name, 'SHA256SUMS.txt', [StringComparison]::Ordinal) }) + if ($zipAssets.Count -ne 1 -or $sumAssets.Count -ne 1) { + throw "Release '$resolvedTag' must contain exactly one '$zipName' and one 'SHA256SUMS.txt' asset." + } + if ([long]$zipAssets[0].size -le 0 -or [long]$zipAssets[0].size -gt $script:ZipLimitBytes) { + throw "Release ZIP size is missing or exceeds the installer limit." + } + if ([long]$sumAssets[0].size -le 0 -or [long]$sumAssets[0].size -gt $script:ChecksumLimitBytes) { + throw "Checksum asset size is missing or exceeds the installer limit." + } + + return [pscustomobject]@{ + Tag = $resolvedTag + ZipName = $zipName + ZipUri = [Uri][string]$zipAssets[0].browser_download_url + ChecksumUri = [Uri][string]$sumAssets[0].browser_download_url + } +} + +function Get-ExpectedChecksum { + param( + [Parameter(Mandatory = $true)][string]$ChecksumPath, + [Parameter(Mandatory = $true)][string]$AssetName + ) + + $text = Read-BoundedUtf8File -Path $ChecksumPath -MaximumBytes $script:ChecksumLimitBytes + $foundHashes = New-Object 'Collections.Generic.List[string]' + foreach ($line in ($text -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -notmatch '^(?[0-9A-Fa-f]{64})[ \t]+\*?(?[^\r\n]+)$') { + throw "SHA256SUMS.txt contains a malformed non-empty line." + } + if ([string]::Equals($Matches.name, $AssetName, [StringComparison]::Ordinal)) { + $foundHashes.Add($Matches.hash.ToLowerInvariant()) + } + } + if ($foundHashes.Count -ne 1) { + throw "SHA256SUMS.txt must contain exactly one checksum for '$AssetName'." + } + return $foundHashes[0] +} + +function Get-Sha256 { + param([Parameter(Mandatory = $true)][string]$Path) + + $stream = [IO.File]::OpenRead($Path) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + $stream.Dispose() + } +} + +function Expand-PortCVEExecutable { + param( + [Parameter(Mandatory = $true)][string]$ZipPath, + [Parameter(Mandatory = $true)][string]$DestinationDirectory + ) + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($ZipPath) + try { + if ($archive.Entries.Count -gt $script:MaximumArchiveEntries) { throw 'Release ZIP has too many entries.' } + $expanded = 0L + $executableEntries = New-Object 'Collections.Generic.List[object]' + foreach ($entry in $archive.Entries) { + $name = $entry.FullName.Replace('\', '/') + if ([string]::IsNullOrWhiteSpace($name) -or $name.Contains([char]0) -or $name.Contains(':') -or $name.StartsWith('/') -or $name -match '(^|/)\.\.(/|$)') { + throw "Release ZIP contains unsafe entry '$name'." + } + $expanded += [long]$entry.Length + if ($expanded -gt $script:MaximumExpandedBytes) { throw 'Release ZIP expands beyond the installer limit.' } + if ([string]::Equals($name, 'portcve.exe', [StringComparison]::Ordinal)) { $executableEntries.Add($entry) } + } + if ($executableEntries.Count -ne 1) { throw "Release ZIP must contain exactly one root 'portcve.exe'." } + $entry = $executableEntries[0] + if ($entry.Length -le 0 -or $entry.Length -gt $script:ExecutableLimitBytes) { throw 'portcve.exe size is invalid.' } + + [IO.Directory]::CreateDirectory($DestinationDirectory) | Out-Null + $destination = Join-Path $DestinationDirectory 'portcve.exe' + $inputStream = $entry.Open() + $outputStream = [IO.File]::Open($destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $buffer = New-Object byte[] 65536 + $total = 0L + while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $total += $read + if ($total -gt $script:ExecutableLimitBytes) { throw 'Expanded executable exceeded the installer limit.' } + $outputStream.Write($buffer, 0, $read) + } + if ($total -ne [long]$entry.Length) { throw 'Expanded executable length did not match the ZIP entry.' } + } + finally { + $outputStream.Dispose() + $inputStream.Dispose() + } + return $destination + } + finally { + $archive.Dispose() + } +} + +function Test-CertificateEku { + param( + [Parameter(Mandatory = $true)][Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [Parameter(Mandatory = $true)][string]$RequiredOid + ) + + foreach ($extension in $Certificate.Extensions) { + if ($extension.Oid.Value -eq '2.5.29.37') { + $eku = New-Object Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension($extension, $extension.Critical) + foreach ($usage in $eku.EnhancedKeyUsages) { + if ($usage.Value -eq $RequiredOid) { return $true } + } + } + } + return $false +} + +function Assert-TrustedAuthenticodeFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ExpectedFileName + ) + + if ([string]::IsNullOrWhiteSpace($script:ExpectedSignerSubject) -or $script:ExpectedSignerSubject.Contains('__PORTCVE_')) { + throw 'This installer template was not finalized by the PortCVE release workflow. Refusing installation.' + } + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "Signed '$ExpectedFileName' must be invoked from or resolved to a file." + } + $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + if (-not (Test-Path -LiteralPath $resolved -PathType Leaf) -or + -not [string]::Equals([IO.Path]::GetFileName($resolved), $ExpectedFileName, [StringComparison]::Ordinal)) { + throw "Signed file must be named exactly '$ExpectedFileName'." + } + + $signature = Get-AuthenticodeSignature -LiteralPath $resolved + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $null -eq $signature.SignerCertificate) { + throw "'$ExpectedFileName' does not have a valid trusted Authenticode signature: $($signature.StatusMessage)" + } + if (-not [string]::Equals([string]$signature.SignatureType, 'Authenticode', [StringComparison]::Ordinal)) { + throw "'$ExpectedFileName' must carry its own embedded Authenticode signature; Windows selected '$($signature.SignatureType)'." + } + if (-not [string]::Equals($signature.SignerCertificate.Subject, $script:ExpectedSignerSubject, [StringComparison]::Ordinal)) { + throw "Signer subject mismatch. Expected '$script:ExpectedSignerSubject'; received '$($signature.SignerCertificate.Subject)'." + } + if (-not (Test-CertificateEku -Certificate $signature.SignerCertificate -RequiredOid '1.3.6.1.5.5.7.3.3')) { + throw 'Signer certificate does not contain the Code Signing EKU.' + } + if ($null -eq $signature.TimeStamperCertificate) { throw 'Authenticode signature has no timestamp certificate.' } + if (-not (Test-CertificateEku -Certificate $signature.TimeStamperCertificate -RequiredOid '1.3.6.1.5.5.7.3.8')) { + throw 'Timestamp certificate does not contain the Time Stamping EKU.' + } + return $signature +} + +function Assert-TrustedReleaseExecutable { + param([Parameter(Mandatory = $true)][string]$Path) + return Assert-TrustedAuthenticodeFile -Path $Path -ExpectedFileName 'portcve.exe' +} + +function Assert-TrustedInstallerFile { + param([Parameter(Mandatory = $true)][string]$Path) + return Assert-TrustedAuthenticodeFile -Path $Path -ExpectedFileName 'install.ps1' +} + +function Get-CanonicalPath { + param([Parameter(Mandatory = $true)][string]$Path) + return [IO.Path]::GetFullPath($Path).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) +} + +function Assert-SafeInstallTarget { + param([Parameter(Mandatory = $true)][string]$Path) + + $full = Get-CanonicalPath $Path + $root = [IO.Path]::GetPathRoot($full).TrimEnd('\', '/') + if ([string]::IsNullOrWhiteSpace($full) -or $full.TrimEnd('\', '/') -eq $root -or $full.Length -gt 220 ` + -or $full.Contains(';') -or $full.Contains('"')) { + throw "Install directory '$full' is unsafe or too long." + } + if (Test-Path -LiteralPath $full -PathType Leaf) { throw "Install target '$full' is a file." } + if (Test-Path -LiteralPath $full -PathType Container) { + $item = Get-Item -LiteralPath $full -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Install target must not be a reparse point.' } + $allowed = @('portcve.exe', 'install-receipt.json') + foreach ($child in Get-ChildItem -LiteralPath $full -Force) { + if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $child.PSIsContainer -or $allowed -cnotcontains $child.Name) { + throw "Install target contains unmanaged entry '$($child.Name)'; refusing to replace or delete it." + } + } + } + return $full +} + +function Get-UpdatedUserPath { + param( + [AllowNull()][string]$CurrentPath, + [Parameter(Mandatory = $true)][string]$InstallPath + ) + + $canonicalInstall = Get-CanonicalPath $InstallPath + foreach ($entry in @($CurrentPath -split ';')) { + $candidate = $entry.Trim().Trim('"') + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + try { $candidate = Get-CanonicalPath ([Environment]::ExpandEnvironmentVariables($candidate)) } catch { continue } + if ([string]::Equals($candidate, $canonicalInstall, [StringComparison]::OrdinalIgnoreCase)) { return $CurrentPath } + } + $updated = if ([string]::IsNullOrWhiteSpace($CurrentPath)) { $canonicalInstall } else { "$($CurrentPath.TrimEnd(';'));$canonicalInstall" } + if ($updated.Length -gt 32760) { throw 'User PATH would exceed the Windows environment-variable limit.' } + return $updated +} + +function Assert-ManagedDirectory { + param( + [Parameter(Mandatory = $true)][string]$Candidate, + [Parameter(Mandatory = $true)][string]$ExpectedParent, + [Parameter(Mandatory = $true)][string]$ExpectedLeaf + ) + + $full = Get-CanonicalPath $Candidate + $parent = Get-CanonicalPath (Split-Path -Parent $full) + if (-not [string]::Equals($parent, (Get-CanonicalPath $ExpectedParent), [StringComparison]::OrdinalIgnoreCase) ` + -or -not [string]::Equals((Split-Path -Leaf $full), $ExpectedLeaf, [StringComparison]::Ordinal)) { + throw "Refusing cleanup of unvalidated directory '$full'." + } + return $full +} + +function Remove-ManagedDirectory { + param( + [Parameter(Mandatory = $true)][string]$Candidate, + [Parameter(Mandatory = $true)][string]$ExpectedParent, + [Parameter(Mandatory = $true)][string]$ExpectedLeaf + ) + + $full = Assert-ManagedDirectory -Candidate $Candidate -ExpectedParent $ExpectedParent -ExpectedLeaf $ExpectedLeaf + if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force } +} + +function Invoke-AtomicInstall { + param( + [Parameter(Mandatory = $true)][string]$InstallPath, + [Parameter(Mandatory = $true)][string]$StagingPath, + [Parameter(Mandatory = $true)][string]$Token, + [Parameter(Mandatory = $true)][string]$OriginalUserPath, + [Parameter(Mandatory = $true)][string]$UpdatedUserPath + ) + + $parent = Split-Path -Parent $InstallPath + $leaf = Split-Path -Leaf $InstallPath + $backupLeaf = "$leaf.backup-$Token" + $failedLeaf = "$leaf.failed-$Token" + $backup = Join-Path $parent $backupLeaf + $failed = Join-Path $parent $failedLeaf + $hadExisting = Test-Path -LiteralPath $InstallPath -PathType Container + $newInstalled = $false + $pathChanged = $false + try { + if ($hadExisting) { [IO.Directory]::Move($InstallPath, $backup) } + [IO.Directory]::Move($StagingPath, $InstallPath) + $newInstalled = $true + if (-not [string]::Equals($OriginalUserPath, $UpdatedUserPath, [StringComparison]::Ordinal)) { + [Environment]::SetEnvironmentVariable('Path', $UpdatedUserPath, [EnvironmentVariableTarget]::User) + $pathChanged = $true + } + if ($hadExisting) { Remove-ManagedDirectory -Candidate $backup -ExpectedParent $parent -ExpectedLeaf $backupLeaf } + } + catch { + $failure = $_ + $rollbackErrors = New-Object 'Collections.Generic.List[string]' + if ($pathChanged) { + try { [Environment]::SetEnvironmentVariable('Path', $OriginalUserPath, [EnvironmentVariableTarget]::User) } + catch { $rollbackErrors.Add("PATH rollback failed: $($_.Exception.Message)") } + } + if ($newInstalled -and (Test-Path -LiteralPath $InstallPath -PathType Container)) { + try { [IO.Directory]::Move($InstallPath, $failed) } + catch { $rollbackErrors.Add("new installation isolation failed: $($_.Exception.Message)") } + } + if ($hadExisting -and (Test-Path -LiteralPath $backup -PathType Container) -and -not (Test-Path -LiteralPath $InstallPath)) { + try { [IO.Directory]::Move($backup, $InstallPath) } + catch { $rollbackErrors.Add("previous installation restore failed: $($_.Exception.Message)") } + } + if (Test-Path -LiteralPath $failed) { + try { Remove-ManagedDirectory -Candidate $failed -ExpectedParent $parent -ExpectedLeaf $failedLeaf } + catch { $rollbackErrors.Add("failed-install cleanup failed: $($_.Exception.Message)") } + } + if ($rollbackErrors.Count -gt 0) { + throw "Installation failed: $($failure.Exception.Message). Rollback was incomplete: $($rollbackErrors -join '; ')" + } + throw $failure + } +} + +function Invoke-PortCVEInstall { + param( + [string]$Version, + [string]$InstallDirectory, + [AllowNull()][string]$InstallerPath + ) + + if ([string]::IsNullOrWhiteSpace($script:ExpectedSignerSubject) -or $script:ExpectedSignerSubject.Contains('__PORTCVE_')) { + throw 'This is an unfinalized installer template, not a production release asset.' + } + if ([string]::IsNullOrWhiteSpace($InstallerPath)) { + throw 'PortCVE installation must run from the signed install.ps1 file; piped or in-memory execution is refused.' + } + $null = Assert-TrustedInstallerFile -Path $InstallerPath + + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitOperatingSystem) { + throw 'PortCVE installer supports 64-bit Windows only.' + } + + if ([string]::IsNullOrWhiteSpace($InstallDirectory)) { + $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) + if ([string]::IsNullOrWhiteSpace($localAppData)) { throw 'LocalAppData could not be resolved.' } + $InstallDirectory = Join-Path $localAppData 'Programs\PortCVE' + } + $installPath = Assert-SafeInstallTarget $InstallDirectory + $installParent = Split-Path -Parent $installPath + [IO.Directory]::CreateDirectory($installParent) | Out-Null + + $token = [Guid]::NewGuid().ToString('N') + $tempParent = Get-CanonicalPath ([IO.Path]::GetTempPath()) + $workingLeaf = "portcve-install-$token" + $working = Join-Path $tempParent $workingLeaf + $installLeaf = Split-Path -Leaf $installPath + $stagingLeaf = "$installLeaf.staging-$token" + $staging = Join-Path $installParent $stagingLeaf + [IO.Directory]::CreateDirectory($working) | Out-Null + + $previousProtocol = [Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol = $previousProtocol -bor [Net.SecurityProtocolType]::Tls12 + $release = Resolve-Release -RequestedVersion $Version -WorkingDirectory $working + $zipPath = Join-Path $working $release.ZipName + $checksumPath = Join-Path $working 'SHA256SUMS.txt' + Save-BoundedHttpsFile -Uri $release.ChecksumUri -Destination $checksumPath -MaximumBytes $script:ChecksumLimitBytes + Save-BoundedHttpsFile -Uri $release.ZipUri -Destination $zipPath -MaximumBytes $script:ZipLimitBytes + + $expectedHash = Get-ExpectedChecksum -ChecksumPath $checksumPath -AssetName $release.ZipName + $actualHash = Get-Sha256 $zipPath + if (-not [string]::Equals($expectedHash, $actualHash, [StringComparison]::Ordinal)) { + throw "Checksum mismatch for '$($release.ZipName)'." + } + + $extractedDirectory = Join-Path $working 'extracted' + $executable = Expand-PortCVEExecutable -ZipPath $zipPath -DestinationDirectory $extractedDirectory + $signature = Assert-TrustedReleaseExecutable $executable + + [IO.Directory]::CreateDirectory($staging) | Out-Null + [IO.File]::Copy($executable, (Join-Path $staging 'portcve.exe'), $false) + $null = Assert-TrustedReleaseExecutable (Join-Path $staging 'portcve.exe') + $receipt = [ordered]@{ + product = 'PortCVE' + version = $release.Tag + repository = $script:Repository + zip_asset = $release.ZipName + zip_sha256 = $actualHash + signer_subject = $signature.SignerCertificate.Subject + timestamp_subject = $signature.TimeStamperCertificate.Subject + installed_at_utc = [DateTime]::UtcNow.ToString('o') + } | ConvertTo-Json + [IO.File]::WriteAllText((Join-Path $staging 'install-receipt.json'), $receipt + "`r`n", [Text.UTF8Encoding]::new($false)) + + $originalUserPath = [Environment]::GetEnvironmentVariable('Path', [EnvironmentVariableTarget]::User) + if ($null -eq $originalUserPath) { $originalUserPath = '' } + $updatedUserPath = Get-UpdatedUserPath -CurrentPath $originalUserPath -InstallPath $installPath + Invoke-AtomicInstall -InstallPath $installPath -StagingPath $staging -Token $token -OriginalUserPath $originalUserPath -UpdatedUserPath $updatedUserPath + + Write-Host "PortCVE $($release.Tag) installed to '$installPath'." + Write-Host 'Open a new terminal, then run: portcve --version' + } + finally { + [Net.ServicePointManager]::SecurityProtocol = $previousProtocol + if (Test-Path -LiteralPath $staging) { + Remove-ManagedDirectory -Candidate $staging -ExpectedParent $installParent -ExpectedLeaf $stagingLeaf + } + if (Test-Path -LiteralPath $working) { + Remove-ManagedDirectory -Candidate $working -ExpectedParent $tempParent -ExpectedLeaf $workingLeaf + } + } +} + +# PORTCVE_INSTALLER_ENTRYPOINT +Invoke-PortCVEInstall @PSBoundParameters -InstallerPath $PSCommandPath diff --git a/scripts/tests/Test-Installer.ps1 b/scripts/tests/Test-Installer.ps1 new file mode 100644 index 0000000..3cff856 --- /dev/null +++ b/scripts/tests/Test-Installer.ps1 @@ -0,0 +1,165 @@ +#requires -Version 5.1 + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$installerPath = Join-Path $repositoryRoot 'scripts\install.ps1' +$finalizerPath = Join-Path $repositoryRoot 'scripts\Finalize-ReleaseInstaller.ps1' +$releaseWorkflowPath = Join-Path $repositoryRoot '.github\workflows\release.yml' +$source = [IO.File]::ReadAllText($installerPath) +$tokens = $null +$parseErrors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { + throw "Installer has PowerShell syntax errors: $($parseErrors.Message -join '; ')" +} + +$script:Passed = 0 +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } + $script:Passed++ +} + +function Assert-Throws { + param([scriptblock]$Action, [string]$Message) + try { & $Action; throw "Expected failure: $Message" } + catch { + if ($_.Exception.Message -eq "Expected failure: $Message") { throw } + $script:Passed++ + } +} + +$placeholderMatches = [regex]::Matches($source, [regex]::Escape('__PORTCVE_EXPECTED_SIGNER_SUBJECT__')) +Assert-True ($placeholderMatches.Count -eq 1) 'Installer must contain exactly one release-time signer placeholder.' + +$parameterNames = @($ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath }) +Assert-True (($parameterNames -join ',') -ceq 'Version,InstallDirectory') 'Installer exposed an unexpected production parameter.' +Assert-True ($source -notmatch '(?i)skip(signature|checksum)|allowunsigned|localassets?|testassets?') 'Installer contains a bypass or local-asset surface.' +Assert-True ($source -notmatch '(?i)Rfc3161|1\.3\.6\.1\.4\.1\.311\.3\.3\.1') 'PowerShell 5.1 installer contains an unsupported independent RFC 3161 claim or OID-only check.' + +$forbiddenCommands = @('cmd', 'cmd.exe', 'curl', 'curl.exe', 'Invoke-Expression', 'Start-Process') +$commands = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $null -ne $_ }) +foreach ($forbidden in $forbiddenCommands) { + Assert-True ($commands -cnotcontains $forbidden) "Installer shells out through forbidden command '$forbidden'." +} + +$entrypointMarker = '# PORTCVE_INSTALLER_ENTRYPOINT' +$markerIndex = $source.IndexOf($entrypointMarker, [StringComparison]::Ordinal) +Assert-True ($markerIndex -gt 0) 'Installer entrypoint marker is missing.' +$librarySource = $source.Substring(0, $markerIndex) +Invoke-Expression $librarySource + +$workflowSource = [IO.File]::ReadAllText($releaseWorkflowPath) +$workflowPatternMatch = [regex]::Match($workflowSource, "(?m)^\s+\`$pattern = '(?[^']+)'\s*$") +Assert-True $workflowPatternMatch.Success 'Release workflow tag pattern was not found.' +Assert-True ([StringComparer]::Ordinal.Equals($workflowPatternMatch.Groups['pattern'].Value, $script:ReleaseTagPattern)) 'Workflow and installer release-tag patterns diverged.' +foreach ($acceptedTag in @('v1.0.0', 'v1.0.0-rc.1', 'v2.3.4-1rc.2')) { + Assert-True ([regex]::IsMatch($acceptedTag, $script:ReleaseTagPattern)) "Compatible release tag '$acceptedTag' was rejected." +} +foreach ($rejectedTag in @('1.0.0', 'v01.0.0', 'v1.0.0-01', 'v1.0.0--rc', 'v1.0.0+build.1')) { + Assert-True (-not [regex]::IsMatch($rejectedTag, $script:ReleaseTagPattern)) "Out-of-policy release tag '$rejectedTag' was accepted." +} + +$testParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) +$testLeaf = 'portcve-installer-tests-' + [Guid]::NewGuid().ToString('N') +$testRoot = Join-Path $testParent $testLeaf +[IO.Directory]::CreateDirectory($testRoot) | Out-Null +try { + $assetName = 'portcve-v1.0.0-win-x64.zip' + $assetPath = Join-Path $testRoot $assetName + [IO.File]::WriteAllBytes($assetPath, [byte[]](1, 2, 3, 4, 5)) + $hash = Get-Sha256 $assetPath + $sumPath = Join-Path $testRoot 'SHA256SUMS.txt' + [IO.File]::WriteAllText($sumPath, "$hash $assetName`r`n", [Text.UTF8Encoding]::new($false)) + Assert-True ((Get-ExpectedChecksum -ChecksumPath $sumPath -AssetName $assetName) -ceq $hash) 'Exact checksum lookup failed.' + + [IO.File]::WriteAllText($sumPath, "$hash $assetName`r`n$hash $assetName`r`n", [Text.UTF8Encoding]::new($false)) + Assert-Throws { Get-ExpectedChecksum -ChecksumPath $sumPath -AssetName $assetName } 'Duplicate checksum entry was accepted.' + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $safeZip = Join-Path $testRoot 'safe.zip' + $archive = [IO.Compression.ZipFile]::Open($safeZip, [IO.Compression.ZipArchiveMode]::Create) + try { + $entry = $archive.CreateEntry('portcve.exe') + $stream = $entry.Open() + try { $stream.Write([byte[]](77, 90, 1, 2), 0, 4) } finally { $stream.Dispose() } + $null = $archive.CreateEntry('docs/readme.txt') + } + finally { $archive.Dispose() } + $expanded = Join-Path $testRoot 'expanded' + $expandedExe = Expand-PortCVEExecutable -ZipPath $safeZip -DestinationDirectory $expanded + Assert-True ([IO.File]::Exists($expandedExe)) 'Safe archive did not produce portcve.exe.' + + $unsafeZip = Join-Path $testRoot 'unsafe.zip' + $archive = [IO.Compression.ZipFile]::Open($unsafeZip, [IO.Compression.ZipArchiveMode]::Create) + try { $null = $archive.CreateEntry('../portcve.exe') } finally { $archive.Dispose() } + Assert-Throws { Expand-PortCVEExecutable -ZipPath $unsafeZip -DestinationDirectory (Join-Path $testRoot 'unsafe-expanded') } 'Zip traversal entry was accepted.' + + $installPath = Join-Path $testRoot 'PortCVE' + $updatedPath = Get-UpdatedUserPath -CurrentPath 'C:\Windows' -InstallPath $installPath + Assert-True ($updatedPath.EndsWith(";$installPath", [StringComparison]::OrdinalIgnoreCase)) 'User PATH was not extended safely.' + Assert-True ((Get-UpdatedUserPath -CurrentPath "C:\Windows;$installPath" -InstallPath $installPath) -ceq "C:\Windows;$installPath") 'Duplicate PATH entry was added.' + + [IO.Directory]::CreateDirectory($installPath) | Out-Null + [IO.File]::WriteAllText((Join-Path $installPath 'unexpected.txt'), 'x') + Assert-Throws { Assert-SafeInstallTarget $installPath } 'Unmanaged install content was accepted.' + [IO.File]::Delete((Join-Path $installPath 'unexpected.txt')) + [IO.File]::WriteAllText((Join-Path $installPath 'portcve.exe'), 'old') + + $token = [Guid]::NewGuid().ToString('N') + $staging = "$installPath.staging-$token" + [IO.Directory]::CreateDirectory($staging) | Out-Null + [IO.File]::WriteAllText((Join-Path $staging 'portcve.exe'), 'new') + [IO.File]::WriteAllText((Join-Path $staging 'install-receipt.json'), '{}') + Invoke-AtomicInstall -InstallPath $installPath -StagingPath $staging -Token $token -OriginalUserPath 'unchanged' -UpdatedUserPath 'unchanged' + Assert-True (([IO.File]::ReadAllText((Join-Path $installPath 'portcve.exe'))) -ceq 'new') 'Atomic replacement did not install staged bytes.' + + $unicodeSubject = "CN=Jos$([char]0x00e9) O'Brien, O=PortCVE" + $finalizedPath = Join-Path $testRoot 'finalized\install.ps1' + $null = & $finalizerPath -TemplatePath $installerPath -OutputPath $finalizedPath -ExpectedSignerSubject $unicodeSubject + $finalizedBytes = [IO.File]::ReadAllBytes($finalizedPath) + Assert-True ($finalizedBytes.Length -ge 3 -and $finalizedBytes[0] -eq 0xef -and $finalizedBytes[1] -eq 0xbb -and $finalizedBytes[2] -eq 0xbf) 'Finalized installer is not UTF-8 with BOM.' + + # This harness runs under Windows PowerShell 5.1. ParseFile therefore proves + # the BOM preserves a non-ASCII subject for the supported legacy host. + $finalizedTokens = $null + $finalizedErrors = $null + $finalizedAst = [Management.Automation.Language.Parser]::ParseFile($finalizedPath, [ref]$finalizedTokens, [ref]$finalizedErrors) + Assert-True ($finalizedErrors.Count -eq 0) 'PowerShell 5.1 could not parse the BOM-finalized installer.' + $subjectAssignments = @($finalizedAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + [StringComparer]::Ordinal.Equals($node.Left.Extent.Text, '$script:ExpectedSignerSubject') + }, $true)) + $subjectPreserved = $subjectAssignments.Count -eq 1 -and + $subjectAssignments[0].Right -is [Management.Automation.Language.CommandExpressionAst] -and + $subjectAssignments[0].Right.Expression -is [Management.Automation.Language.StringConstantExpressionAst] -and + [StringComparer]::Ordinal.Equals([string]$subjectAssignments[0].Right.Expression.Value, $unicodeSubject) + Assert-True $subjectPreserved 'PowerShell 5.1 did not preserve the exact non-ASCII signer subject.' + + $forbiddenInstallPath = Join-Path $testRoot 'must-not-exist' + Assert-Throws { & $finalizedPath -Version 'v1.0.0' -InstallDirectory $forbiddenInstallPath } 'Unsigned finalized installer was accepted.' + Assert-True (-not (Test-Path -LiteralPath $forbiddenInstallPath)) 'Unsigned installer mutated the install target before rejecting its own signature.' +} +finally { + $resolved = [IO.Path]::GetFullPath($testRoot) + if ((Split-Path -Parent $resolved) -ne $testParent -or (Split-Path -Leaf $resolved) -ne $testLeaf) { + throw "Refusing test cleanup outside validated root '$testParent'." + } + if (Test-Path -LiteralPath $resolved) { Remove-Item -LiteralPath $resolved -Recurse -Force } +} + +Assert-Throws { & $installerPath -Version 'v1.0.0' } 'Unfinalized template did not fail closed before network access.' + +Write-Host "Installer offline checks passed: $script:Passed" diff --git a/scripts/tests/Test-Rfc3161Timestamp.ps1 b/scripts/tests/Test-Rfc3161Timestamp.ps1 new file mode 100644 index 0000000..9550e75 --- /dev/null +++ b/scripts/tests/Test-Rfc3161Timestamp.ps1 @@ -0,0 +1,204 @@ +#requires -Version 7.2 + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +. (Join-Path $repositoryRoot 'scripts\Rfc3161TimestampValidation.ps1') + +$script:Passed = 0 +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } + $script:Passed++ +} + +function Assert-Throws { + param([scriptblock]$Action, [string]$Message) + try { & $Action; throw "Expected failure: $Message" } + catch { + if ($_.Exception.Message -eq "Expected failure: $Message") { throw } + $script:Passed++ + } +} + +$timestampType = 'System.Security.Cryptography.Pkcs.Rfc3161TimestampToken' -as [type] +Assert-True ($null -ne $timestampType -and $null -ne $timestampType.GetMethod('VerifySignatureForSignerInfo')) ` + 'PowerShell 7 does not expose Rfc3161TimestampToken.VerifySignatureForSignerInfo.' + +$gitCommand = Get-Command git.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1 +$primaryPath = $gitCommand.Source +$primaryAuthenticode = Get-AuthenticodeSignature -LiteralPath $primaryPath +Assert-True ($primaryAuthenticode.Status -eq [Management.Automation.SignatureStatus]::Valid -and + $null -ne $primaryAuthenticode.SignerCertificate -and + $null -ne $primaryAuthenticode.TimeStamperCertificate) ` + 'Git for Windows is not a trusted timestamped Authenticode fixture on this runner.' + +$primaryContent = Get-PeSignatureContent -ExecutablePath $primaryPath +$primaryCms = [Security.Cryptography.Pkcs.SignedCms]::new() +$primaryCms.Decode($primaryContent) +$primaryCms.CheckSignature($true) +$primarySigner = $primaryCms.SignerInfos[0] +$validResult = Assert-Rfc3161SignerInfo ` + -SignerInfo $primarySigner ` + -ExtraCandidates $primaryCms.Certificates ` + -ExpectedTimestampCertificate $primaryAuthenticode.TimeStamperCertificate +Assert-True ($null -ne $validResult.Timestamp -and + [StringComparer]::Ordinal.Equals( + $validResult.TimestampSignerCertificate.Thumbprint, + $primaryAuthenticode.TimeStamperCertificate.Thumbprint)) ` + 'Valid RFC 3161 fixture was not cryptographically bound to its primary SignerInfo and trusted TSA certificate.' + +$timestampAttribute = @($primarySigner.UnsignedAttributes | Where-Object { + [StringComparer]::Ordinal.Equals($_.Oid.Value, $script:Rfc3161TimestampTokenOid) +}) +if ($timestampAttribute.Count -ne 1 -or $timestampAttribute[0].Values.Count -ne 1) { + throw 'Valid fixture did not contain the expected single RFC 3161 token.' +} +$tokenBytes = [byte[]]$timestampAttribute[0].Values[0].RawData.Clone() + +$decodedToken = $null +$decodedLength = 0 +if (-not [Security.Cryptography.Pkcs.Rfc3161TimestampToken]::TryDecode( + [ReadOnlyMemory[byte]]::new($tokenBytes), + [ref]$decodedToken, + [ref]$decodedLength) -or $decodedLength -ne $tokenBytes.Length) { + throw 'Valid fixture token could not be decoded for the tamper regression.' +} +$timestampSignature = $decodedToken.AsSignedCms().SignerInfos[0].GetSignature() +$signatureOffset = -1 +for ($candidateOffset = 0; $candidateOffset -le ($tokenBytes.Length - $timestampSignature.Length); $candidateOffset++) { + $matches = $true + for ($signatureIndex = 0; $signatureIndex -lt $timestampSignature.Length; $signatureIndex++) { + if ($tokenBytes[$candidateOffset + $signatureIndex] -ne $timestampSignature[$signatureIndex]) { + $matches = $false + break + } + } + if ($matches) { + $signatureOffset = $candidateOffset + break + } +} +if ($signatureOffset -lt 0) { throw 'Could not locate the TSA signature bytes in the RFC 3161 token.' } +$tamperedToken = [byte[]]$tokenBytes.Clone() +$tamperIndex = $signatureOffset + [Math]::Floor($timestampSignature.Length / 2) +$tamperedToken[$tamperIndex] = $tamperedToken[$tamperIndex] -bxor 1 +Assert-Throws { + Assert-BoundRfc3161TimestampToken ` + -SignerInfo $primarySigner ` + -TokenBytes $tamperedToken ` + -ExtraCandidates $primaryCms.Certificates ` + -ExpectedTimestampCertificate $primaryAuthenticode.TimeStamperCertificate +} 'Tampered RFC 3161 token was accepted.' + +$gitRoot = Split-Path -Parent (Split-Path -Parent $primaryPath) +$primarySignature = [Convert]::ToBase64String($primarySigner.GetSignature()) +$differentSigner = $null +foreach ($candidate in Get-ChildItem -LiteralPath $gitRoot -Recurse -Filter *.exe -File -ErrorAction SilentlyContinue) { + try { + $candidateContent = Get-PeSignatureContent -ExecutablePath $candidate.FullName + $candidateCms = [Security.Cryptography.Pkcs.SignedCms]::new() + $candidateCms.Decode($candidateContent) + $candidateCms.CheckSignature($true) + if ($candidateCms.SignerInfos.Count -eq 1 -and + -not [StringComparer]::Ordinal.Equals( + [Convert]::ToBase64String($candidateCms.SignerInfos[0].GetSignature()), + $primarySignature)) { + $differentSigner = $candidateCms.SignerInfos[0] + break + } + } + catch { + continue + } +} +if ($null -eq $differentSigner) { + throw 'Could not find a second embedded Authenticode SignerInfo for the unbound-token regression.' +} +Assert-Throws { + Assert-BoundRfc3161TimestampToken ` + -SignerInfo $differentSigner ` + -TokenBytes $tokenBytes ` + -ExtraCandidates $primaryCms.Certificates ` + -ExpectedTimestampCertificate $primaryAuthenticode.TimeStamperCertificate +} 'RFC 3161 token was accepted for a different primary SignerInfo.' + +$fakeKey = [Security.Cryptography.RSA]::Create(2048) +$fakeCertificate = $null +try { + $fakeRequest = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=RFC3161 Fake OID Fixture', + $fakeKey, + [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $fakeCertificate = $fakeRequest.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddDays(-1), + [DateTimeOffset]::UtcNow.AddDays(1)) + $fakeCms = [Security.Cryptography.Pkcs.SignedCms]::new( + [Security.Cryptography.Pkcs.ContentInfo]::new([byte[]](1, 2, 3)), + $false) + $fakeCms.ComputeSignature([Security.Cryptography.Pkcs.CmsSigner]::new($fakeCertificate)) + $fakeCms.SignerInfos[0].AddUnsignedAttribute([Security.Cryptography.AsnEncodedData]::new( + [Security.Cryptography.Oid]::new($script:Rfc3161TimestampTokenOid), + [byte[]](0x30, 0x00))) + $fakeEncoded = $fakeCms.Encode() + $fakeCms = [Security.Cryptography.Pkcs.SignedCms]::new() + $fakeCms.Decode($fakeEncoded) + Assert-Throws { + Assert-Rfc3161SignerInfo ` + -SignerInfo $fakeCms.SignerInfos[0] ` + -ExtraCandidates $fakeCms.Certificates ` + -ExpectedTimestampCertificate $fakeCertificate + } 'Fake RFC 3161 OID payload was accepted without decoding and binding a timestamp token.' +} +finally { + if ($null -ne $fakeCertificate) { $fakeCertificate.Dispose() } + $fakeKey.Dispose() +} + +$multipleCms = [Security.Cryptography.Pkcs.SignedCms]::new() +$multipleCms.Decode($primaryContent) +$multipleSigner = $multipleCms.SignerInfos[0] +$multipleSigner.AddUnsignedAttribute([Security.Cryptography.AsnEncodedData]::new( + [Security.Cryptography.Oid]::new($script:Rfc3161TimestampTokenOid), + $tokenBytes)) +$multipleEncoded = $multipleCms.Encode() +$multipleCms = [Security.Cryptography.Pkcs.SignedCms]::new() +$multipleCms.Decode($multipleEncoded) +$multipleSigner = $multipleCms.SignerInfos[0] +Assert-Throws { + Assert-Rfc3161SignerInfo ` + -SignerInfo $multipleSigner ` + -ExtraCandidates $multipleCms.Certificates ` + -ExpectedTimestampCertificate $primaryAuthenticode.TimeStamperCertificate +} 'Multiple RFC 3161 timestamp tokens were accepted.' + +$legacyCms = [Security.Cryptography.Pkcs.SignedCms]::new() +$legacyCms.Decode($primaryContent) +$legacySigner = $legacyCms.SignerInfos[0] +$legacySigner.AddUnsignedAttribute([Security.Cryptography.AsnEncodedData]::new( + [Security.Cryptography.Oid]::new($script:LegacyAuthenticodeTimestampOid), + [byte[]](0x05, 0x00))) +$legacyEncoded = $legacyCms.Encode() +$legacyCms = [Security.Cryptography.Pkcs.SignedCms]::new() +$legacyCms.Decode($legacyEncoded) +$legacySigner = $legacyCms.SignerInfos[0] +Assert-Throws { + Assert-Rfc3161SignerInfo ` + -SignerInfo $legacySigner ` + -ExtraCandidates $legacyCms.Certificates ` + -ExpectedTimestampCertificate $primaryAuthenticode.TimeStamperCertificate +} 'Legacy Authenticode countersignature was accepted alongside RFC 3161.' + +Assert-Throws { + Assert-Rfc3161SignerInfo ` + -SignerInfo $primarySigner ` + -ExtraCandidates $primaryCms.Certificates ` + -ExpectedTimestampCertificate $primaryAuthenticode.SignerCertificate +} 'Cryptographic timestamp signer was not required to match the platform-trusted timestamp certificate.' + +Write-Host "RFC 3161 binding checks passed: $script:Passed" diff --git a/src/BindWitness/Analysis/BindScopeClassifier.cs b/src/PortCVE/Analysis/BindScopeClassifier.cs similarity index 97% rename from src/BindWitness/Analysis/BindScopeClassifier.cs rename to src/PortCVE/Analysis/BindScopeClassifier.cs index 33dca70..c0c495d 100644 --- a/src/BindWitness/Analysis/BindScopeClassifier.cs +++ b/src/PortCVE/Analysis/BindScopeClassifier.cs @@ -1,7 +1,7 @@ using System.Net; -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Analysis; +namespace PortCVE.Analysis; public sealed record BindClassification( BindScope Scope, diff --git a/src/BindWitness/Analysis/ListenerDiffEngine.cs b/src/PortCVE/Analysis/ListenerDiffEngine.cs similarity index 99% rename from src/BindWitness/Analysis/ListenerDiffEngine.cs rename to src/PortCVE/Analysis/ListenerDiffEngine.cs index 30d2043..5a941ed 100644 --- a/src/BindWitness/Analysis/ListenerDiffEngine.cs +++ b/src/PortCVE/Analysis/ListenerDiffEngine.cs @@ -1,7 +1,7 @@ -using BindWitness.Domain; -using BindWitness.Snapshots; +using PortCVE.Domain; +using PortCVE.Snapshots; -namespace BindWitness.Analysis; +namespace PortCVE.Analysis; public enum ListenerChangeKind { diff --git a/src/BindWitness/Cli/CliApplication.cs b/src/PortCVE/Cli/CliApplication.cs similarity index 81% rename from src/BindWitness/Cli/CliApplication.cs rename to src/PortCVE/Cli/CliApplication.cs index 48d80b7..f5b6854 100644 --- a/src/BindWitness/Cli/CliApplication.cs +++ b/src/PortCVE/Cli/CliApplication.cs @@ -1,27 +1,61 @@ using System.Reflection; using System.Text; -using BindWitness.Analysis; -using BindWitness.Collection; -using BindWitness.Domain; -using BindWitness.Output; -using BindWitness.Snapshots; +using PortCVE.Analysis; +using PortCVE.Collection; +using PortCVE.Domain; +using PortCVE.Output; +using PortCVE.Snapshots; +using PortCVE.Vulnerabilities; -namespace BindWitness.Cli; +namespace PortCVE.Cli; public sealed class CliApplication { private readonly ISnapshotBuilder snapshotBuilder; private readonly LockfileService lockfileService; + private readonly IVulnerabilityScanner vulnerabilityScanner; + private readonly Func sbomPathValidator; public CliApplication() - : this(new SnapshotBuilder(), new LockfileService()) + : this( + new SnapshotBuilder(), + new LockfileService(), + new TrivyVulnerabilityScanner(), + LocalPathPolicy.ValidateExistingLocalFile) { } internal CliApplication(ISnapshotBuilder snapshotBuilder, LockfileService lockfileService) + : this( + snapshotBuilder, + lockfileService, + new TrivyVulnerabilityScanner(), + LocalPathPolicy.ValidateExistingLocalFile) + { + } + + internal CliApplication( + ISnapshotBuilder snapshotBuilder, + LockfileService lockfileService, + IVulnerabilityScanner vulnerabilityScanner) + : this( + snapshotBuilder, + lockfileService, + vulnerabilityScanner, + LocalPathPolicy.ValidateExistingLocalFile) + { + } + + internal CliApplication( + ISnapshotBuilder snapshotBuilder, + LockfileService lockfileService, + IVulnerabilityScanner vulnerabilityScanner, + Func sbomPathValidator) { this.snapshotBuilder = snapshotBuilder; this.lockfileService = lockfileService; + this.vulnerabilityScanner = vulnerabilityScanner; + this.sbomPathValidator = sbomPathValidator; } public async Task RunAsync( @@ -35,6 +69,7 @@ public async Task RunAsync( CommandKind.Help => WriteHelp(output), CommandKind.Version => WriteVersion(output), CommandKind.List or CommandKind.Inspect => await RunListAsync(options, output, error, cancellationToken), + CommandKind.Scan => await RunScanAsync(options, output, error, cancellationToken), CommandKind.Lock => await RunLockAsync(options, output, error, cancellationToken), CommandKind.Snapshot => await RunSnapshotAsync(options, output, error, cancellationToken), CommandKind.Diff or CommandKind.Check => await RunDiffAsync(options, output, error, cancellationToken), @@ -44,6 +79,98 @@ public async Task RunAsync( }; } + private async Task RunScanAsync( + CliOptions options, + TextWriter output, + TextWriter error, + CancellationToken cancellationToken) + { + string? sbomPath = null; + if (options.SbomPath is not null) + { + var validation = sbomPathValidator(options.SbomPath); + if (!validation.IsValid) + { + error.WriteLine($"error: {validation.Code}: {validation.Message}"); + return ExitCodes.UsageOrSchema; + } + + sbomPath = validation.FullPath; + } + + var snapshot = await snapshotBuilder.CollectAsync( + new(IncludeFirewall: false, IncludeProfiles: false), + cancellationToken); + if (!SocketsAvailable(snapshot)) + { + TextRenderer.RenderDiagnostics(snapshot.Diagnostics, error); + return ExitCodes.RuntimeFailure; + } + + var selected = snapshot.Listeners + .Where(static listener => listener.Protocol == TransportProtocol.Tcp) + .Where(listener => options.All || listener.LocalPort == options.Port) + .OrderBy(static listener => listener.Key, StringComparer.Ordinal) + .ToArray(); + var selector = options.All ? "all_tcp_listeners" : $"tcp:{options.Port}"; + var service = new VulnerabilityAssessmentService(vulnerabilityScanner); + VulnerabilityReport report; + try + { + report = await service.AssessAsync( + Version, + selector, + selected, + selected.Length == 0 ? null : sbomPath, + cancellationToken); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + error.WriteLine($"error: could not read the SBOM: {exception.Message}"); + return ExitCodes.RuntimeFailure; + } + + if (options.Json) + { + var jsonReport = options.IncludePrivate + ? report + : VulnerabilityReportRedactor.Redact(report); + await output.WriteLineAsync(JsonOutput.Serialize(jsonReport)); + } + else + { + VulnerabilityTextRenderer.Render(report, output, error); + } + + if (selected.Length == 0) + { + if (!options.Json) + { + error.WriteLine($"error: no TCP listeners matched {selector}."); + } + + return ExitCodes.NegativeResult; + } + + if (!report.HasSuccessfulScan) + { + return ExitCodes.IncompleteEvidence; + } + + if (options.Strict && !report.Summary.IsComplete) + { + return ExitCodes.IncompleteEvidence; + } + + if (options.FailOn is not null && report.Findings.Any(finding => + MeetsThreshold(finding.Severity, options.FailOn.Value))) + { + return ExitCodes.NegativeResult; + } + + return ExitCodes.Success; + } + private async Task RunListAsync( CliOptions options, TextWriter output, @@ -490,7 +617,7 @@ private async Task RunDoctorAsync( await output.WriteLineAsync(JsonOutput.Serialize(new { schema_version = 1, - tool = "bindwitness", + tool = "portcve", version = Version, platform = snapshot.Platform, privileged = Environment.IsPrivilegedProcess, @@ -510,7 +637,7 @@ await output.WriteLineAsync(JsonOutput.Serialize(new } else { - output.WriteLine($"BindWitness {Version}"); + output.WriteLine($"PortCVE {Version}"); output.WriteLine($"Platform {snapshot.Platform}"); output.WriteLine($"Elevated {(Environment.IsPrivilegedProcess ? "yes" : "no")}"); output.WriteLine($"Endpoints {snapshot.Listeners.Count}"); @@ -651,6 +778,15 @@ private static bool CollectorEvidenceDegraded( _ => 0, }; + private static bool MeetsThreshold( + VulnerabilitySeverity actual, + VulnerabilitySeverity threshold) => actual switch + { + VulnerabilitySeverity.Critical => true, + VulnerabilitySeverity.High => threshold == VulnerabilitySeverity.High, + _ => false, + }; + private static bool IsCheckFailure(ListenerChange change) => change.Kind switch { ListenerChangeKind.Added => true, @@ -673,23 +809,25 @@ private static bool CollectorEvidenceDegraded( private static int WriteVersion(TextWriter output) { - output.WriteLine($"bindwitness {Version}"); + output.WriteLine($"portcve {Version}"); return ExitCodes.Success; } private static int WriteHelp(TextWriter output) { - output.WriteLine("BindWitness explains local ports and locks the ones you expect."); + output.WriteLine("PortCVE explains local ports and locks the ones you expect."); output.WriteLine(); output.WriteLine("USAGE"); - output.WriteLine(" bindwitness List local TCP listeners and UDP binds"); - output.WriteLine(" bindwitness 8080 Explain every local endpoint on port 8080"); - output.WriteLine(" bindwitness tcp:8080 --evidence Explain one protocol with firewall evidence"); - output.WriteLine(" bindwitness lock -o listeners.lock Save a normalized, privacy-reduced baseline"); - output.WriteLine(" bindwitness diff listeners.lock Show drift from the live machine"); - output.WriteLine(" bindwitness check listeners.lock Fail on new, wider, or owner-changed binds"); - output.WriteLine(" bindwitness watch --json Stream endpoint changes as JSONL"); - output.WriteLine(" bindwitness doctor Check collection coverage and privacy mode"); + output.WriteLine(" portcve List local TCP listeners and UDP binds"); + output.WriteLine(" portcve 8080 Explain every local endpoint on port 8080"); + output.WriteLine(" portcve tcp:8080 --evidence Explain one protocol with firewall evidence"); + output.WriteLine(" portcve lock -o listeners.lock Save a normalized, privacy-reduced baseline"); + 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 watch --json Stream endpoint changes as JSONL"); + output.WriteLine(" portcve doctor Check collection coverage and privacy mode"); output.WriteLine(); output.WriteLine("OPTIONS"); output.WriteLine(" -p, --port <1-65535> Filter by local port"); @@ -704,6 +842,9 @@ private static int WriteHelp(TextWriter output) 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(" --strict Exit 3 when core evidence is incomplete"); + output.WriteLine(" --all Select every TCP listener for scan"); + output.WriteLine(" --sbom Scan an explicitly supplied local SBOM"); + output.WriteLine(" --fail-on Exit 1 when that severity threshold is met"); output.WriteLine(" -o, --output Write lock or snapshot output to a file"); output.WriteLine(" --force Replace an existing output file"); output.WriteLine(" --interval Watch interval, for example 500ms or 2s"); @@ -712,7 +853,8 @@ private static int WriteHelp(TextWriter output) output.WriteLine(" 0 success/pass; 1 no match or policy fail; 2 usage/schema;"); output.WriteLine(" 3 incomplete evidence; 4 collection/runtime failure; 130 interrupted."); output.WriteLine(); - output.WriteLine("BindWitness is read-only and does not prove Internet reachability."); + output.WriteLine("PortCVE is read-only and does not prove reachability or exploitability."); + output.WriteLine("Vulnerability scans use a preinstalled Trivy database in offline mode; no update is automatic."); return ExitCodes.Success; } diff --git a/src/BindWitness/Cli/CliOptions.cs b/src/PortCVE/Cli/CliOptions.cs similarity index 83% rename from src/BindWitness/Cli/CliOptions.cs rename to src/PortCVE/Cli/CliOptions.cs index f4e57ec..7519f16 100644 --- a/src/BindWitness/Cli/CliOptions.cs +++ b/src/PortCVE/Cli/CliOptions.cs @@ -1,11 +1,13 @@ -using BindWitness.Domain; +using PortCVE.Domain; +using PortCVE.Vulnerabilities; -namespace BindWitness.Cli; +namespace PortCVE.Cli; public enum CommandKind { List, Inspect, + Scan, Lock, Snapshot, Diff, @@ -34,7 +36,10 @@ public sealed record CliOptions( bool IncludePrivate = false, bool ResolveAccounts = false, TimeSpan? Interval = null, - int? Iterations = null); + int? Iterations = null, + bool All = false, + string? SbomPath = null, + VulnerabilitySeverity? FailOn = null); public sealed class CliUsageException(string message) : Exception(message); diff --git a/src/BindWitness/Cli/CliParser.cs b/src/PortCVE/Cli/CliParser.cs similarity index 80% rename from src/BindWitness/Cli/CliParser.cs rename to src/PortCVE/Cli/CliParser.cs index 0f874ec..f8de9ad 100644 --- a/src/BindWitness/Cli/CliParser.cs +++ b/src/PortCVE/Cli/CliParser.cs @@ -1,7 +1,8 @@ using System.Globalization; -using BindWitness.Domain; +using PortCVE.Domain; +using PortCVE.Vulnerabilities; -namespace BindWitness.Cli; +namespace PortCVE.Cli; public static class CliParser { @@ -29,6 +30,9 @@ public static CliOptions Parse(IReadOnlyList arguments) var includeUdp = false; var includePrivate = false; var resolveAccounts = false; + var all = false; + string? sbomPath = null; + VulnerabilitySeverity? failOn = null; TimeSpan? interval = null; int? iterations = null; var index = 0; @@ -49,6 +53,7 @@ public static CliOptions Parse(IReadOnlyList arguments) { "list" or "ls" => CommandKind.List, "inspect" or "explain" => CommandKind.Inspect, + "scan" => CommandKind.Scan, "lock" => CommandKind.Lock, "snapshot" => CommandKind.Snapshot, "diff" => CommandKind.Diff, @@ -120,6 +125,15 @@ public static CliOptions Parse(IReadOnlyList arguments) case "--resolve-accounts": resolveAccounts = true; break; + case "--all": + all = true; + break; + case "--sbom": + sbomPath = RequireValue(arguments, ref index, argument); + break; + case "--fail-on": + failOn = ParseVulnerabilitySeverity(RequireValue(arguments, ref index, argument)); + break; case "-p" or "--port": port = ParsePort(RequireValue(arguments, ref index, argument)); break; @@ -164,6 +178,18 @@ public static CliOptions Parse(IReadOnlyList arguments) positionals.RemoveAt(0); } + if (command == CommandKind.Scan && positionals.Count > 0) + { + if (!TryParseQuery(positionals[0], out var queryProtocol, out var queryPort)) + { + throw new CliUsageException($"'{positionals[0]}' is not a valid TCP port query."); + } + + protocol = queryProtocol ?? TransportProtocol.Tcp; + port = queryPort; + positionals.RemoveAt(0); + } + if (command is CommandKind.Diff or CommandKind.Check) { if (positionals.Count == 0) @@ -187,7 +213,38 @@ public static CliOptions Parse(IReadOnlyList arguments) if (command == CommandKind.Inspect && port is null) { - throw new CliUsageException("inspect requires a port, for example: bindwitness tcp:8080"); + throw new CliUsageException("inspect requires a port, for example: portcve tcp:8080"); + } + + if (command == CommandKind.Scan) + { + if (all == (port is not null)) + { + throw new CliUsageException("scan requires exactly one TCP port query or --all."); + } + + if (protocol is not null && protocol != TransportProtocol.Tcp) + { + throw new CliUsageException("scan supports TCP listeners only."); + } + + protocol = TransportProtocol.Tcp; + if (all && sbomPath is not null) + { + throw new CliUsageException("--sbom requires an exact TCP port query and cannot be combined with --all."); + } + + if (process is not null || scope is not null || firewall || firewallExplicitlyDisabled || evidence || includeUdp + || resolveAccounts || output is not null || interval is not null || iterations is not null + || force || allowIncomplete) + { + throw new CliUsageException( + "scan accepts only its TCP selector, --all, --sbom, --fail-on, --json, --include-private, and --strict."); + } + } + else if (all || sbomPath is not null || failOn is not null) + { + throw new CliUsageException("--all, --sbom, and --fail-on are available only with scan."); } if (command == CommandKind.Lock) @@ -228,7 +285,10 @@ public static CliOptions Parse(IReadOnlyList arguments) includePrivate, resolveAccounts, interval, - iterations); + iterations, + all, + sbomPath, + failOn); } private static string RequireValue(IReadOnlyList arguments, ref int index, string option) @@ -326,4 +386,11 @@ private static int ParsePositiveInt(string value, string option) return result; } + + private static VulnerabilitySeverity ParseVulnerabilitySeverity(string value) => value.ToLowerInvariant() switch + { + "high" => VulnerabilitySeverity.High, + "critical" => VulnerabilitySeverity.Critical, + _ => throw new CliUsageException("--fail-on must be high or critical."), + }; } diff --git a/src/BindWitness/Collection/CollectionResult.cs b/src/PortCVE/Collection/CollectionResult.cs similarity index 82% rename from src/BindWitness/Collection/CollectionResult.cs rename to src/PortCVE/Collection/CollectionResult.cs index 2ad675f..cf97812 100644 --- a/src/BindWitness/Collection/CollectionResult.cs +++ b/src/PortCVE/Collection/CollectionResult.cs @@ -1,6 +1,6 @@ -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Collection; +namespace PortCVE.Collection; public sealed record CollectionResult( T Value, diff --git a/src/BindWitness/Collection/DockerExposureCorrelator.cs b/src/PortCVE/Collection/DockerExposureCorrelator.cs similarity index 98% rename from src/BindWitness/Collection/DockerExposureCorrelator.cs rename to src/PortCVE/Collection/DockerExposureCorrelator.cs index e7bbd81..62ed39a 100644 --- a/src/BindWitness/Collection/DockerExposureCorrelator.cs +++ b/src/PortCVE/Collection/DockerExposureCorrelator.cs @@ -1,6 +1,6 @@ -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Collection; +namespace PortCVE.Collection; internal sealed record DockerCorrelationResult( IReadOnlyList Listeners, diff --git a/src/BindWitness/Collection/DockerPublishedPort.cs b/src/PortCVE/Collection/DockerPublishedPort.cs similarity index 86% rename from src/BindWitness/Collection/DockerPublishedPort.cs rename to src/PortCVE/Collection/DockerPublishedPort.cs index 4508d03..03f2b14 100644 --- a/src/BindWitness/Collection/DockerPublishedPort.cs +++ b/src/PortCVE/Collection/DockerPublishedPort.cs @@ -1,4 +1,4 @@ -namespace BindWitness.Collection; +namespace PortCVE.Collection; public sealed record DockerPublishedPort( string ContainerId, diff --git a/src/BindWitness/Collection/DockerPublishedPortParser.cs b/src/PortCVE/Collection/DockerPublishedPortParser.cs similarity index 99% rename from src/BindWitness/Collection/DockerPublishedPortParser.cs rename to src/PortCVE/Collection/DockerPublishedPortParser.cs index 6c173bd..baf5959 100644 --- a/src/BindWitness/Collection/DockerPublishedPortParser.cs +++ b/src/PortCVE/Collection/DockerPublishedPortParser.cs @@ -2,7 +2,7 @@ using System.Net; using System.Text.Json; -namespace BindWitness.Collection; +namespace PortCVE.Collection; internal static class DockerPublishedPortParser { diff --git a/src/BindWitness/Collection/EndpointSnapshotMatcher.cs b/src/PortCVE/Collection/EndpointSnapshotMatcher.cs similarity index 96% rename from src/BindWitness/Collection/EndpointSnapshotMatcher.cs rename to src/PortCVE/Collection/EndpointSnapshotMatcher.cs index 516b80d..aefa8fe 100644 --- a/src/BindWitness/Collection/EndpointSnapshotMatcher.cs +++ b/src/PortCVE/Collection/EndpointSnapshotMatcher.cs @@ -1,7 +1,7 @@ using System.Net.Sockets; -using BindWitness.Platforms.Windows; +using PortCVE.Platforms.Windows; -namespace BindWitness.Collection; +namespace PortCVE.Collection; internal sealed record EndpointSnapshotOccurrence( WindowsRawEndpoint Endpoint, diff --git a/src/BindWitness/Collection/NetworkInterfaceCollector.cs b/src/PortCVE/Collection/NetworkInterfaceCollector.cs similarity index 99% rename from src/BindWitness/Collection/NetworkInterfaceCollector.cs rename to src/PortCVE/Collection/NetworkInterfaceCollector.cs index ba60452..77bc4a9 100644 --- a/src/BindWitness/Collection/NetworkInterfaceCollector.cs +++ b/src/PortCVE/Collection/NetworkInterfaceCollector.cs @@ -2,9 +2,9 @@ using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text.Json; -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Collection; +namespace PortCVE.Collection; public sealed class NetworkInterfaceCollector { diff --git a/src/BindWitness/Collection/PowerShellJsonRunner.cs b/src/PortCVE/Collection/PowerShellJsonRunner.cs similarity index 98% rename from src/BindWitness/Collection/PowerShellJsonRunner.cs rename to src/PortCVE/Collection/PowerShellJsonRunner.cs index 13b3c42..69209f6 100644 --- a/src/BindWitness/Collection/PowerShellJsonRunner.cs +++ b/src/PortCVE/Collection/PowerShellJsonRunner.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace BindWitness.Collection; +namespace PortCVE.Collection; public sealed record PowerShellResult( bool Succeeded, diff --git a/src/BindWitness/Collection/SnapshotBuilder.cs b/src/PortCVE/Collection/SnapshotBuilder.cs similarity index 98% rename from src/BindWitness/Collection/SnapshotBuilder.cs rename to src/PortCVE/Collection/SnapshotBuilder.cs index fa3b069..69197bc 100644 --- a/src/BindWitness/Collection/SnapshotBuilder.cs +++ b/src/PortCVE/Collection/SnapshotBuilder.cs @@ -2,11 +2,11 @@ using System.Net.NetworkInformation; using System.Net.Sockets; using System.Reflection; -using BindWitness.Analysis; -using BindWitness.Domain; -using BindWitness.Platforms.Windows; +using PortCVE.Analysis; +using PortCVE.Domain; +using PortCVE.Platforms.Windows; -namespace BindWitness.Collection; +namespace PortCVE.Collection; public sealed record SnapshotOptions( bool IncludeFirewall = false, @@ -65,7 +65,7 @@ public async Task CollectAsync( "sockets", CollectorStatus.Unavailable, "platform_unsupported", - "BindWitness 0.1 supports Windows only."); + "PortCVE 0.1 supports Windows only."); return new( SystemSnapshot.CurrentSchemaVersion, ToolVersion, diff --git a/src/BindWitness/Collection/WindowsDockerEngineCollector.cs b/src/PortCVE/Collection/WindowsDockerEngineCollector.cs similarity index 98% rename from src/BindWitness/Collection/WindowsDockerEngineCollector.cs rename to src/PortCVE/Collection/WindowsDockerEngineCollector.cs index 4d1a3c6..8f7f2c2 100644 --- a/src/BindWitness/Collection/WindowsDockerEngineCollector.cs +++ b/src/PortCVE/Collection/WindowsDockerEngineCollector.cs @@ -6,9 +6,9 @@ using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text.Json; -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Collection; +namespace PortCVE.Collection; public sealed class WindowsDockerEngineCollector { @@ -60,7 +60,7 @@ public async Task>> CollectA DefaultRequestVersion = HttpVersion.Version11, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, }; - client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("bindwitness", "0.1")); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("portcve", "0.1")); using var versionResponse = await client.GetAsync( "/version", diff --git a/src/BindWitness/Collection/WindowsFirewallCollector.cs b/src/PortCVE/Collection/WindowsFirewallCollector.cs similarity index 99% rename from src/BindWitness/Collection/WindowsFirewallCollector.cs rename to src/PortCVE/Collection/WindowsFirewallCollector.cs index 16a43ad..0201973 100644 --- a/src/BindWitness/Collection/WindowsFirewallCollector.cs +++ b/src/PortCVE/Collection/WindowsFirewallCollector.cs @@ -2,9 +2,9 @@ using System.Globalization; using System.Net; using System.Text.Json; -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Collection; +namespace PortCVE.Collection; public sealed record FirewallCollection( WindowsFirewallPolicy? Policy, diff --git a/src/BindWitness/Collection/WindowsOwnerCollector.cs b/src/PortCVE/Collection/WindowsOwnerCollector.cs similarity index 99% rename from src/BindWitness/Collection/WindowsOwnerCollector.cs rename to src/PortCVE/Collection/WindowsOwnerCollector.cs index dbbabe1..6ef6fa2 100644 --- a/src/BindWitness/Collection/WindowsOwnerCollector.cs +++ b/src/PortCVE/Collection/WindowsOwnerCollector.cs @@ -3,9 +3,9 @@ using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Collection; +namespace PortCVE.Collection; [SupportedOSPlatform("windows")] public sealed class WindowsOwnerCollector diff --git a/src/BindWitness/Domain/Models.cs b/src/PortCVE/Domain/Models.cs similarity index 99% rename from src/BindWitness/Domain/Models.cs rename to src/PortCVE/Domain/Models.cs index 492515b..aa76372 100644 --- a/src/BindWitness/Domain/Models.cs +++ b/src/PortCVE/Domain/Models.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace BindWitness.Domain; +namespace PortCVE.Domain; public enum TransportProtocol { diff --git a/src/BindWitness/Output/JsonOutput.cs b/src/PortCVE/Output/JsonOutput.cs similarity index 96% rename from src/BindWitness/Output/JsonOutput.cs rename to src/PortCVE/Output/JsonOutput.cs index 1567853..a6c7236 100644 --- a/src/BindWitness/Output/JsonOutput.cs +++ b/src/PortCVE/Output/JsonOutput.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace BindWitness.Output; +namespace PortCVE.Output; public static class JsonOutput { diff --git a/src/BindWitness/Output/SnapshotRedactor.cs b/src/PortCVE/Output/SnapshotRedactor.cs similarity index 98% rename from src/BindWitness/Output/SnapshotRedactor.cs rename to src/PortCVE/Output/SnapshotRedactor.cs index 926ba54..e790618 100644 --- a/src/BindWitness/Output/SnapshotRedactor.cs +++ b/src/PortCVE/Output/SnapshotRedactor.cs @@ -1,8 +1,8 @@ using System.Net; -using BindWitness.Collection; -using BindWitness.Domain; +using PortCVE.Collection; +using PortCVE.Domain; -namespace BindWitness.Output; +namespace PortCVE.Output; public static class SnapshotRedactor { diff --git a/src/BindWitness/Output/TextRenderer.cs b/src/PortCVE/Output/TextRenderer.cs similarity index 99% rename from src/BindWitness/Output/TextRenderer.cs rename to src/PortCVE/Output/TextRenderer.cs index 8574e7e..8bdc4f2 100644 --- a/src/BindWitness/Output/TextRenderer.cs +++ b/src/PortCVE/Output/TextRenderer.cs @@ -1,7 +1,7 @@ -using BindWitness.Analysis; -using BindWitness.Domain; +using PortCVE.Analysis; +using PortCVE.Domain; -namespace BindWitness.Output; +namespace PortCVE.Output; public static class TextRenderer { diff --git a/src/BindWitness/Platforms/Windows/WindowsEndpointCollector.cs b/src/PortCVE/Platforms/Windows/WindowsEndpointCollector.cs similarity index 99% rename from src/BindWitness/Platforms/Windows/WindowsEndpointCollector.cs rename to src/PortCVE/Platforms/Windows/WindowsEndpointCollector.cs index 9944f1e..eaca6ad 100644 --- a/src/BindWitness/Platforms/Windows/WindowsEndpointCollector.cs +++ b/src/PortCVE/Platforms/Windows/WindowsEndpointCollector.cs @@ -6,9 +6,9 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; -[assembly: InternalsVisibleTo("BindWitness.Tests")] +[assembly: InternalsVisibleTo("PortCVE.Tests")] -namespace BindWitness.Platforms.Windows; +namespace PortCVE.Platforms.Windows; public sealed class WindowsEndpointCollector { diff --git a/src/BindWitness/Platforms/Windows/WindowsRawEndpoint.cs b/src/PortCVE/Platforms/Windows/WindowsRawEndpoint.cs similarity index 89% rename from src/BindWitness/Platforms/Windows/WindowsRawEndpoint.cs rename to src/PortCVE/Platforms/Windows/WindowsRawEndpoint.cs index 2019a67..53b9b1c 100644 --- a/src/BindWitness/Platforms/Windows/WindowsRawEndpoint.cs +++ b/src/PortCVE/Platforms/Windows/WindowsRawEndpoint.cs @@ -2,7 +2,7 @@ using System.Net.NetworkInformation; using System.Net.Sockets; -namespace BindWitness.Platforms.Windows; +namespace PortCVE.Platforms.Windows; public enum WindowsEndpointProtocol { diff --git a/src/BindWitness/BindWitness.csproj b/src/PortCVE/PortCVE.csproj similarity index 85% rename from src/BindWitness/BindWitness.csproj rename to src/PortCVE/PortCVE.csproj index c68ca41..143391e 100644 --- a/src/BindWitness/BindWitness.csproj +++ b/src/PortCVE/PortCVE.csproj @@ -1,12 +1,12 @@ - + Exe net10.0 enable enable - bindwitness - BindWitness + portcve + PortCVE true true true @@ -14,7 +14,7 @@ 10.0.10 false 0.1.0-alpha.1 - BindWitness + PortCVE Explain local Windows ports, their owners, bind scope, host-firewall evidence, and baseline drift. Labeeb MIT diff --git a/src/BindWitness/Program.cs b/src/PortCVE/Program.cs similarity index 88% rename from src/BindWitness/Program.cs rename to src/PortCVE/Program.cs index 0e5f33d..edc4e75 100644 --- a/src/BindWitness/Program.cs +++ b/src/PortCVE/Program.cs @@ -1,4 +1,4 @@ -using BindWitness.Cli; +using PortCVE.Cli; using var cancellationSource = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => @@ -19,7 +19,7 @@ catch (CliUsageException exception) { Console.Error.WriteLine($"error: {exception.Message}"); - Console.Error.WriteLine("Run 'bindwitness help' for usage."); + Console.Error.WriteLine("Run 'portcve help' for usage."); return ExitCodes.UsageOrSchema; } catch (OperationCanceledException) diff --git a/src/BindWitness/Snapshots/LockfileModels.cs b/src/PortCVE/Snapshots/LockfileModels.cs similarity index 96% rename from src/BindWitness/Snapshots/LockfileModels.cs rename to src/PortCVE/Snapshots/LockfileModels.cs index 4d9edd3..73bd9e2 100644 --- a/src/BindWitness/Snapshots/LockfileModels.cs +++ b/src/PortCVE/Snapshots/LockfileModels.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Snapshots; +namespace PortCVE.Snapshots; public enum OwnerIdentityStrength { diff --git a/src/BindWitness/Snapshots/LockfileService.cs b/src/PortCVE/Snapshots/LockfileService.cs similarity index 99% rename from src/BindWitness/Snapshots/LockfileService.cs rename to src/PortCVE/Snapshots/LockfileService.cs index f72c5dc..722e04d 100644 --- a/src/BindWitness/Snapshots/LockfileService.cs +++ b/src/PortCVE/Snapshots/LockfileService.cs @@ -2,9 +2,9 @@ using System.Text.Json.Serialization; using System.Security.Cryptography; using System.Text; -using BindWitness.Domain; +using PortCVE.Domain; -namespace BindWitness.Snapshots; +namespace PortCVE.Snapshots; public sealed class LockfileService { @@ -52,7 +52,7 @@ public ListenerLockfile Create( return new( ListenerLockfile.CurrentSchemaVersion, - $"bindwitness/{snapshot.ToolVersion}", + $"portcve/{snapshot.ToolVersion}", includesUdp, selector ?? new(null, null, null, null), evidence, diff --git a/src/PortCVE/Vulnerabilities/BoundedProcessRunner.cs b/src/PortCVE/Vulnerabilities/BoundedProcessRunner.cs new file mode 100644 index 0000000..1558f0d --- /dev/null +++ b/src/PortCVE/Vulnerabilities/BoundedProcessRunner.cs @@ -0,0 +1,273 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace PortCVE.Vulnerabilities; + +internal sealed record ProcessInvocation( + string FileName, + IReadOnlyList Arguments, + TimeSpan Timeout, + int MaximumStandardOutputCharacters, + int MaximumStandardErrorCharacters, + IReadOnlyList? EnvironmentVariablesToRemove = null, + IReadOnlyDictionary? EnvironmentVariablesToSet = null, + IReadOnlyList? EnvironmentVariablePrefixesToRemove = null); + +internal sealed record ProcessExecutionResult( + bool Started, + int? ExitCode, + string StandardOutput, + string StandardError, + long DurationMs, + bool TimedOut = false, + bool OutputLimitExceeded = false, + string? StartError = null); + +internal interface IProcessRunner +{ + Task RunAsync( + ProcessInvocation invocation, + CancellationToken cancellationToken); +} + +internal sealed class BoundedProcessRunner : IProcessRunner +{ + private static readonly TimeSpan DefaultPostKillGrace = TimeSpan.FromSeconds(2); + private readonly TimeSpan postKillGrace; + + public BoundedProcessRunner() + : this(DefaultPostKillGrace) + { + } + + internal BoundedProcessRunner(TimeSpan postKillGrace) + { + if (postKillGrace <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(postKillGrace)); + } + + this.postKillGrace = postKillGrace; + } + + public async Task RunAsync( + ProcessInvocation invocation, + CancellationToken cancellationToken) + { + var startedAt = Stopwatch.StartNew(); + using var process = new Process + { + StartInfo = CreateStartInfo(invocation), + EnableRaisingEvents = true, + }; + + try + { + if (!process.Start()) + { + return new(false, null, string.Empty, string.Empty, startedAt.ElapsedMilliseconds, + StartError: "The scanner process did not start."); + } + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException) + { + return new(false, null, string.Empty, string.Empty, startedAt.ElapsedMilliseconds, + StartError: exception.Message); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(invocation.Timeout); + var standardOutputTask = ReadBoundedAsync( + process.StandardOutput, + invocation.MaximumStandardOutputCharacters, + timeout.Token); + var standardErrorTask = ReadBoundedAsync( + process.StandardError, + invocation.MaximumStandardErrorCharacters, + timeout.Token); + var readersTask = Task.WhenAll(standardOutputTask, standardErrorTask); + var exitTask = process.WaitForExitAsync(timeout.Token); + + try + { + var first = await Task.WhenAny(exitTask, readersTask); + if (first == readersTask && readersTask.IsFaulted) + { + await readersTask; + } + + await exitTask; + var streams = await readersTask; + return new( + true, + process.ExitCode, + streams[0], + streams[1], + startedAt.ElapsedMilliseconds); + } + catch (OutputLimitExceededException) + { + KillProcessTree(process); + await WaitAfterKillAsync(process, postKillGrace); + return new( + true, + process.HasExited ? process.ExitCode : null, + string.Empty, + string.Empty, + startedAt.ElapsedMilliseconds, + OutputLimitExceeded: true); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + KillProcessTree(process); + await WaitAfterKillAsync(process, postKillGrace); + return new( + true, + process.HasExited ? process.ExitCode : null, + string.Empty, + string.Empty, + startedAt.ElapsedMilliseconds, + TimedOut: true); + } + catch (OperationCanceledException) + { + KillProcessTree(process); + await WaitAfterKillAsync(process, postKillGrace); + throw; + } + } + + internal static ProcessStartInfo CreateStartInfo(ProcessInvocation invocation) + { + var startInfo = new ProcessStartInfo + { + FileName = invocation.FileName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = Environment.SystemDirectory, + }; + foreach (var argument in invocation.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + ApplyEnvironmentPolicy(startInfo.Environment, invocation); + return startInfo; + } + + internal static void ApplyEnvironmentPolicy( + IDictionary environment, + ProcessInvocation invocation) + { + foreach (var name in invocation.EnvironmentVariablesToRemove ?? []) + { + environment.Remove(name); + } + + foreach (var prefix in invocation.EnvironmentVariablePrefixesToRemove ?? []) + { + foreach (var name in environment.Keys + .Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToArray()) + { + environment.Remove(name); + } + } + + foreach (var item in invocation.EnvironmentVariablesToSet + ?? new Dictionary()) + { + environment[item.Key] = item.Value; + } + } + + private static async Task ReadBoundedAsync( + StreamReader reader, + int maximumCharacters, + CancellationToken cancellationToken) + { + var result = new StringBuilder(Math.Min(maximumCharacters, 16 * 1024)); + var buffer = new char[8192]; + while (true) + { + var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken); + if (read == 0) + { + return result.ToString(); + } + + if (result.Length + read > maximumCharacters) + { + throw new OutputLimitExceededException(); + } + + result.Append(buffer, 0, read); + } + } + + private static void KillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + catch (Win32Exception) + { + } + } + + private static Task WaitAfterKillAsync(Process process, TimeSpan grace) => + WaitWithGraceAsync(token => process.WaitForExitAsync(token), grace); + + internal static async Task WaitWithGraceAsync( + Func waitAsync, + TimeSpan grace) + { + using var cancellation = new CancellationTokenSource(); + Task waitTask; + try + { + waitTask = waitAsync(cancellation.Token); + } + catch (InvalidOperationException) + { + return true; + } + + var delayTask = Task.Delay(grace); + if (await Task.WhenAny(waitTask, delayTask) != waitTask) + { + cancellation.Cancel(); + _ = waitTask.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return false; + } + + try + { + await waitTask; + } + catch (OperationCanceledException) + { + } + catch (InvalidOperationException) + { + } + + return true; + } + + private sealed class OutputLimitExceededException : Exception; +} diff --git a/src/PortCVE/Vulnerabilities/IVulnerabilityScanner.cs b/src/PortCVE/Vulnerabilities/IVulnerabilityScanner.cs new file mode 100644 index 0000000..dda4af5 --- /dev/null +++ b/src/PortCVE/Vulnerabilities/IVulnerabilityScanner.cs @@ -0,0 +1,12 @@ +namespace PortCVE.Vulnerabilities; + +internal interface IVulnerabilityScanner +{ + Task ScanContainerImageAsync( + string imageId, + CancellationToken cancellationToken); + + Task ScanSbomAsync( + string path, + CancellationToken cancellationToken); +} diff --git a/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs b/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs new file mode 100644 index 0000000..e963909 --- /dev/null +++ b/src/PortCVE/Vulnerabilities/LocalPathPolicy.cs @@ -0,0 +1,230 @@ +using System.Runtime.InteropServices; + +namespace PortCVE.Vulnerabilities; + +internal sealed record LocalPathValidation( + bool IsValid, + string? FullPath, + string Code, + string Message); + +internal static class LocalPathPolicy +{ + private const uint InvalidFileAttributes = uint.MaxValue; + private const int ErrorFileNotFound = 2; + private const int ErrorPathNotFound = 3; + + public static LocalPathValidation ValidateLocalDirectoryPath(string path) + { + var resolved = Resolve(path, "local_path"); + if (!resolved.IsValid) + { + return resolved; + } + + var fullPath = resolved.FullPath!; + var root = Path.GetPathRoot(fullPath)!; + var inspection = InspectComponents(fullPath, root, allowMissingTail: true, "local_path"); + if (!inspection.IsValid) + { + return Invalid(inspection.Code!, inspection.Message!); + } + + if (inspection.FinalExists && !IsDirectory(inspection.FinalAttributes)) + { + return Invalid("local_path_invalid", "The local directory path names an existing non-directory."); + } + + return new(true, fullPath, "ok", "The directory path is local."); + } + + public static LocalPathValidation ValidateExistingLocalFile(string path) + { + return ValidateLocalFile(path, requireExists: true); + } + + public static LocalPathValidation ValidateOptionalLocalFile(string path) + { + return ValidateLocalFile(path, requireExists: false); + } + + private static LocalPathValidation ValidateLocalFile(string path, bool requireExists) + { + var resolved = Resolve(path, "sbom_path"); + if (!resolved.IsValid) + { + return resolved; + } + + var fullPath = resolved.FullPath!; + var root = Path.GetPathRoot(fullPath)!; + var inspection = InspectComponents(fullPath, root, allowMissingTail: !requireExists, "sbom_path"); + if (!inspection.IsValid) + { + return inspection.IsMissing && requireExists + ? Invalid("sbom_not_found", $"SBOM file not found: '{fullPath}'.") + : Invalid(inspection.Code!, inspection.Message!); + } + + if (inspection.FinalExists && IsDirectory(inspection.FinalAttributes)) + { + return Invalid("sbom_path_invalid", "The SBOM path must name a regular file, not a directory."); + } + + return new(true, fullPath, "ok", "The path is a local regular file."); + } + + internal static bool IsAllowedLocalDriveType(DriveType driveType) => driveType is + DriveType.Fixed or DriveType.Removable or DriveType.CDRom or DriveType.Ram; + + internal static bool IsReparsePoint(FileAttributes attributes) => + (attributes & FileAttributes.ReparsePoint) != 0; + + internal static bool TryGetAttributesWithoutFollowing( + string path, + out FileAttributes attributes, + out int errorCode) + { + var raw = GetFileAttributesW(path); + if (raw == InvalidFileAttributes) + { + attributes = default; + errorCode = Marshal.GetLastWin32Error(); + return false; + } + + attributes = (FileAttributes)raw; + errorCode = 0; + return true; + } + + private static LocalPathValidation Resolve(string path, string codePrefix) + { + string fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) + { + return Invalid($"{codePrefix}_invalid", $"The path is invalid: {exception.Message}"); + } + + if (fullPath.StartsWith("\\\\", StringComparison.Ordinal)) + { + return Invalid($"{codePrefix}_network", "The path must not be a UNC or device path."); + } + + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrWhiteSpace(root)) + { + return Invalid($"{codePrefix}_invalid", "The path has no local drive root."); + } + + try + { + var driveType = new DriveInfo(root).DriveType; + if (!IsAllowedLocalDriveType(driveType)) + { + return Invalid($"{codePrefix}_network", + $"The path must be on a local drive; drive type '{driveType}' is not allowed."); + } + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return Invalid($"{codePrefix}_invalid", $"The path drive could not be validated: {exception.Message}"); + } + + return new(true, fullPath, "ok", "The path has a permitted local drive root."); + } + + private static ComponentInspection InspectComponents( + string fullPath, + string root, + bool allowMissingTail, + string codePrefix) + { + var finalAttributes = default(FileAttributes); + var missingTail = false; + + foreach (var component in LexicalPathComponents(fullPath, root)) + { + if (missingTail) + { + continue; + } + + if (!TryGetAttributesWithoutFollowing(component, out var attributes, out var errorCode)) + { + if (errorCode is ErrorFileNotFound or ErrorPathNotFound) + { + if (!allowMissingTail) + { + return ComponentInspection.Missing(); + } + + missingTail = true; + continue; + } + + return ComponentInspection.Invalid( + $"{codePrefix}_invalid", + $"The local path component '{component}' could not be inspected (Windows error {errorCode})."); + } + + // GetFileAttributesW reports the attributes of the named reparse point itself. + // Inspecting from the root down means no child beneath it is ever resolved first. + if (IsReparsePoint(attributes)) + { + return ComponentInspection.Invalid( + $"{codePrefix}_reparse", + "The path must not traverse a symbolic link, junction, mount point, or cloud placeholder."); + } + + finalAttributes = attributes; + } + + return ComponentInspection.Valid(!missingTail, finalAttributes); + } + + private static IEnumerable LexicalPathComponents(string fullPath, string root) + { + var current = root; + yield return current; + var relative = Path.GetRelativePath(root, fullPath); + foreach (var component in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, component); + yield return current; + } + } + + private static bool IsDirectory(FileAttributes attributes) => + (attributes & FileAttributes.Directory) != 0; + + private static LocalPathValidation Invalid(string code, string message) => + new(false, null, code, message); + + private sealed record ComponentInspection( + bool IsValid, + bool IsMissing, + bool FinalExists, + FileAttributes FinalAttributes, + string? Code, + string? Message) + { + public static ComponentInspection Valid(bool finalExists, FileAttributes finalAttributes) => + new(true, false, finalExists, finalAttributes, null, null); + + public static ComponentInspection Missing() => + new(false, true, false, default, null, null); + + public static ComponentInspection Invalid(string code, string message) => + new(false, false, false, default, code, message); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFileAttributesW(string lpFileName); +} diff --git a/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs b/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs new file mode 100644 index 0000000..d1c4b13 --- /dev/null +++ b/src/PortCVE/Vulnerabilities/TrivyVulnerabilityScanner.cs @@ -0,0 +1,675 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace PortCVE.Vulnerabilities; + +internal sealed partial class TrivyVulnerabilityScanner : IVulnerabilityScanner +{ + private const int MaxScanOutputCharacters = 64 * 1024 * 1024; + private const int MaxErrorOutputCharacters = 1024 * 1024; + private static readonly TimeSpan DefaultScanTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MaximumDatabaseAge = TimeSpan.FromHours(72); + private static readonly string[] RemovedEnvironmentVariables = + [ + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CERT_PATH", + "DOCKER_TLS_VERIFY", + "CONTAINERD_ADDRESS", + "PODMAN_HOST", + "TRIVY_PASSWORD", + "TRIVY_USERNAME", + "TRIVY_REGISTRY_TOKEN", + "GITHUB_TOKEN", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + ]; + + private readonly string executable; + private readonly string cacheDirectory; + private readonly IProcessRunner processRunner; + private readonly TimeProvider timeProvider; + private readonly TimeSpan scanTimeout; + private readonly string tempRootDirectory; + private readonly Func cachePathValidator; + + public TrivyVulnerabilityScanner() + : this( + Environment.GetEnvironmentVariable("PORTCVE_TRIVY_PATH") ?? "trivy.exe", + ResolveCacheDirectory(), + new BoundedProcessRunner(), + TimeProvider.System, + DefaultScanTimeout, + ResolveTempRootDirectory()) + { + } + + internal TrivyVulnerabilityScanner( + string executable, + string cacheDirectory, + IProcessRunner processRunner, + TimeProvider timeProvider, + TimeSpan scanTimeout, + string? tempRootDirectory = null, + Func? cachePathValidator = null) + { + this.executable = 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; + } + + public Task ScanContainerImageAsync( + string imageId, + CancellationToken cancellationToken) + { + if (!IsImmutableImageId(imageId)) + { + return Task.FromResult(InvalidTarget("container_image_id_invalid", + "The correlated Docker image ID was not an immutable sha256 identifier.")); + } + + return ScanAsync( + ["image", "--image-src", "docker", .. CommonArguments(), imageId], + cancellationToken); + } + + public Task ScanSbomAsync( + string path, + CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path); + return ScanAsync( + ["sbom", .. CommonArguments(), fullPath], + cancellationToken); + } + + internal static bool IsImmutableImageId(string value) => ImmutableImageIdRegex().IsMatch(value); + + internal static IReadOnlyList ParseReport(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 128, + }); + var root = document.RootElement; + if (!TryGetInt32(root, "SchemaVersion", out var schemaVersion) || schemaVersion != 2) + { + throw new InvalidDataException("Unsupported Trivy JSON schema; PortCVE requires SchemaVersion 2."); + } + + var findings = new Dictionary(StringComparer.Ordinal); + if (!TryGetProperty(root, "Results", out var results) || results.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("Invalid Trivy JSON: SchemaVersion 2 requires a Results array."); + } + + foreach (var result in results.EnumerateArray()) + { + if (result.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Invalid Trivy JSON: every Results entry must be an object."); + } + + var target = GetString(result, "Target"); + var resultClass = GetString(result, "Class"); + var ecosystem = GetString(result, "Type"); + if (string.IsNullOrWhiteSpace(target) + || resultClass is not ("os-pkgs" or "lang-pkgs") + || string.IsNullOrWhiteSpace(ecosystem)) + { + throw new InvalidDataException( + "Invalid Trivy JSON: a package Results entry requires non-empty Target and Type fields and a supported package Class."); + } + + if (!TryGetProperty(result, "Vulnerabilities", out var vulnerabilities) + || vulnerabilities.ValueKind == JsonValueKind.Null) + { + // Trivy's SchemaVersion 2 result model uses omitempty for Vulnerabilities. + // A structurally valid package result may therefore omit it when no findings exist. + continue; + } + + if (vulnerabilities.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException( + "Invalid Trivy JSON: Vulnerabilities must be an array when present."); + } + + foreach (var vulnerability in vulnerabilities.EnumerateArray()) + { + if (vulnerability.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException( + "Invalid Trivy JSON: every Vulnerabilities entry must be an object."); + } + + var advisoryId = GetString(vulnerability, "VulnerabilityID"); + var packageName = GetString(vulnerability, "PkgName"); + var installedVersion = GetString(vulnerability, "InstalledVersion"); + if (string.IsNullOrWhiteSpace(advisoryId) + || string.IsNullOrWhiteSpace(packageName) + || string.IsNullOrWhiteSpace(installedVersion)) + { + throw new InvalidDataException( + "Invalid Trivy JSON: a vulnerability requires non-empty VulnerabilityID, PkgName, and InstalledVersion fields."); + } + + var fixedVersions = SplitFixedVersions(GetString(vulnerability, "FixedVersion")); + var aliases = GetStringArray(vulnerability, "VendorIDs") + .Where(alias => !alias.Equals(advisoryId, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var references = GetStringArray(vulnerability, "References") + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var severity = ParseSeverity(GetString(vulnerability, "Severity")); + var item = new ScannedVulnerability( + advisoryId, + aliases, + ecosystem, + packageName, + installedVersion, + fixedVersions, + severity, + GetString(vulnerability, "SeveritySource"), + GetString(vulnerability, "Title"), + GetString(vulnerability, "PrimaryURL"), + references); + findings[$"{advisoryId}\n{ecosystem}\n{packageName}\n{installedVersion}"] = item; + } + } + + return findings.Values + .OrderByDescending(static finding => finding.Severity) + .ThenBy(static finding => finding.AdvisoryId, StringComparer.Ordinal) + .ThenBy(static finding => finding.PackageName, StringComparer.Ordinal) + .ToArray(); + } + + private async Task ScanAsync( + IReadOnlyList scanArguments, + CancellationToken cancellationToken) + { + var cacheValidation = ValidateCachePaths(); + if (!cacheValidation.IsValid) + { + return Unavailable( + "trivy_cache_unsafe", + $"The configured Trivy cache directory is not a safe local directory: {cacheValidation.Message}", + 0); + } + + var temp = CreateInvocationTempDirectory(); + if (temp.Path is null) + { + return Unavailable("trivy_temp_unavailable", temp.ErrorMessage!, 0); + } + + try + { + return await ScanWithTempAsync(scanArguments, temp.Path, cancellationToken); + } + finally + { + TryDeleteInvocationTempDirectory(tempRootDirectory, temp.Path); + } + } + + private LocalPathValidation ValidateCachePaths() + { + var cacheValidation = cachePathValidator(cacheDirectory); + if (!cacheValidation.IsValid) + { + return cacheValidation; + } + + var databaseDirectory = Path.Combine(cacheDirectory, "db"); + var databaseDirectoryValidation = LocalPathPolicy.ValidateLocalDirectoryPath(databaseDirectory); + if (!databaseDirectoryValidation.IsValid) + { + return databaseDirectoryValidation; + } + + foreach (var knownDatabaseFile in new[] + { + Path.Combine(databaseDirectory, "metadata.json"), + Path.Combine(databaseDirectory, "trivy.db"), + }) + { + var fileValidation = LocalPathPolicy.ValidateOptionalLocalFile(knownDatabaseFile); + if (!fileValidation.IsValid) + { + return fileValidation; + } + } + + return cacheValidation; + } + + private async Task ScanWithTempAsync( + IReadOnlyList scanArguments, + string tempDirectory, + CancellationToken cancellationToken) + { + var versionResult = await processRunner.RunAsync( + new( + executable, + ["--version"], + TimeSpan.FromSeconds(10), + 64 * 1024, + 64 * 1024, + RemovedEnvironmentVariables, + SafeEnvironment(tempDirectory), + EnvironmentVariablePrefixesToRemove: ["TRIVY_"]), + cancellationToken); + if (!versionResult.Started) + { + return Unavailable( + "trivy_unavailable", + "Trivy was not found. Install Trivy and populate its local database before scanning.", + versionResult.DurationMs); + } + + if (versionResult.TimedOut || versionResult.ExitCode != 0) + { + return Unavailable( + "trivy_version_failed", + "Trivy could not report its version without error.", + versionResult.DurationMs); + } + + var engineVersion = ParseEngineVersion(versionResult.StandardOutput); + var cacheValidation = ValidateCachePaths(); + if (!cacheValidation.IsValid) + { + return UnsafeCache(cacheValidation, versionResult.DurationMs, engineVersion); + } + + var database = ReadDatabaseMetadata(); + if (database.ErrorCode is not null) + { + return Unavailable(database.ErrorCode, database.ErrorMessage!, versionResult.DurationMs, engineVersion); + } + + var databaseAge = timeProvider.GetUtcNow() - database.UpdatedAt!.Value; + var stale = databaseAge > MaximumDatabaseAge || databaseAge < TimeSpan.Zero; + var diagnostics = new List(); + if (stale) + { + diagnostics.Add(new( + "trivy", + VulnerabilityProviderStatus.Partial, + "vulnerability_db_stale", + $"The local Trivy vulnerability database is {Math.Max(0, databaseAge.TotalHours):0.#} hours old; the freshness limit is 72 hours.")); + } + + cacheValidation = ValidateCachePaths(); + if (!cacheValidation.IsValid) + { + return UnsafeCache(cacheValidation, versionResult.DurationMs, engineVersion); + } + + var scanResult = await processRunner.RunAsync( + new( + executable, + scanArguments, + scanTimeout, + MaxScanOutputCharacters, + MaxErrorOutputCharacters, + RemovedEnvironmentVariables, + SafeEnvironment(tempDirectory), + EnvironmentVariablePrefixesToRemove: ["TRIVY_"]), + cancellationToken); + var duration = versionResult.DurationMs + scanResult.DurationMs; + if (scanResult.TimedOut) + { + return Failed("trivy_timeout", "Trivy exceeded the five-minute scan limit.", duration, + engineVersion, database, diagnostics); + } + + if (scanResult.OutputLimitExceeded) + { + return Failed("trivy_output_too_large", "Trivy exceeded the bounded output limit.", duration, + engineVersion, database, diagnostics); + } + + if (!scanResult.Started || scanResult.ExitCode != 0) + { + var code = scanResult.StandardError.Contains("database", StringComparison.OrdinalIgnoreCase) + ? "vulnerability_db_unusable" + : "trivy_scan_failed"; + return Failed(code, "Trivy did not complete the offline vulnerability scan.", duration, + engineVersion, database, diagnostics); + } + + IReadOnlyList findings; + try + { + findings = ParseReport(scanResult.StandardOutput); + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + return Failed("trivy_json_invalid", exception.Message, duration, + engineVersion, database, diagnostics); + } + + var providerStatus = stale ? VulnerabilityProviderStatus.Partial : VulnerabilityProviderStatus.Complete; + var scanStatus = stale ? VulnerabilityScanStatus.Partial : VulnerabilityScanStatus.Complete; + var provider = new VulnerabilityProviderRun( + "trivy", + engineVersion, + database.UpdatedAt, + Math.Max(0, (long)databaseAge.TotalSeconds), + "offline", + providerStatus, + duration, + diagnostics); + return new( + scanStatus, + provider, + findings, + stale ? ["The vulnerability database is older than the configured freshness limit."] : []); + } + + private IReadOnlyList CommonArguments() => + [ + "--scanners", "vuln", + "--detection-priority", "precise", + "--format", "json", + "--exit-code", "0", + "--cache-dir", cacheDirectory, + "--skip-db-update", + "--skip-java-db-update", + "--skip-check-update", + "--skip-vex-repo-update", + "--offline-scan", + "--skip-version-check", + "--disable-telemetry", + "--no-progress", + ]; + + private IReadOnlyDictionary SafeEnvironment(string tempDirectory) => new Dictionary + { + ["TEMP"] = tempDirectory, + ["TMP"] = tempDirectory, + ["TRIVY_CACHE_DIR"] = cacheDirectory, + ["TRIVY_DISABLE_TELEMETRY"] = "true", + ["TRIVY_SKIP_VERSION_CHECK"] = "true", + ["TRIVY_SKIP_DB_UPDATE"] = "true", + ["TRIVY_SKIP_JAVA_DB_UPDATE"] = "true", + ["TRIVY_SKIP_CHECK_UPDATE"] = "true", + ["TRIVY_SKIP_VEX_REPO_UPDATE"] = "true", + ["TRIVY_OFFLINE_SCAN"] = "true", + }; + + private TempDirectoryResult CreateInvocationTempDirectory() + { + var rootValidation = LocalPathPolicy.ValidateLocalDirectoryPath(tempRootDirectory); + if (!rootValidation.IsValid) + { + return new(null, rootValidation.Message); + } + + try + { + Directory.CreateDirectory(tempRootDirectory); + rootValidation = LocalPathPolicy.ValidateLocalDirectoryPath(tempRootDirectory); + if (!rootValidation.IsValid) + { + return new(null, rootValidation.Message); + } + + var path = Path.Combine(tempRootDirectory, $"scan-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + var pathValidation = LocalPathPolicy.ValidateLocalDirectoryPath(path); + if (!pathValidation.IsValid) + { + TryDeleteInvocationTempDirectory(tempRootDirectory, path); + return new(null, pathValidation.Message); + } + + return new(path, null); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return new(null, $"PortCVE could not create a local scanner temp directory: {exception.Message}"); + } + } + + internal static bool TryDeleteInvocationTempDirectory(string root, string candidate) + { + try + { + var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullCandidate = Path.GetFullPath(candidate) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.Equals(Path.GetDirectoryName(fullCandidate), fullRoot, StringComparison.OrdinalIgnoreCase) + || !InvocationTempNameRegex().IsMatch(Path.GetFileName(fullCandidate))) + { + return false; + } + + if (Directory.Exists(fullCandidate)) + { + var isReparse = LocalPathPolicy.IsReparsePoint(File.GetAttributes(fullCandidate)); + Directory.Delete(fullCandidate, recursive: !isReparse); + } + + return true; + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private DatabaseMetadata ReadDatabaseMetadata() + { + var path = Path.Combine(cacheDirectory, "db", "metadata.json"); + var validation = LocalPathPolicy.ValidateExistingLocalFile(path); + if (!validation.IsValid) + { + return validation.Code == "sbom_not_found" + ? new(null, "vulnerability_db_missing", + $"No local Trivy vulnerability database was found at '{path}'. PortCVE never downloads it automatically.") + : new(null, "vulnerability_db_invalid", + $"The local Trivy vulnerability database metadata path is unsafe: {validation.Message}"); + } + + try + { + if (new FileInfo(path).Length > 1024 * 1024) + { + return new(null, "vulnerability_db_invalid", + "The local Trivy vulnerability database metadata exceeds the one-megabyte safety limit."); + } + + using var document = JsonDocument.Parse(File.ReadAllBytes(path)); + if (!TryGetProperty(document.RootElement, "UpdatedAt", out var value) + || value.ValueKind != JsonValueKind.String + || !value.TryGetDateTimeOffset(out var updatedAt)) + { + return new(null, "vulnerability_db_invalid", + "The local Trivy vulnerability database metadata has no valid UpdatedAt value."); + } + + return new(updatedAt, null, null); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return new(null, "vulnerability_db_invalid", + $"The local Trivy vulnerability database metadata could not be read: {exception.Message}"); + } + } + + private static VulnerabilityScanResult InvalidTarget(string code, string message) + { + var diagnostic = new VulnerabilityDiagnostic( + "trivy", + VulnerabilityProviderStatus.Failed, + code, + message); + return new( + VulnerabilityScanStatus.Failed, + new("trivy", null, null, null, "offline", VulnerabilityProviderStatus.Failed, 0, [diagnostic]), + [], + [message]); + } + + private static VulnerabilityScanResult Unavailable( + string code, + string message, + long durationMs, + string? engineVersion = null) + { + var diagnostic = new VulnerabilityDiagnostic( + "trivy", + VulnerabilityProviderStatus.Unavailable, + code, + message); + return new( + VulnerabilityScanStatus.Unavailable, + new("trivy", engineVersion, null, null, "offline", + VulnerabilityProviderStatus.Unavailable, durationMs, [diagnostic]), + [], + [message]); + } + + private static VulnerabilityScanResult UnsafeCache( + LocalPathValidation validation, + long durationMs, + string? engineVersion = null) => + Unavailable( + "trivy_cache_unsafe", + $"The configured Trivy cache directory is not a safe local directory: {validation.Message}", + durationMs, + engineVersion); + + private static VulnerabilityScanResult Failed( + string code, + string message, + long durationMs, + string? engineVersion, + DatabaseMetadata database, + List diagnostics) + { + var diagnostic = new VulnerabilityDiagnostic( + "trivy", + VulnerabilityProviderStatus.Failed, + code, + message); + diagnostics.Add(diagnostic); + return new( + VulnerabilityScanStatus.Failed, + new("trivy", engineVersion, database.UpdatedAt, null, "offline", + VulnerabilityProviderStatus.Failed, durationMs, diagnostics), + [], + [message]); + } + + private static string? ParseEngineVersion(string output) + { + var match = EngineVersionRegex().Match(output); + return match.Success ? match.Groups[1].Value : null; + } + + private static VulnerabilitySeverity ParseSeverity(string? severity) => severity?.ToUpperInvariant() switch + { + "LOW" => VulnerabilitySeverity.Low, + "MEDIUM" => VulnerabilitySeverity.Medium, + "HIGH" => VulnerabilitySeverity.High, + "CRITICAL" => VulnerabilitySeverity.Critical, + _ => VulnerabilitySeverity.Unknown, + }; + + private static IReadOnlyList SplitFixedVersions(string? value) => string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + private static string? GetString(JsonElement element, string name) => + TryGetProperty(element, name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static IReadOnlyList GetStringArray(JsonElement element, string name) + { + if (!TryGetProperty(element, name, out var value) || value.ValueKind != JsonValueKind.Array) + { + return []; + } + + return value.EnumerateArray() + .Where(static item => item.ValueKind == JsonValueKind.String) + .Select(static item => item.GetString()) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item!) + .ToArray(); + } + + private static bool TryGetInt32(JsonElement element, string name, out int value) + { + value = 0; + return TryGetProperty(element, name, out var property) + && property.ValueKind == JsonValueKind.Number + && property.TryGetInt32(out value); + } + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + if (element.ValueKind != JsonValueKind.Object) + { + value = default; + return false; + } + + if (element.TryGetProperty(name, out value)) + { + return true; + } + + var camel = char.ToLowerInvariant(name[0]) + name[1..]; + return element.TryGetProperty(camel, out value); + } + + private static string ResolveCacheDirectory() + { + var configured = Environment.GetEnvironmentVariable("PORTCVE_TRIVY_CACHE_DIR") + ?? Environment.GetEnvironmentVariable("TRIVY_CACHE_DIR"); + return !string.IsNullOrWhiteSpace(configured) + ? configured + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "trivy"); + } + + private static string ResolveTempRootDirectory() => Path.Combine( + Path.GetTempPath(), + "PortCVE", + "trivy"); + + private sealed record DatabaseMetadata( + DateTimeOffset? UpdatedAt, + string? ErrorCode, + string? ErrorMessage); + + private sealed record TempDirectoryResult(string? Path, string? ErrorMessage); + + [GeneratedRegex("^sha256:[0-9a-f]{64}$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)] + private static partial Regex ImmutableImageIdRegex(); + + [GeneratedRegex("^scan-[0-9a-f]{32}$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)] + private static partial Regex InvocationTempNameRegex(); + + [GeneratedRegex(@"(?im)^Version:\s*([^\s]+)", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)] + private static partial Regex EngineVersionRegex(); +} diff --git a/src/PortCVE/Vulnerabilities/VulnerabilityAssessmentService.cs b/src/PortCVE/Vulnerabilities/VulnerabilityAssessmentService.cs new file mode 100644 index 0000000..df5db92 --- /dev/null +++ b/src/PortCVE/Vulnerabilities/VulnerabilityAssessmentService.cs @@ -0,0 +1,244 @@ +using System.Security.Cryptography; +using System.Text; +using PortCVE.Domain; + +namespace PortCVE.Vulnerabilities; + +internal sealed class VulnerabilityAssessmentService(IVulnerabilityScanner scanner) +{ + public async Task AssessAsync( + string toolVersion, + string selector, + IReadOnlyList listeners, + string? sbomPath, + CancellationToken cancellationToken) + { + var subjects = new List(); + var providerRuns = new List(); + var findings = new List(); + var diagnostics = new List(); + var coveredListeners = new HashSet(ReferenceEqualityComparer.Instance); + var subjectNumber = 0; + var findingNumber = 0; + + var imageGroups = listeners + .SelectMany(listener => (listener.ContainerExposures ?? []) + .Where(static exposure => exposure.ImageId is not null + && TrivyVulnerabilityScanner.IsImmutableImageId(exposure.ImageId)) + .Select(exposure => new { Listener = listener, Exposure = exposure })) + .GroupBy(static item => item.Exposure.ImageId!, StringComparer.Ordinal) + .OrderBy(static group => group.Key, StringComparer.Ordinal) + .ToArray(); + + foreach (var imageGroup in imageGroups) + { + cancellationToken.ThrowIfCancellationRequested(); + var subjectId = $"subject-{++subjectNumber:000}"; + var listenerReferences = imageGroup + .Select(static item => ToReference(item.Listener)) + .DistinctBy(static item => item.Key, StringComparer.Ordinal) + .OrderBy(static item => item.Key, StringComparer.Ordinal) + .ToArray(); + foreach (var item in imageGroup) + { + coveredListeners.Add(item.Listener); + } + + var displayNames = imageGroup.Select(static item => item.Exposure.Image) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var result = await scanner.ScanContainerImageAsync(imageGroup.Key, cancellationToken); + providerRuns.Add(result.ProviderRun); + diagnostics.AddRange(result.ProviderRun.Diagnostics); + var limitations = result.Limitations + .Concat(imageGroup.SelectMany(static item => item.Exposure.Limitations)) + .Append("Docker subject linkage uses published-port tuple correlation; it does not prove guest-process ownership.") + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + subjects.Add(new( + subjectId, + VulnerabilitySubjectKind.ContainerImage, + displayNames.Length == 1 ? displayNames[0] : "Docker image", + imageGroup.Key, + imageGroup.Key["sha256:".Length..], + VulnerabilityIdentityConfidence.Exact, + listenerReferences, + result.Status, + limitations)); + AddFindings( + findings, + result.Findings, + subjectId, + "vendor_package_version", + VulnerabilityIdentityConfidence.Exact, + ref findingNumber); + } + + if (sbomPath is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + var fullPath = Path.GetFullPath(sbomPath); + var hashBefore = await HashFileAsync(fullPath, cancellationToken); + var subjectId = $"subject-{++subjectNumber:000}"; + var result = await scanner.ScanSbomAsync(fullPath, cancellationToken); + var hashAfter = await HashFileAsync(fullPath, cancellationToken); + var status = result.Status; + var resultFindings = result.Findings; + var limitations = result.Limitations.ToList(); + var providerRun = result.ProviderRun; + if (!hashBefore.Equals(hashAfter, StringComparison.Ordinal)) + { + status = result.Status is VulnerabilityScanStatus.Failed or VulnerabilityScanStatus.Unavailable + ? result.Status + : VulnerabilityScanStatus.Failed; + resultFindings = []; + limitations.Add("The SBOM changed during scanning, so its findings were discarded."); + var diagnostic = new VulnerabilityDiagnostic( + "trivy", + providerRun.Status is VulnerabilityProviderStatus.Failed or VulnerabilityProviderStatus.Unavailable + ? providerRun.Status + : VulnerabilityProviderStatus.Failed, + "sbom_changed_during_scan", + "The explicit SBOM changed during scanning; rerun against a stable file."); + providerRun = providerRun with + { + Status = diagnostic.Status, + Diagnostics = [.. providerRun.Diagnostics, diagnostic], + }; + } + + providerRuns.Add(providerRun); + diagnostics.AddRange(providerRun.Diagnostics); + var listenerReferences = listeners.Select(ToReference) + .OrderBy(static item => item.Key, StringComparer.Ordinal) + .ToArray(); + foreach (var listener in listeners) + { + coveredListeners.Add(listener); + } + + subjects.Add(new( + subjectId, + VulnerabilitySubjectKind.Sbom, + Path.GetFileName(fullPath), + fullPath, + hashBefore, + VulnerabilityIdentityConfidence.Declared, + listenerReferences, + status, + limitations)); + AddFindings( + findings, + resultFindings, + subjectId, + "sbom_package_version", + VulnerabilityIdentityConfidence.Declared, + ref findingNumber); + } + + 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}")); + } + + var completeSubjects = subjects.Count(static subject => + subject.ScanStatus == VulnerabilityScanStatus.Complete); + var isComplete = subjects.Count > 0 && completeSubjects == subjects.Count + && providerRuns.All(static run => run.Status == VulnerabilityProviderStatus.Complete); + var summary = new VulnerabilitySummary( + listeners.Count, + subjects.Count, + completeSubjects, + findings.Count, + findings.Count(static finding => finding.Severity == VulnerabilitySeverity.Critical), + findings.Count(static finding => finding.Severity == VulnerabilitySeverity.High), + isComplete); + return new( + VulnerabilityReport.CurrentSchemaVersion, + toolVersion, + DateTimeOffset.UtcNow, + selector, + subjects, + providerRuns, + findings, + summary, + diagnostics); + } + + private static void AddFindings( + List destination, + IReadOnlyList source, + string subjectId, + string matchMethod, + VulnerabilityIdentityConfidence confidence, + ref int findingNumber) + { + foreach (var finding in source) + { + destination.Add(new( + $"finding-{++findingNumber:0000}", + subjectId, + "known_advisory_match", + finding.AdvisoryId, + finding.Aliases, + new( + finding.Ecosystem, + finding.PackageName, + finding.InstalledVersion, + finding.FixedVersions), + matchMethod, + confidence, + finding.Severity, + finding.SeveritySource, + finding.FixedVersions.Count > 0 + ? VulnerabilityFixState.FixedVersionAvailable + : VulnerabilityFixState.NoFixedVersion, + "not_assessed", + "not_assessed", + finding.Title, + finding.PrimaryUrl, + finding.References)); + } + } + + private static VulnerabilityListenerReference ToReference(ListenerEvidence listener) => new( + listener.Key, + listener.Protocol, + listener.Family, + listener.BindScope, + listener.LocalPort); + + private static async Task HashFileAsync(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + return Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)).ToLowerInvariant(); + } +} diff --git a/src/PortCVE/Vulnerabilities/VulnerabilityModels.cs b/src/PortCVE/Vulnerabilities/VulnerabilityModels.cs new file mode 100644 index 0000000..d49b63c --- /dev/null +++ b/src/PortCVE/Vulnerabilities/VulnerabilityModels.cs @@ -0,0 +1,156 @@ +using System.Text.Json.Serialization; +using PortCVE.Domain; + +namespace PortCVE.Vulnerabilities; + +public enum VulnerabilitySeverity +{ + Unknown, + Low, + Medium, + High, + Critical, +} + +public enum VulnerabilitySubjectKind +{ + ContainerImage, + Sbom, + HostProcess, +} + +public enum VulnerabilityIdentityConfidence +{ + Exact, + Declared, + Unresolved, +} + +public enum VulnerabilityScanStatus +{ + Complete, + Partial, + Unavailable, + Failed, + NotSupported, +} + +public enum VulnerabilityProviderStatus +{ + Complete, + Partial, + Unavailable, + Failed, +} + +public enum VulnerabilityFixState +{ + FixedVersionAvailable, + NoFixedVersion, + Unknown, +} + +public sealed record VulnerabilityListenerReference( + string Key, + TransportProtocol Protocol, + IpFamily Family, + BindScope BindScope, + int LocalPort); + +public sealed record VulnerabilitySubject( + string SubjectId, + VulnerabilitySubjectKind Kind, + string DisplayName, + string? ArtifactReference, + string? ArtifactSha256, + VulnerabilityIdentityConfidence IdentityConfidence, + IReadOnlyList Listeners, + VulnerabilityScanStatus ScanStatus, + IReadOnlyList Limitations); + +public sealed record VulnerabilityDiagnostic( + string Provider, + VulnerabilityProviderStatus Status, + string Code, + string Message); + +public sealed record VulnerabilityProviderRun( + string Provider, + string? EngineVersion, + DateTimeOffset? DatabaseUpdatedAt, + long? DatabaseAgeSeconds, + string NetworkMode, + VulnerabilityProviderStatus Status, + long DurationMs, + IReadOnlyList Diagnostics); + +public sealed record VulnerabilityPackage( + string? Ecosystem, + string Name, + string InstalledVersion, + IReadOnlyList FixedVersions); + +public sealed record VulnerabilityFinding( + string FindingId, + string SubjectId, + string Type, + string AdvisoryId, + IReadOnlyList Aliases, + VulnerabilityPackage Package, + string MatchMethod, + VulnerabilityIdentityConfidence MatchConfidence, + VulnerabilitySeverity Severity, + string? SeveritySource, + VulnerabilityFixState FixState, + string Exploitability, + string NetworkReachability, + string? Title, + string? PrimaryUrl, + IReadOnlyList References); + +public sealed record VulnerabilitySummary( + int SelectedListenerCount, + int SubjectCount, + int CompleteSubjectCount, + int FindingCount, + int CriticalCount, + int HighCount, + bool IsComplete); + +public sealed record VulnerabilityReport( + int SchemaVersion, + string ToolVersion, + DateTimeOffset GeneratedAt, + string Selector, + IReadOnlyList Subjects, + IReadOnlyList ProviderRuns, + IReadOnlyList Findings, + VulnerabilitySummary Summary, + IReadOnlyList Diagnostics) +{ + public const int CurrentSchemaVersion = 1; + + [JsonIgnore] + public bool HasSuccessfulScan => Subjects.Any(static subject => + subject.ScanStatus is VulnerabilityScanStatus.Complete or VulnerabilityScanStatus.Partial); +} + +internal sealed record ScannedVulnerability( + string AdvisoryId, + IReadOnlyList Aliases, + string? Ecosystem, + string PackageName, + string InstalledVersion, + IReadOnlyList FixedVersions, + VulnerabilitySeverity Severity, + string? SeveritySource, + string? Title, + string? PrimaryUrl, + IReadOnlyList References); + +internal sealed record VulnerabilityScanResult( + VulnerabilityScanStatus Status, + VulnerabilityProviderRun ProviderRun, + IReadOnlyList Findings, + IReadOnlyList Limitations); + diff --git a/src/PortCVE/Vulnerabilities/VulnerabilityReportRedactor.cs b/src/PortCVE/Vulnerabilities/VulnerabilityReportRedactor.cs new file mode 100644 index 0000000..4b1e78f --- /dev/null +++ b/src/PortCVE/Vulnerabilities/VulnerabilityReportRedactor.cs @@ -0,0 +1,41 @@ +namespace PortCVE.Vulnerabilities; + +public static class VulnerabilityReportRedactor +{ + public static VulnerabilityReport Redact(VulnerabilityReport report) => report with + { + Subjects = report.Subjects.Select(RedactSubject).ToArray(), + ProviderRuns = report.ProviderRuns.Select(run => run with + { + Diagnostics = run.Diagnostics.Select(RedactDiagnostic).ToArray(), + }).ToArray(), + Diagnostics = report.Diagnostics.Select(RedactDiagnostic).ToArray(), + }; + + private static VulnerabilitySubject RedactSubject(VulnerabilitySubject subject) => subject with + { + DisplayName = subject.Kind switch + { + VulnerabilitySubjectKind.ContainerImage => "redacted container image", + VulnerabilitySubjectKind.Sbom => "explicit SBOM", + _ => subject.DisplayName, + }, + ArtifactReference = null, + ArtifactSha256 = null, + Listeners = subject.Listeners.Select(static listener => listener with + { + Key = $"{listener.Protocol.ToString().ToLowerInvariant()}/" + + $"{listener.Family.ToString().ToLowerInvariant()}/" + + $"{listener.BindScope.ToString().ToLowerInvariant()}/" + + listener.LocalPort, + }).ToArray(), + Limitations = subject.Limitations.Count == 0 + ? [] + : ["Details redacted; rerun with --include-private to inspect them locally."], + }; + + private static VulnerabilityDiagnostic RedactDiagnostic(VulnerabilityDiagnostic diagnostic) => diagnostic with + { + Message = "Diagnostic details redacted; rerun with --include-private to inspect them locally.", + }; +} diff --git a/src/PortCVE/Vulnerabilities/VulnerabilityTextRenderer.cs b/src/PortCVE/Vulnerabilities/VulnerabilityTextRenderer.cs new file mode 100644 index 0000000..3203dc3 --- /dev/null +++ b/src/PortCVE/Vulnerabilities/VulnerabilityTextRenderer.cs @@ -0,0 +1,62 @@ +namespace PortCVE.Vulnerabilities; + +public static class VulnerabilityTextRenderer +{ + public static void Render(VulnerabilityReport report, TextWriter output, TextWriter error) + { + output.WriteLine($"PortCVE known-vulnerability assessment: {report.Selector}"); + output.WriteLine( + $"Listeners {report.Summary.SelectedListenerCount} " + + $"Subjects {report.Summary.SubjectCount} " + + $"Findings {report.Summary.FindingCount} " + + $"Complete {(report.Summary.IsComplete ? "yes" : "no")}"); + output.WriteLine(); + + output.WriteLine("SUBJECTS"); + foreach (var subject in report.Subjects) + { + output.WriteLine( + $" {subject.SubjectId,-12} {subject.Kind.ToString().ToLowerInvariant(),-16} " + + $"{subject.ScanStatus.ToString().ToLowerInvariant(),-13} {subject.DisplayName}"); + } + + output.WriteLine(); + output.WriteLine("KNOWN ADVISORY MATCHES"); + if (report.Findings.Count == 0) + { + var databaseDate = report.ProviderRuns + .Where(static run => run.DatabaseUpdatedAt is not null) + .Select(static run => run.DatabaseUpdatedAt) + .Max(); + output.WriteLine(databaseDate is null + ? " None reported; evidence was incomplete, so this is not a clean result." + : $" No known matches in the local database dated {databaseDate:O}; this does not prove the software is safe."); + } + else + { + foreach (var finding in report.Findings) + { + var fixedIn = finding.Package.FixedVersions.Count == 0 + ? "no fixed version reported" + : $"fixed in {string.Join(", ", finding.Package.FixedVersions)}"; + output.WriteLine( + $" {finding.Severity.ToString().ToUpperInvariant(),-8} {finding.AdvisoryId,-20} " + + $"{finding.Package.Name} {finding.Package.InstalledVersion} ({fixedIn})"); + } + } + + output.WriteLine(); + output.WriteLine("Exploitability and network reachability were not assessed."); + if (report.Subjects.Any(static subject => subject.Kind == VulnerabilitySubjectKind.ContainerImage)) + { + output.WriteLine("Docker subject linkage is published-port tuple correlation, not proof of guest-process ownership."); + } + + foreach (var diagnostic in report.Diagnostics) + { + error.WriteLine( + $"{diagnostic.Status.ToString().ToLowerInvariant()}: " + + $"{diagnostic.Provider}/{diagnostic.Code}: {diagnostic.Message}"); + } + } +} diff --git a/src/BindWitness/packages.lock.json b/src/PortCVE/packages.lock.json similarity index 100% rename from src/BindWitness/packages.lock.json rename to src/PortCVE/packages.lock.json diff --git a/tests/BindWitness.Tests/BindScopeClassifierTests.cs b/tests/PortCVE.Tests/BindScopeClassifierTests.cs similarity index 94% rename from tests/BindWitness.Tests/BindScopeClassifierTests.cs rename to tests/PortCVE.Tests/BindScopeClassifierTests.cs index 379a173..38f29fd 100644 --- a/tests/BindWitness.Tests/BindScopeClassifierTests.cs +++ b/tests/PortCVE.Tests/BindScopeClassifierTests.cs @@ -1,8 +1,8 @@ using System.Net; -using BindWitness.Analysis; -using BindWitness.Domain; +using PortCVE.Analysis; +using PortCVE.Domain; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class BindScopeClassifierTests { diff --git a/tests/BindWitness.Tests/CliApplicationTests.cs b/tests/PortCVE.Tests/CliApplicationTests.cs similarity index 88% rename from tests/BindWitness.Tests/CliApplicationTests.cs rename to tests/PortCVE.Tests/CliApplicationTests.cs index 53b26e7..7dd1df3 100644 --- a/tests/BindWitness.Tests/CliApplicationTests.cs +++ b/tests/PortCVE.Tests/CliApplicationTests.cs @@ -1,19 +1,39 @@ -using BindWitness.Cli; -using BindWitness.Collection; -using BindWitness.Domain; -using BindWitness.Snapshots; +using PortCVE.Cli; +using PortCVE.Collection; +using PortCVE.Domain; +using PortCVE.Snapshots; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class CliApplicationTests { + [Fact] + public async Task Help_UsesPortCVEProductAndCommandNames() + { + 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.Help), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("PortCVE explains local ports", output.ToString(), StringComparison.Ordinal); + Assert.Contains("portcve 8080", output.ToString(), StringComparison.Ordinal); + } + [Fact] public async Task CheckStrict_IncompleteCollectorNeverPrintsPass() { var snapshot = IncompleteSnapshot(); var lockfileService = new LockfileService(); var baseline = lockfileService.Create(snapshot); - var path = Path.Combine(Path.GetTempPath(), $"bindwitness-check-{Guid.NewGuid():N}.lock.json"); + var path = Path.Combine(Path.GetTempPath(), $"portcve-check-{Guid.NewGuid():N}.lock.json"); try { await lockfileService.WriteAsync(path, baseline, overwrite: false, CancellationToken.None); @@ -98,7 +118,7 @@ public async Task Diff_ReportsRequiredContainerEvidenceRegression( var baseline = lockfileService.Create( baselineSnapshot, includesContainerEvidence: true); - var path = Path.Combine(Path.GetTempPath(), $"bindwitness-diff-{Guid.NewGuid():N}.lock.json"); + var path = Path.Combine(Path.GetTempPath(), $"portcve-diff-{Guid.NewGuid():N}.lock.json"); try { await lockfileService.WriteAsync(path, baseline, overwrite: false, CancellationToken.None); diff --git a/tests/BindWitness.Tests/CliParserTests.cs b/tests/PortCVE.Tests/CliParserTests.cs similarity index 57% rename from tests/BindWitness.Tests/CliParserTests.cs rename to tests/PortCVE.Tests/CliParserTests.cs index 96f0957..b214c61 100644 --- a/tests/BindWitness.Tests/CliParserTests.cs +++ b/tests/PortCVE.Tests/CliParserTests.cs @@ -1,7 +1,8 @@ -using BindWitness.Cli; -using BindWitness.Domain; +using PortCVE.Cli; +using PortCVE.Domain; +using PortCVE.Vulnerabilities; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class CliParserTests { @@ -87,4 +88,55 @@ public void Parse_PrivacyAndBaselineFlags_AreWired() Assert.True(result.IncludeUdp); Assert.True(result.AllowIncomplete); } + + [Fact] + public void Parse_ScanExactTcpSubjectAndPolicyFlags_AreWired() + { + var result = CliParser.Parse( + ["scan", "tcp:8080", "--sbom", "fixture.cdx.json", "--fail-on", "high", "--strict", "--json"]); + + Assert.Equal(CommandKind.Scan, result.Command); + Assert.Equal(8080, result.Port); + Assert.Equal(TransportProtocol.Tcp, result.Protocol); + Assert.Equal("fixture.cdx.json", result.SbomPath); + Assert.Equal(VulnerabilitySeverity.High, result.FailOn); + Assert.True(result.Strict); + Assert.True(result.Json); + Assert.False(result.IncludeFirewall); + } + + [Fact] + public void Parse_ScanAll_IsTcpOnly() + { + var result = CliParser.Parse(["scan", "--all"]); + + Assert.True(result.All); + Assert.Equal(TransportProtocol.Tcp, result.Protocol); + } + + [Theory] + [InlineData("scan")] + [InlineData("scan", "udp:53")] + [InlineData("scan", "tcp:443", "--all")] + [InlineData("scan", "--all", "--sbom", "fixture.json")] + [InlineData("scan", "tcp:443", "--firewall")] + [InlineData("scan", "tcp:443", "--force")] + [InlineData("scan", "tcp:443", "--allow-incomplete")] + [InlineData("list", "--fail-on", "high")] + public void Parse_InvalidScanCombinations_AreRejected(params string[] arguments) + { + Assert.Throws(() => CliParser.Parse(arguments)); + } + + [Theory] + [InlineData("low")] + [InlineData("medium")] + [InlineData("unknown")] + public void Parse_FailOnAcceptsOnlyHighOrCritical(string severity) + { + var error = Assert.Throws(() => + CliParser.Parse(["scan", "tcp:443", "--fail-on", severity])); + + Assert.Contains("high or critical", error.Message, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/BindWitness.Tests/DockerExposureCorrelatorTests.cs b/tests/PortCVE.Tests/DockerExposureCorrelatorTests.cs similarity index 98% rename from tests/BindWitness.Tests/DockerExposureCorrelatorTests.cs rename to tests/PortCVE.Tests/DockerExposureCorrelatorTests.cs index af4a595..4a73762 100644 --- a/tests/BindWitness.Tests/DockerExposureCorrelatorTests.cs +++ b/tests/PortCVE.Tests/DockerExposureCorrelatorTests.cs @@ -1,7 +1,7 @@ -using BindWitness.Collection; -using BindWitness.Domain; +using PortCVE.Collection; +using PortCVE.Domain; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class DockerExposureCorrelatorTests { diff --git a/tests/BindWitness.Tests/DockerPublishedPortParserTests.cs b/tests/PortCVE.Tests/DockerPublishedPortParserTests.cs similarity index 98% rename from tests/BindWitness.Tests/DockerPublishedPortParserTests.cs rename to tests/PortCVE.Tests/DockerPublishedPortParserTests.cs index 93bb139..92a06c6 100644 --- a/tests/BindWitness.Tests/DockerPublishedPortParserTests.cs +++ b/tests/PortCVE.Tests/DockerPublishedPortParserTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using BindWitness.Collection; +using PortCVE.Collection; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class DockerPublishedPortParserTests { diff --git a/tests/BindWitness.Tests/EndpointSnapshotMatcherTests.cs b/tests/PortCVE.Tests/EndpointSnapshotMatcherTests.cs similarity index 96% rename from tests/BindWitness.Tests/EndpointSnapshotMatcherTests.cs rename to tests/PortCVE.Tests/EndpointSnapshotMatcherTests.cs index de5ffa2..3ce4851 100644 --- a/tests/BindWitness.Tests/EndpointSnapshotMatcherTests.cs +++ b/tests/PortCVE.Tests/EndpointSnapshotMatcherTests.cs @@ -1,10 +1,10 @@ using System.Net; using System.Net.NetworkInformation; -using BindWitness.Collection; -using BindWitness.Domain; -using BindWitness.Platforms.Windows; +using PortCVE.Collection; +using PortCVE.Domain; +using PortCVE.Platforms.Windows; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class EndpointSnapshotMatcherTests { diff --git a/tests/PortCVE.Tests/Fixtures/trivy-report-v2.json b/tests/PortCVE.Tests/Fixtures/trivy-report-v2.json new file mode 100644 index 0000000..efce683 --- /dev/null +++ b/tests/PortCVE.Tests/Fixtures/trivy-report-v2.json @@ -0,0 +1,34 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ArtifactType": "container_image", + "Results": [ + { + "Target": "fixture (alpine 3.20)", + "Class": "os-pkgs", + "Type": "alpine", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2026-0002", + "VendorIDs": ["ALPINE-2026-2", "CVE-2026-0002"], + "PkgName": "libssl3", + "InstalledVersion": "3.3.0-r0", + "FixedVersion": "3.3.0-r1, 3.3.1-r0", + "SeveritySource": "nvd", + "PrimaryURL": "https://example.invalid/CVE-2026-0002", + "Title": "Fixture critical advisory", + "Severity": "CRITICAL", + "References": ["https://example.invalid/ref-2", "https://example.invalid/ref-1"] + }, + { + "VulnerabilityID": "CVE-2026-0001", + "PkgName": "busybox", + "InstalledVersion": "1.36.1-r28", + "FixedVersion": "", + "Severity": "HIGH", + "References": [] + } + ] + } + ] +} diff --git a/tests/BindWitness.Tests/ListenerDiffEngineTests.cs b/tests/PortCVE.Tests/ListenerDiffEngineTests.cs similarity index 97% rename from tests/BindWitness.Tests/ListenerDiffEngineTests.cs rename to tests/PortCVE.Tests/ListenerDiffEngineTests.cs index 10fa31c..0caf852 100644 --- a/tests/BindWitness.Tests/ListenerDiffEngineTests.cs +++ b/tests/PortCVE.Tests/ListenerDiffEngineTests.cs @@ -1,8 +1,8 @@ -using BindWitness.Analysis; -using BindWitness.Domain; -using BindWitness.Snapshots; +using PortCVE.Analysis; +using PortCVE.Domain; +using PortCVE.Snapshots; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class ListenerDiffEngineTests { diff --git a/tests/BindWitness.Tests/LockfileServiceTests.cs b/tests/PortCVE.Tests/LockfileServiceTests.cs similarity index 94% rename from tests/BindWitness.Tests/LockfileServiceTests.cs rename to tests/PortCVE.Tests/LockfileServiceTests.cs index d7ebbc9..399b885 100644 --- a/tests/BindWitness.Tests/LockfileServiceTests.cs +++ b/tests/PortCVE.Tests/LockfileServiceTests.cs @@ -1,8 +1,8 @@ -using BindWitness.Domain; -using BindWitness.Output; -using BindWitness.Snapshots; +using PortCVE.Domain; +using PortCVE.Output; +using PortCVE.Snapshots; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class LockfileServiceTests { @@ -16,6 +16,7 @@ public void Create_IsNormalizedSortedAndOmitsRuntimeIdentity() var result = new LockfileService().Create(snapshot); var json = JsonOutput.Serialize(result); + Assert.Equal("portcve/test", result.CreatedBy); Assert.Equal("tcp/ipv4/loopback/80", result.Listeners[0].Key); Assert.Equal("udp/ipv4/any/5353", result.Listeners[1].Key); Assert.DoesNotContain("created_at", json, StringComparison.Ordinal); @@ -41,7 +42,7 @@ public async Task WriteAsync_RejectsUnsupportedSelectorBeforeCreatingFile() { Selector = new(null, null, "server.exe", null), }; - var path = Path.Combine(Path.GetTempPath(), $"bindwitness-invalid-{Guid.NewGuid():N}.lock.json"); + var path = Path.Combine(Path.GetTempPath(), $"portcve-invalid-{Guid.NewGuid():N}.lock.json"); await Assert.ThrowsAsync(() => service.WriteAsync(path, lockfile, overwrite: false, CancellationToken.None)); diff --git a/tests/BindWitness.Tests/BindWitness.Tests.csproj b/tests/PortCVE.Tests/PortCVE.Tests.csproj similarity index 87% rename from tests/BindWitness.Tests/BindWitness.Tests.csproj rename to tests/PortCVE.Tests/PortCVE.Tests.csproj index 4fc4d2f..75ab3ea 100644 --- a/tests/BindWitness.Tests/BindWitness.Tests.csproj +++ b/tests/PortCVE.Tests/PortCVE.Tests.csproj @@ -1,11 +1,11 @@ - + net10.0 enable enable false - BindWitness.Tests + PortCVE.Tests @@ -30,7 +30,7 @@ - + diff --git a/tests/BindWitness.Tests/SchemaContractTests.cs b/tests/PortCVE.Tests/SchemaContractTests.cs similarity index 67% rename from tests/BindWitness.Tests/SchemaContractTests.cs rename to tests/PortCVE.Tests/SchemaContractTests.cs index a8a131d..f33e79e 100644 --- a/tests/BindWitness.Tests/SchemaContractTests.cs +++ b/tests/PortCVE.Tests/SchemaContractTests.cs @@ -1,9 +1,10 @@ using System.Text.Json; -using BindWitness.Domain; -using BindWitness.Output; -using BindWitness.Snapshots; +using PortCVE.Domain; +using PortCVE.Output; +using PortCVE.Snapshots; +using PortCVE.Vulnerabilities; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class SchemaContractTests { @@ -20,7 +21,7 @@ public void GeneratedLockfileShapeMatchesPublishedSchema() AssertSerializedShape( JsonOutput.Serialize(lockfile), - "bindwitness.lock.v1.schema.json"); + "portcve.lock.v1.schema.json"); } [Fact] @@ -30,10 +31,23 @@ public void PrivateAndRedactedSnapshotShapesMatchPublishedSchema() AssertSerializedShape( JsonOutput.Serialize(snapshot), - "bindwitness.snapshot.v1.schema.json"); + "portcve.snapshot.v1.schema.json"); AssertSerializedShape( JsonOutput.Serialize(SnapshotRedactor.Redact(snapshot)), - "bindwitness.snapshot.v1.schema.json"); + "portcve.snapshot.v1.schema.json"); + } + + [Fact] + public void PrivateAndRedactedVulnerabilityReportShapesMatchPublishedSchema() + { + var report = VulnerabilityReportFixture(); + + AssertSerializedShape( + JsonOutput.Serialize(report), + "portcve.vulnerability.v1.schema.json"); + AssertSerializedShape( + JsonOutput.Serialize(VulnerabilityReportRedactor.Redact(report)), + "portcve.vulnerability.v1.schema.json"); } private static void AssertSerializedShape(string json, string schemaFile) @@ -111,13 +125,13 @@ private static JsonElement ResolveReference(JsonElement schema, JsonElement sche private static string RepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "BindWitness.sln"))) + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PortCVE.sln"))) { directory = directory.Parent; } return directory?.FullName - ?? throw new DirectoryNotFoundException("Could not locate the BindWitness repository root."); + ?? throw new DirectoryNotFoundException("Could not locate the PortCVE repository root."); } private static SystemSnapshot Snapshot() @@ -202,4 +216,69 @@ private static SystemSnapshot Snapshot() [listener], []); } + + private static VulnerabilityReport VulnerabilityReportFixture() + { + var diagnostic = new VulnerabilityDiagnostic( + "trivy", + VulnerabilityProviderStatus.Partial, + "vulnerability_db_stale", + "Fixture database is stale."); + return new( + 1, + "test", + DateTimeOffset.UnixEpoch, + "tcp:8080", + [ + new( + "subject-001", + VulnerabilitySubjectKind.ContainerImage, + "example/web:1", + $"sha256:{new string('a', 64)}", + new string('a', 64), + VulnerabilityIdentityConfidence.Exact, + [ + new( + "tcp/ipv4/0.0.0.0/8080", + TransportProtocol.Tcp, + IpFamily.Ipv4, + BindScope.Wildcard, + 8080), + ], + VulnerabilityScanStatus.Partial, + ["Fixture limitation."]), + ], + [ + new( + "trivy", + "0.66.0", + DateTimeOffset.UnixEpoch, + 1, + "offline", + VulnerabilityProviderStatus.Partial, + 5, + [diagnostic]), + ], + [ + new( + "finding-0001", + "subject-001", + "known_advisory_match", + "CVE-2026-0001", + ["VENDOR-1"], + new("alpine", "busybox", "1.0", ["1.1"]), + "vendor_package_version", + VulnerabilityIdentityConfidence.Exact, + VulnerabilitySeverity.High, + "vendor", + VulnerabilityFixState.FixedVersionAvailable, + "not_assessed", + "not_assessed", + "Fixture advisory", + "https://example.invalid/CVE-2026-0001", + ["https://example.invalid/reference"]), + ], + new(1, 1, 0, 1, 0, 1, false), + [diagnostic]); + } } diff --git a/tests/BindWitness.Tests/SnapshotBuilderPolicyGuardTests.cs b/tests/PortCVE.Tests/SnapshotBuilderPolicyGuardTests.cs similarity index 96% rename from tests/BindWitness.Tests/SnapshotBuilderPolicyGuardTests.cs rename to tests/PortCVE.Tests/SnapshotBuilderPolicyGuardTests.cs index 69ecf40..bdc9504 100644 --- a/tests/BindWitness.Tests/SnapshotBuilderPolicyGuardTests.cs +++ b/tests/PortCVE.Tests/SnapshotBuilderPolicyGuardTests.cs @@ -1,7 +1,7 @@ -using BindWitness.Collection; -using BindWitness.Domain; +using PortCVE.Collection; +using PortCVE.Domain; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class SnapshotBuilderPolicyGuardTests { diff --git a/tests/BindWitness.Tests/SnapshotRedactorTests.cs b/tests/PortCVE.Tests/SnapshotRedactorTests.cs similarity index 97% rename from tests/BindWitness.Tests/SnapshotRedactorTests.cs rename to tests/PortCVE.Tests/SnapshotRedactorTests.cs index 780fb77..43a5676 100644 --- a/tests/BindWitness.Tests/SnapshotRedactorTests.cs +++ b/tests/PortCVE.Tests/SnapshotRedactorTests.cs @@ -1,7 +1,7 @@ -using BindWitness.Domain; -using BindWitness.Output; +using PortCVE.Domain; +using PortCVE.Output; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class SnapshotRedactorTests { diff --git a/tests/BindWitness.Tests/TextRendererTests.cs b/tests/PortCVE.Tests/TextRendererTests.cs similarity index 97% rename from tests/BindWitness.Tests/TextRendererTests.cs rename to tests/PortCVE.Tests/TextRendererTests.cs index 2aa7eee..ded5654 100644 --- a/tests/BindWitness.Tests/TextRendererTests.cs +++ b/tests/PortCVE.Tests/TextRendererTests.cs @@ -1,7 +1,7 @@ -using BindWitness.Domain; -using BindWitness.Output; +using PortCVE.Domain; +using PortCVE.Output; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class TextRendererTests { diff --git a/tests/PortCVE.Tests/TrivyVulnerabilityScannerTests.cs b/tests/PortCVE.Tests/TrivyVulnerabilityScannerTests.cs new file mode 100644 index 0000000..bada7d1 --- /dev/null +++ b/tests/PortCVE.Tests/TrivyVulnerabilityScannerTests.cs @@ -0,0 +1,648 @@ +using PortCVE.Vulnerabilities; + +namespace PortCVE.Tests; + +public sealed class TrivyVulnerabilityScannerTests +{ + private static readonly DateTimeOffset Now = new(2026, 8, 9, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void ProcessStartInfo_UsesArgumentListWithoutAShell() + { + var invocation = new ProcessInvocation( + "trivy.exe", + ["image", "sha256:value & unexpected.exe"], + TimeSpan.FromSeconds(1), + 10, + 10); + + var startInfo = BoundedProcessRunner.CreateStartInfo(invocation); + + Assert.False(startInfo.UseShellExecute); + Assert.True(startInfo.RedirectStandardOutput); + Assert.True(startInfo.RedirectStandardError); + Assert.Empty(startInfo.Arguments); + Assert.Equal(invocation.Arguments, startInfo.ArgumentList.ToArray()); + } + + [Fact] + public void EnvironmentPolicy_RemovesEveryTrivyVariableCaseInsensitivelyThenSetsAllowlist() + { + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TrIvY_SeRvEr"] = "https://remote.invalid", + ["TRIVY_CONFIG"] = "\\\\server\\share\\trivy.yaml", + ["trivy_ignore_file"] = "ignore-all", + ["TRIVY_SEVERITY"] = "UNKNOWN", + ["TRIVY_CACHE_DIR"] = "unsafe-cache", + ["PATH"] = "preserved", + }; + var invocation = new ProcessInvocation( + "trivy.exe", + ["--version"], + TimeSpan.FromSeconds(1), + 10, + 10, + EnvironmentVariablesToSet: new Dictionary + { + ["TRIVY_CACHE_DIR"] = "safe-cache", + ["TRIVY_OFFLINE_SCAN"] = "true", + }, + EnvironmentVariablePrefixesToRemove: ["TRIVY_"]); + + BoundedProcessRunner.ApplyEnvironmentPolicy(environment, invocation); + + Assert.Equal("preserved", environment["PATH"]); + Assert.Equal("safe-cache", environment["TRIVY_CACHE_DIR"]); + Assert.Equal("true", environment["TRIVY_OFFLINE_SCAN"]); + Assert.DoesNotContain(environment.Keys, key => + key.StartsWith("TRIVY_", StringComparison.OrdinalIgnoreCase) + && key is not "TRIVY_CACHE_DIR" and not "TRIVY_OFFLINE_SCAN"); + } + + [Fact] + public async Task PostKillWait_ReturnsAtGraceEvenWhenWaitIgnoresCancellation() + { + var never = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var completed = await BoundedProcessRunner.WaitWithGraceAsync( + _ => never.Task, + TimeSpan.FromMilliseconds(25)); + + stopwatch.Stop(); + Assert.False(completed); + Assert.InRange(stopwatch.Elapsed, TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task ImageScan_UsesImmutableIdAndOfflineArguments() + { + var cache = CreateCache(Now.AddHours(-1)); + try + { + var runner = new RecordingProcessRunner( + 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", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + var imageId = $"sha256:{new string('a', 64)}"; + + var result = await scanner.ScanContainerImageAsync(imageId, CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Complete, result.Status); + Assert.Equal(VulnerabilityProviderStatus.Complete, result.ProviderRun.Status); + Assert.Equal("0.66.0", result.ProviderRun.EngineVersion); + Assert.Equal(2, result.Findings.Count); + Assert.Collection( + runner.Invocations, + version => Assert.Equal(["--version"], version.Arguments), + scan => + { + Assert.Equal("C:\\Tools\\trivy.exe", scan.FileName); + Assert.Equal("image", scan.Arguments[0]); + Assert.Equal(imageId, scan.Arguments[^1]); + AssertFlag(scan.Arguments, "--image-src", "docker"); + AssertFlag(scan.Arguments, "--scanners", "vuln"); + AssertFlag(scan.Arguments, "--detection-priority", "precise"); + AssertFlag(scan.Arguments, "--format", "json"); + AssertFlag(scan.Arguments, "--cache-dir", Path.GetFullPath(cache)); + Assert.Contains("--skip-db-update", scan.Arguments); + Assert.Contains("--skip-java-db-update", scan.Arguments); + Assert.Contains("--skip-check-update", scan.Arguments); + Assert.Contains("--skip-vex-repo-update", scan.Arguments); + Assert.Contains("--offline-scan", scan.Arguments); + Assert.Contains("--skip-version-check", scan.Arguments); + Assert.Contains("--disable-telemetry", scan.Arguments); + Assert.Contains("TRIVY_REGISTRY_TOKEN", scan.EnvironmentVariablesToRemove!); + Assert.Contains("HTTPS_PROXY", scan.EnvironmentVariablesToRemove!); + Assert.Equal("true", scan.EnvironmentVariablesToSet!["TRIVY_OFFLINE_SCAN"]); + Assert.Equal(["TRIVY_"], scan.EnvironmentVariablePrefixesToRemove); + }); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + + [Fact] + public async Task InvalidImageId_IsRejectedBeforeLaunchingTrivy() + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + Path.GetTempPath(), + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync("repository:latest", CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Failed, result.Status); + Assert.Equal("container_image_id_invalid", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task MissingDatabase_ReturnsClearUnavailableDiagnosticWithoutScanning() + { + var cache = Path.Combine(Path.GetTempPath(), $"portcve-trivy-{Guid.NewGuid():N}"); + Directory.CreateDirectory(cache); + try + { + var runner = new RecordingProcessRunner( + new ProcessExecutionResult(true, 0, "Version: 0.66.0\n", string.Empty, 2)); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('b', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("vulnerability_db_missing", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Contains("never downloads", result.ProviderRun.Diagnostics[0].Message, StringComparison.Ordinal); + Assert.Single(runner.Invocations); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + + [Fact] + public async Task StaleDatabase_ScansButMarksEvidencePartial() + { + var cache = CreateCache(Now.AddHours(-73)); + try + { + var runner = new RecordingProcessRunner( + 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", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('c', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Partial, result.Status); + Assert.Equal(VulnerabilityProviderStatus.Partial, result.ProviderRun.Status); + Assert.Equal("vulnerability_db_stale", Assert.Single(result.ProviderRun.Diagnostics).Code); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + + [Fact] + public async Task ScannerTempDirectory_ExistsForBothProcessesAndIsDeletedAfterSuccess() + { + var cache = CreateCache(Now.AddHours(-1)); + var tempRoot = Path.Combine(cache, "scanner-temp"); + try + { + var runner = new TempObservingProcessRunner( + 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", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30), + tempRoot); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('e', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Complete, result.Status); + Assert.Equal(2, runner.TempDirectories.Count); + Assert.All(runner.ExistedDuringInvocation, Assert.True); + var directory = Assert.Single(runner.TempDirectories.Distinct(StringComparer.OrdinalIgnoreCase)); + Assert.False(Directory.Exists(directory)); + Assert.StartsWith(Path.GetFullPath(tempRoot), directory, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + + [Theory] + [InlineData(true, false, "trivy_timeout")] + [InlineData(false, true, "trivy_output_too_large")] + public async Task ScannerTempDirectory_IsDeletedAfterBoundedProcessFailure( + bool timedOut, + bool outputLimitExceeded, + string expectedCode) + { + var cache = CreateCache(Now.AddHours(-1)); + var tempRoot = Path.Combine(cache, "scanner-temp"); + try + { + var runner = new TempObservingProcessRunner( + new(true, 0, "Version: 0.73.0\n", string.Empty, 2), + new( + true, + null, + string.Empty, + string.Empty, + 7, + TimedOut: timedOut, + OutputLimitExceeded: outputLimitExceeded)); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30), + tempRoot); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('f', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Failed, result.Status); + Assert.Equal(expectedCode, result.ProviderRun.Diagnostics[^1].Code); + var directory = Assert.Single(runner.TempDirectories.Distinct(StringComparer.OrdinalIgnoreCase)); + Assert.False(Directory.Exists(directory)); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + + [Fact] + public async Task ScannerTempDirectory_IsDeletedAfterCancellation() + { + var cache = CreateCache(Now.AddHours(-1)); + var tempRoot = Path.Combine(cache, "scanner-temp"); + try + { + var runner = new CancellingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30), + tempRoot); + + await Assert.ThrowsAsync(() => + scanner.ScanContainerImageAsync( + $"sha256:{new string('1', 64)}", + new CancellationToken(canceled: true))); + + Assert.NotNull(runner.TempDirectory); + Assert.True(runner.ExistedDuringInvocation); + Assert.False(Directory.Exists(runner.TempDirectory)); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + + [Fact] + public void TempCleanupGuard_RefusesSiblingDirectory() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-cleanup-{Guid.NewGuid():N}"); + var root = Path.Combine(parent, "root"); + var sibling = Path.Combine(parent, $"scan-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + Directory.CreateDirectory(sibling); + try + { + var deleted = TrivyVulnerabilityScanner.TryDeleteInvocationTempDirectory(root, sibling); + + Assert.False(deleted); + Assert.True(Directory.Exists(sibling)); + } + finally + { + Directory.Delete(parent, recursive: true); + } + } + + [Fact] + public async Task MissingResultsArray_IsReportedAsInvalidScannerJson() + { + var cache = CreateCache(Now.AddHours(-1)); + try + { + var runner = new RecordingProcessRunner( + 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", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('d', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Failed, result.Status); + Assert.Equal("trivy_json_invalid", Assert.Single(result.ProviderRun.Diagnostics).Code); + } + finally + { + Directory.Delete(cache, recursive: true); + } + } + + [Theory] + [InlineData("{\"SchemaVersion\":2,\"Results\":[1]}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":[{\"Target\":\"x\",\"Class\":\"os-pkgs\",\"Type\":\"alpine\",\"Vulnerabilities\":{}}]}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":[{\"Target\":\"x\",\"Class\":\"os-pkgs\",\"Type\":\"alpine\",\"Vulnerabilities\":[null]}]}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":[{\"Target\":\"x\",\"Class\":\"os-pkgs\",\"Type\":\"alpine\",\"Vulnerabilities\":[{\"VulnerabilityID\":\"CVE-1\"}]}]}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":[{}]}")] + public void ParseReport_FailsClosedOnMalformedEvidence(string json) + { + Assert.Throws(() => TrivyVulnerabilityScanner.ParseReport(json)); + } + + [Theory] + [InlineData("{\"SchemaVersion\":2,\"Results\":[]}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":[{\"Target\":\"clean\",\"Class\":\"os-pkgs\",\"Type\":\"alpine\"}]}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":[{\"Target\":\"clean\",\"Class\":\"lang-pkgs\",\"Type\":\"gobinary\",\"Vulnerabilities\":null}]}")] + public void ParseReport_AcceptsEmptyOrOmittedFindingsFromValidPackageResults(string json) + { + Assert.Empty(TrivyVulnerabilityScanner.ParseReport(json)); + } + + [Fact] + public async Task UnsafeUncCache_IsRejectedBeforeAnyProcessInvocation() + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + "\\\\127.0.0.1\\portcve-never\\cache", + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('2', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("trivy_cache_unsafe", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task ReparseCache_IsRejectedBeforeAnyProcessInvocation() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-cache-link-{Guid.NewGuid():N}"); + var target = Path.Combine(parent, "target"); + var link = Path.Combine(parent, "link"); + Directory.CreateDirectory(target); + Directory.CreateSymbolicLink(link, target); + try + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + link, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('3', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("trivy_cache_unsafe", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + finally + { + Directory.Delete(link); + Directory.Delete(parent, recursive: true); + } + } + + [Fact] + public async Task ReparseDatabaseChild_IsRejectedBeforeAnyProcessInvocation() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-db-link-{Guid.NewGuid():N}"); + var cache = Path.Combine(parent, "cache"); + var targetDatabase = Path.Combine(parent, "target-db"); + var databaseLink = Path.Combine(cache, "db"); + Directory.CreateDirectory(cache); + Directory.CreateDirectory(targetDatabase); + Directory.CreateSymbolicLink(databaseLink, targetDatabase); + try + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('4', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("trivy_cache_unsafe", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + finally + { + Directory.Delete(databaseLink); + Directory.Delete(parent, recursive: true); + } + } + + [Fact] + public async Task ReparseMetadataFile_IsRejectedBeforeAnyProcessInvocation() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-metadata-link-{Guid.NewGuid():N}"); + var cache = Path.Combine(parent, "cache"); + var database = Path.Combine(cache, "db"); + var targetMetadata = Path.Combine(parent, "target-metadata.json"); + var metadataLink = Path.Combine(database, "metadata.json"); + Directory.CreateDirectory(database); + File.WriteAllText(targetMetadata, $"{{\"UpdatedAt\":\"{Now:O}\"}}"); + File.CreateSymbolicLink(metadataLink, targetMetadata); + try + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + cache, + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('6', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Equal("trivy_cache_unsafe", Assert.Single(result.ProviderRun.Diagnostics).Code); + Assert.Empty(runner.Invocations); + } + finally + { + File.Delete(metadataLink); + Directory.Delete(parent, recursive: true); + } + } + + [Theory] + [InlineData("mapped Network drive")] + [InlineData("Unknown drive type")] + public async Task CachePolicyRejection_IsUnavailableWithoutProcessInvocation(string reason) + { + var runner = new RecordingProcessRunner(); + var scanner = new TrivyVulnerabilityScanner( + "trivy.exe", + Path.GetTempPath(), + runner, + new FixedTimeProvider(Now), + TimeSpan.FromSeconds(30), + cachePathValidator: _ => new(false, null, "local_path_network", reason)); + + var result = await scanner.ScanContainerImageAsync( + $"sha256:{new string('5', 64)}", + CancellationToken.None); + + Assert.Equal(VulnerabilityScanStatus.Unavailable, result.Status); + Assert.Empty(runner.Invocations); + } + + private static void AssertFlag(IReadOnlyList arguments, string name, string value) + { + var index = arguments.IndexOf(name); + Assert.True(index >= 0 && index + 1 < arguments.Count, $"Missing flag {name}."); + Assert.Equal(value, arguments[index + 1]); + } + + private static string CreateCache(DateTimeOffset updatedAt) + { + var cache = Path.Combine(Path.GetTempPath(), $"portcve-trivy-{Guid.NewGuid():N}"); + var db = Path.Combine(cache, "db"); + Directory.CreateDirectory(db); + File.WriteAllText( + Path.Combine(db, "metadata.json"), + $"{{\"UpdatedAt\":\"{updatedAt:O}\"}}"); + return cache; + } + + private static string FixtureJson() => File.ReadAllText(Path.Combine( + RepositoryRoot(), + "tests", + "PortCVE.Tests", + "Fixtures", + "trivy-report-v2.json")); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PortCVE.sln"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new DirectoryNotFoundException("Could not locate the PortCVE repository root."); + } + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private sealed class RecordingProcessRunner(params ProcessExecutionResult[] results) : IProcessRunner + { + private readonly Queue results = new(results); + + public List Invocations { get; } = []; + + public Task RunAsync( + ProcessInvocation invocation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Invocations.Add(invocation); + return Task.FromResult(results.Dequeue()); + } + } + + private sealed class TempObservingProcessRunner(params ProcessExecutionResult[] results) : IProcessRunner + { + private readonly Queue results = new(results); + + public List TempDirectories { get; } = []; + + public List ExistedDuringInvocation { get; } = []; + + public Task RunAsync( + ProcessInvocation invocation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var temp = invocation.EnvironmentVariablesToSet!["TEMP"]!; + TempDirectories.Add(temp); + ExistedDuringInvocation.Add(Directory.Exists(temp)); + return Task.FromResult(results.Dequeue()); + } + } + + private sealed class CancellingProcessRunner : IProcessRunner + { + public string? TempDirectory { get; private set; } + + public bool ExistedDuringInvocation { get; private set; } + + public Task RunAsync( + ProcessInvocation invocation, + CancellationToken cancellationToken) + { + TempDirectory = invocation.EnvironmentVariablesToSet!["TEMP"]!; + ExistedDuringInvocation = Directory.Exists(TempDirectory); + throw new OperationCanceledException(cancellationToken); + } + } +} + +internal static class IReadOnlyListTestExtensions +{ + public static int IndexOf(this IReadOnlyList items, string value) + { + for (var index = 0; index < items.Count; index++) + { + if (items[index].Equals(value, StringComparison.Ordinal)) + { + return index; + } + } + + return -1; + } +} diff --git a/tests/PortCVE.Tests/VulnerabilityAssessmentTests.cs b/tests/PortCVE.Tests/VulnerabilityAssessmentTests.cs new file mode 100644 index 0000000..99a00b0 --- /dev/null +++ b/tests/PortCVE.Tests/VulnerabilityAssessmentTests.cs @@ -0,0 +1,291 @@ +using PortCVE.Domain; +using PortCVE.Output; +using PortCVE.Vulnerabilities; + +namespace PortCVE.Tests; + +public sealed class VulnerabilityAssessmentTests +{ + [Fact] + public async Task Assessment_DeduplicatesExactDockerImageIdsAndDoesNotGuessNativeProducts() + { + var imageId = $"sha256:{new string('a', 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(9000, null, null, null), + }; + + var report = await new VulnerabilityAssessmentService(scanner).AssessAsync( + "test", + "all_tcp_listeners", + listeners, + null, + CancellationToken.None); + + Assert.Equal([imageId], scanner.ImageIds); + Assert.Equal(3, report.Summary.SelectedListenerCount); + Assert.Equal(2, report.Summary.SubjectCount); + var imageSubject = Assert.Single(report.Subjects, subject => + subject.Kind == VulnerabilitySubjectKind.ContainerImage); + 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.False(report.Summary.IsComplete); + } + + [Fact] + public async Task Assessment_ExplicitSbomUsesDeclaredIdentityAndStableFileHash() + { + var path = Path.Combine(Path.GetTempPath(), $"portcve-sbom-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(path, "{\"bomFormat\":\"CycloneDX\"}"); + try + { + var scanner = new FixedScanner(CompleteResult()); + + var report = await new VulnerabilityAssessmentService(scanner).AssessAsync( + "test", + "tcp:8080", + [Listener(8080, null, null, null)], + path, + CancellationToken.None); + + Assert.Equal([Path.GetFullPath(path)], scanner.SbomPaths); + var subject = Assert.Single(report.Subjects); + Assert.Equal(VulnerabilitySubjectKind.Sbom, subject.Kind); + Assert.Equal(VulnerabilityIdentityConfidence.Declared, subject.IdentityConfidence); + Assert.Matches("^[0-9a-f]{64}$", subject.ArtifactSha256!); + Assert.Equal("sbom_package_version", Assert.Single(report.Findings).MatchMethod); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task RedactedJson_RemovesLocalPathsContainerNamesAndExactAddresses() + { + const string privatePath = "C:\\Users\\PrivateUser\\secret.cdx.json"; + var imageId = $"sha256:{new string('a', 64)}"; + var scanner = new FixedScanner(CompleteResult()); + var report = await new VulnerabilityAssessmentService(scanner).AssessAsync( + "test", + "tcp:8080", + [Listener(8080, imageId, "customer-secret-container", "registry.example/customer/private:1")], + null, + CancellationToken.None); + report = report with + { + Diagnostics = + [ + new( + "trivy", + VulnerabilityProviderStatus.Partial, + "private_fixture", + privatePath), + ], + }; + + var redactedJson = JsonOutput.Serialize(VulnerabilityReportRedactor.Redact(report)); + var privateJson = JsonOutput.Serialize(report); + + Assert.DoesNotContain("customer-secret", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("registry.example", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("PrivateUser", redactedJson, StringComparison.Ordinal); + Assert.DoesNotContain("0.0.0.0", redactedJson, StringComparison.Ordinal); + Assert.Contains("container_image", redactedJson, StringComparison.Ordinal); + Assert.Contains("registry.example/customer/private:1", privateJson, StringComparison.Ordinal); + Assert.Contains("PrivateUser", privateJson, StringComparison.Ordinal); + } + + [Fact] + public async Task ChangedSbom_DiscardsFindingsAndCannotBecomeSuccessfulPartialEvidence() + { + var path = Path.Combine(Path.GetTempPath(), $"portcve-changing-sbom-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(path, "{\"bomFormat\":\"CycloneDX\"}"); + try + { + var report = await new VulnerabilityAssessmentService( + new MutatingScanner(CompleteResult())).AssessAsync( + "test", + "tcp:8080", + [Listener(8080, null, null, null)], + path, + CancellationToken.None); + + var subject = Assert.Single(report.Subjects); + Assert.Equal(VulnerabilityScanStatus.Failed, subject.ScanStatus); + Assert.Equal(VulnerabilityProviderStatus.Failed, Assert.Single(report.ProviderRuns).Status); + Assert.Empty(report.Findings); + Assert.False(report.HasSuccessfulScan); + Assert.Contains(report.Diagnostics, diagnostic => + diagnostic.Code == "sbom_changed_during_scan"); + } + finally + { + File.Delete(path); + } + } + + [Theory] + [InlineData(VulnerabilityScanStatus.Unavailable, VulnerabilityProviderStatus.Unavailable)] + [InlineData(VulnerabilityScanStatus.Failed, VulnerabilityProviderStatus.Failed)] + public async Task ChangedSbom_DoesNotUpgradeFailedOrUnavailableScan( + VulnerabilityScanStatus scanStatus, + VulnerabilityProviderStatus providerStatus) + { + var path = Path.Combine(Path.GetTempPath(), $"portcve-changing-sbom-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(path, "{\"bomFormat\":\"CycloneDX\"}"); + try + { + var original = new VulnerabilityScanResult( + scanStatus, + new("trivy", "0.73.0", null, null, "offline", providerStatus, 1, []), + [], + ["Original scan did not produce evidence."]); + + var report = await new VulnerabilityAssessmentService( + new MutatingScanner(original)).AssessAsync( + "test", + "tcp:8080", + [Listener(8080, null, null, null)], + path, + CancellationToken.None); + + Assert.Equal(scanStatus, Assert.Single(report.Subjects).ScanStatus); + Assert.Equal(providerStatus, Assert.Single(report.ProviderRuns).Status); + Assert.False(report.HasSuccessfulScan); + } + finally + { + File.Delete(path); + } + } + + internal static ListenerEvidence Listener( + int port, + string? imageId, + string? containerName, + string? image) + { + IReadOnlyList exposures = imageId is null + ? [] + : + [ + new( + "docker", + $"container-{port}", + containerName!, + image!, + imageId, + "0.0.0.0", + port, + 80, + TransportProtocol.Tcp, + Confidence.High, + []), + ]; + return new( + $"tcp/ipv4/0.0.0.0/{port}", + TransportProtocol.Tcp, + IpFamily.Ipv4, + "0.0.0.0", + port, + "LISTEN", + BindScope.Wildcard, + "all interfaces", + new( + port, + DateTimeOffset.UnixEpoch, + "server.exe", + "C:\\Private\\server.exe", + null, + null, + null, + "S-1-5-18", + null, + [], + false, + true, + []), + [], + HostPolicyEvidence.NotEvaluated, + [], + [], + exposures); + } + + internal static VulnerabilityScanResult CompleteResult( + VulnerabilitySeverity severity = VulnerabilitySeverity.High) => new( + VulnerabilityScanStatus.Complete, + new( + "trivy", + "0.66.0", + DateTimeOffset.UnixEpoch, + 0, + "offline", + VulnerabilityProviderStatus.Complete, + 1, + []), + [ + new( + "CVE-2026-0001", + [], + "alpine", + "busybox", + "1.0", + ["1.1"], + severity, + "vendor", + "Fixture advisory", + "https://example.invalid/CVE-2026-0001", + []), + ], + []); + + internal sealed class FixedScanner(VulnerabilityScanResult result) : IVulnerabilityScanner + { + public List ImageIds { get; } = []; + + public List SbomPaths { get; } = []; + + public Task ScanContainerImageAsync( + string imageId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ImageIds.Add(imageId); + return Task.FromResult(result); + } + + public Task ScanSbomAsync( + string path, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + SbomPaths.Add(path); + return Task.FromResult(result); + } + } + + private sealed class MutatingScanner(VulnerabilityScanResult result) : IVulnerabilityScanner + { + public Task ScanContainerImageAsync( + string imageId, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public async Task ScanSbomAsync( + string path, + CancellationToken cancellationToken) + { + await File.AppendAllTextAsync(path, Environment.NewLine, cancellationToken); + return result; + } + } +} diff --git a/tests/PortCVE.Tests/VulnerabilityCliTests.cs b/tests/PortCVE.Tests/VulnerabilityCliTests.cs new file mode 100644 index 0000000..4be86f9 --- /dev/null +++ b/tests/PortCVE.Tests/VulnerabilityCliTests.cs @@ -0,0 +1,283 @@ +using PortCVE.Cli; +using PortCVE.Collection; +using PortCVE.Domain; +using PortCVE.Snapshots; +using PortCVE.Vulnerabilities; + +namespace PortCVE.Tests; + +public sealed class VulnerabilityCliTests +{ + [Fact] + public async Task ScanFailOnHigh_ReturnsNegativeResultForKnownAdvisoryMatch() + { + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult(VulnerabilitySeverity.High)); + var application = Application(SnapshotWithImage(8080), scanner); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Port: 8080, + Protocol: TransportProtocol.Tcp, + FailOn: VulnerabilitySeverity.High), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.NegativeResult, exitCode); + Assert.Contains("known advisory match", output.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "Exploitability and network reachability were not assessed", + output.ToString(), + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ScanStrict_StaleEvidenceTakesPrecedenceOverSeverityFailure() + { + var complete = VulnerabilityAssessmentTests.CompleteResult(VulnerabilitySeverity.Critical); + 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."), + ], + }, + Limitations = ["Fixture database is stale."], + }; + var application = Application( + SnapshotWithImage(8080), + new VulnerabilityAssessmentTests.FixedScanner(partial)); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Port: 8080, + Protocol: TransportProtocol.Tcp, + Strict: true, + FailOn: VulnerabilitySeverity.Critical), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Contains("vulnerability_db_stale", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanUnavailable_ReturnsIncompleteAndNamesMissingTool() + { + var diagnostic = new VulnerabilityDiagnostic( + "trivy", + VulnerabilityProviderStatus.Unavailable, + "trivy_unavailable", + "Trivy was not found."); + var result = new VulnerabilityScanResult( + VulnerabilityScanStatus.Unavailable, + new( + "trivy", + null, + null, + null, + "offline", + VulnerabilityProviderStatus.Unavailable, + 0, + [diagnostic]), + [], + [diagnostic.Message]); + var application = Application( + SnapshotWithImage(8080), + new VulnerabilityAssessmentTests.FixedScanner(result)); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new(CommandKind.Scan, Port: 8080, Protocol: TransportProtocol.Tcp), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.IncompleteEvidence, exitCode); + Assert.Contains("trivy_unavailable", error.ToString(), StringComparison.Ordinal); + Assert.Contains("Trivy was not found", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanNoListenerMatch_ReturnsOneWithoutLaunchingScanner() + { + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult()); + var application = Application(SnapshotWithImage(8080), scanner); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new(CommandKind.Scan, Port: 8081, Protocol: TransportProtocol.Tcp), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.NegativeResult, exitCode); + Assert.Empty(scanner.ImageIds); + Assert.Contains("no TCP listeners matched", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ScanUncSbom_IsRejectedBeforeAnyScannerInvocation() + { + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult()); + var application = Application(SnapshotWithImage(8080), scanner); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Port: 8080, + Protocol: TransportProtocol.Tcp, + SbomPath: "\\\\server.invalid\\share\\fixture.cdx.json"), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.UsageOrSchema, exitCode); + Assert.Contains("sbom_path_network", error.ToString(), StringComparison.Ordinal); + Assert.Empty(scanner.ImageIds); + Assert.Empty(scanner.SbomPaths); + } + + [Theory] + [InlineData("sbom_path_network", "mapped network drive")] + [InlineData("sbom_path_reparse", "reparse point")] + public async Task ScanUnsafeSbomPolicy_IsRejectedBeforeAnyScannerInvocation( + string code, + string reason) + { + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult()); + var application = new CliApplication( + new FixedSnapshotBuilder(SnapshotWithImage(8080)), + new LockfileService(), + scanner, + _ => new(false, null, code, $"Rejected {reason}.")); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Port: 8080, + Protocol: TransportProtocol.Tcp, + SbomPath: "Z:\\fixture.cdx.json"), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.UsageOrSchema, exitCode); + Assert.Contains(code, error.ToString(), StringComparison.Ordinal); + Assert.Empty(scanner.ImageIds); + Assert.Empty(scanner.SbomPaths); + } + + [Fact] + public void LocalPathPolicy_RejectsMappedNetworkDrivesAndReparsePoints() + { + Assert.False(LocalPathPolicy.IsAllowedLocalDriveType(DriveType.Network)); + Assert.False(LocalPathPolicy.IsAllowedLocalDriveType(DriveType.Unknown)); + Assert.True(LocalPathPolicy.IsAllowedLocalDriveType(DriveType.Fixed)); + Assert.True(LocalPathPolicy.IsReparsePoint(FileAttributes.ReparsePoint)); + Assert.False(LocalPathPolicy.IsReparsePoint(FileAttributes.Normal)); + } + + [Fact] + public async Task ScanSbomThroughRealDirectoryLink_IsRejectedBeforeScannerInvocation() + { + var parent = Path.Combine(Path.GetTempPath(), $"portcve-sbom-link-{Guid.NewGuid():N}"); + var target = Path.Combine(parent, "target"); + var link = Path.Combine(parent, "link"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "fixture.cdx.json"), "{}"); + Directory.CreateSymbolicLink(link, target); + try + { + var scanner = new VulnerabilityAssessmentTests.FixedScanner( + VulnerabilityAssessmentTests.CompleteResult()); + var application = Application(SnapshotWithImage(8080), scanner); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = await application.RunAsync( + new( + CommandKind.Scan, + Port: 8080, + Protocol: TransportProtocol.Tcp, + SbomPath: Path.Combine(link, "fixture.cdx.json")), + output, + error, + CancellationToken.None); + + Assert.Equal(ExitCodes.UsageOrSchema, exitCode); + Assert.Contains("sbom_path_reparse", error.ToString(), StringComparison.Ordinal); + Assert.Empty(scanner.ImageIds); + Assert.Empty(scanner.SbomPaths); + } + finally + { + Directory.Delete(link); + Directory.Delete(parent, recursive: true); + } + } + + private static CliApplication Application( + SystemSnapshot snapshot, + IVulnerabilityScanner scanner) => new( + new FixedSnapshotBuilder(snapshot), + new LockfileService(), + scanner); + + private static SystemSnapshot SnapshotWithImage(int port) + { + var imageId = $"sha256:{new string('a', 64)}"; + return new( + 1, + "test", + DateTimeOffset.UnixEpoch, + 1, + "Windows", + [ + new("sockets", CollectorStatus.Complete, DateTimeOffset.UnixEpoch, 1, []), + new("process_owners", CollectorStatus.Complete, DateTimeOffset.UnixEpoch, 1, []), + new("interfaces", CollectorStatus.Complete, DateTimeOffset.UnixEpoch, 1, []), + new("docker", CollectorStatus.Complete, DateTimeOffset.UnixEpoch, 1, []), + ], + [], + [VulnerabilityAssessmentTests.Listener(port, imageId, "private-container", "private/image:1")], + []); + } + + private sealed class FixedSnapshotBuilder(SystemSnapshot snapshot) : ISnapshotBuilder + { + public Task CollectAsync( + SnapshotOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.False(options.IncludeFirewall); + return Task.FromResult(snapshot); + } + } +} diff --git a/tests/PortCVE.Tests/VulnerabilityParserTests.cs b/tests/PortCVE.Tests/VulnerabilityParserTests.cs new file mode 100644 index 0000000..e6a6f0f --- /dev/null +++ b/tests/PortCVE.Tests/VulnerabilityParserTests.cs @@ -0,0 +1,72 @@ +using PortCVE.Vulnerabilities; + +namespace PortCVE.Tests; + +public sealed class VulnerabilityParserTests +{ + [Fact] + public void ParseReport_UsesPinnedTrivySchemaAndSkipsIncompleteEntries() + { + var json = File.ReadAllText(Path.Combine( + RepositoryRoot(), + "tests", + "PortCVE.Tests", + "Fixtures", + "trivy-report-v2.json")); + + var findings = TrivyVulnerabilityScanner.ParseReport(json); + + Assert.Collection( + findings, + critical => + { + Assert.Equal("CVE-2026-0002", critical.AdvisoryId); + Assert.Equal(VulnerabilitySeverity.Critical, critical.Severity); + Assert.Equal(["3.3.0-r1", "3.3.1-r0"], critical.FixedVersions); + Assert.Equal(["ALPINE-2026-2"], critical.Aliases); + Assert.Equal( + ["https://example.invalid/ref-1", "https://example.invalid/ref-2"], + critical.References); + }, + high => + { + Assert.Equal("CVE-2026-0001", high.AdvisoryId); + Assert.Equal(VulnerabilitySeverity.High, high.Severity); + Assert.Empty(high.FixedVersions); + }); + } + + [Fact] + public void ParseReport_RejectsUnpinnedSchemaVersion() + { + var exception = Assert.Throws(() => + TrivyVulnerabilityScanner.ParseReport("{\"SchemaVersion\":3,\"Results\":[]}")); + + Assert.Contains("SchemaVersion 2", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("{\"SchemaVersion\":2}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":null}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":{}}")] + [InlineData("{\"SchemaVersion\":2,\"Results\":\"none\"}")] + public void ParseReport_RejectsMissingOrNonArrayResults(string json) + { + var exception = Assert.Throws(() => + TrivyVulnerabilityScanner.ParseReport(json)); + + Assert.Contains("Results array", exception.Message, StringComparison.Ordinal); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PortCVE.sln"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new DirectoryNotFoundException("Could not locate the PortCVE repository root."); + } +} diff --git a/tests/BindWitness.Tests/WindowsEndpointCollectorTests.cs b/tests/PortCVE.Tests/WindowsEndpointCollectorTests.cs similarity index 96% rename from tests/BindWitness.Tests/WindowsEndpointCollectorTests.cs rename to tests/PortCVE.Tests/WindowsEndpointCollectorTests.cs index ae47b53..5eea3b1 100644 --- a/tests/BindWitness.Tests/WindowsEndpointCollectorTests.cs +++ b/tests/PortCVE.Tests/WindowsEndpointCollectorTests.cs @@ -2,9 +2,9 @@ using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; -using BindWitness.Platforms.Windows; +using PortCVE.Platforms.Windows; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class WindowsEndpointCollectorTests { diff --git a/tests/BindWitness.Tests/WindowsFirewallPolicyTests.cs b/tests/PortCVE.Tests/WindowsFirewallPolicyTests.cs similarity index 98% rename from tests/BindWitness.Tests/WindowsFirewallPolicyTests.cs rename to tests/PortCVE.Tests/WindowsFirewallPolicyTests.cs index baf8013..dcbf4ec 100644 --- a/tests/BindWitness.Tests/WindowsFirewallPolicyTests.cs +++ b/tests/PortCVE.Tests/WindowsFirewallPolicyTests.cs @@ -1,7 +1,7 @@ -using BindWitness.Collection; -using BindWitness.Domain; +using PortCVE.Collection; +using PortCVE.Domain; -namespace BindWitness.Tests; +namespace PortCVE.Tests; public sealed class WindowsFirewallPolicyTests { diff --git a/tests/BindWitness.Tests/packages.lock.json b/tests/PortCVE.Tests/packages.lock.json similarity index 99% rename from tests/BindWitness.Tests/packages.lock.json rename to tests/PortCVE.Tests/packages.lock.json index c7528d9..394ebf3 100644 --- a/tests/BindWitness.Tests/packages.lock.json +++ b/tests/PortCVE.Tests/packages.lock.json @@ -94,7 +94,7 @@ "xunit.extensibility.core": "[2.9.3]" } }, - "bindwitness": { + "portcve": { "type": "Project" } }