diff --git a/docs/releasing.md b/docs/releasing.md index 06b1c2ce..8f88a468 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -204,3 +204,44 @@ unsigned and the run shows a warning. a PowerShell parse, xmllint (WiX, plist, MIME), `desktop-file-validate`, `appstreamcli validate`, and the MSI icon-id check. The FreeBSD and Windows ARM64 workflows also exercise their packaging before a release does. + +## Scanner detections + +Antivirus and reputation scanners sometimes flag a released file. Check the facts before you change +code or answer publicly. The first report was [issue #747](https://github.com/storytold/pdfcraft/issues/747), +a match of the `CobaltStrikeBeacon` family rule. + +1. **Confirm the file is the release.** Compare its SHA-256 with `SHA256SUMS.txt` on the GitHub + Release. Only a file whose hash matches the release says anything about that release. +2. **Record the signature.** `packaging/windows/security-audit.ps1` prints the Authenticode status: + - `Valid`: the signature verifies. + - `NotSigned`: no signature, as in developer and test builds. Not evidence of compromise by itself. + - `HashMismatch`: the file changed after it was signed. Investigate. + - `NotTrusted` or `UnknownError`: a signature exists but did not validate on this machine. Compare + the signer and the hash with the release. +3. **Extract the payloads without running them.** Scan the executables inside the MSI and the + portable zip, not only the outer installer. `7z x -oDIR pdfcraft--windows-x64.msi` + extracts them. The payload names are WiX-internal, so tell the executables apart by their file + type. Don't install or run the MSI or the extracted files. +4. **Scan with YARA and keep every match.** Use your own copies of YARA and the rule. PdfCraft + bundles neither, and the helper never runs in the app, the installer or CI: + + ```powershell + pwsh packaging/windows/security-audit.ps1 -Path .\pdfcraft.exe, .\pdfcraft-cli.exe -YaraExe C:\tools\yara64.exe -YaraRule C:\rules\CobaltStrikeBeacon.yar + ``` + + The output lists each matched identifier with its offset, and the SHA-256 of the rule file. + Record where the rule came from and its revision. For CAPEv2's `CobaltStrikeBeacon` rule, that is + the commit that last changed `data/yara/CAPE/CobaltStrikeBeacon.yar`. +5. **Find the condition branch.** Family rules usually have alternative branches, such as + `all of ($x*) or 2 of ($a*) or ...`. Copy the rule, make one rule per branch with only the strings + it uses, and scan again to see which branch fired. +6. **Interpret with care.** A match on generic repeated-byte patterns says much less than a match on + distinctive code or strings, and it does not show that the software belongs to the family. Rules + change too: CAPEv2 commit `9b599185` (2024-12-05) lengthened this rule's two repeated-byte patterns + from 4 to 16 bytes to reduce false positives, so an older rule revision can match files that the + current one does not. Don't alter PdfCraft's binaries to change a scanner's result. That changes + nothing about the software. +7. **Report.** To the scanner, send the file's SHA-256, the release URL, the rule name and revision, + and the matched identifiers and offsets. Report PdfCraft problems at + with the same facts. diff --git a/packaging/windows/security-audit.ps1 b/packaging/windows/security-audit.ps1 new file mode 100644 index 00000000..8bda7a80 --- /dev/null +++ b/packaging/windows/security-audit.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Record the facts behind a scanner detection of PdfCraft Windows files: SHA-256, Authenticode + status and, with -YaraExe and -YaraRule, the matched strings and offsets of an external YARA rule. + +.DESCRIPTION + Read-only. It never changes, runs or unpacks the files it inspects, makes no network connection + itself and needs no administrator rights. Neither YARA nor any rule is bundled or downloaded: pass + your own copies. See docs/releasing.md ("Scanner detections"). + + A YARA match only means that a byte pattern occurred in the file. It is not proof of malware. + Report the rule file's SHA-256 (printed here), the matched identifiers and offsets, and the + condition branch that fired. + +.EXAMPLE + pwsh packaging/windows/security-audit.ps1 -Path dist\release\pdfcraft-0.5.0-windows-x64-portable\pdfcraft.exe + +.EXAMPLE + pwsh packaging/windows/security-audit.ps1 -Path .\pdfcraft.exe, .\pdfcraft-cli.exe -YaraExe C:\tools\yara64.exe -YaraRule C:\rules\CobaltStrikeBeacon.yar +#> +param( + [Parameter(Mandatory)] [string[]] $Path, + [string] $YaraExe, + [string] $YaraRule +) +$ErrorActionPreference = 'Stop' + +function Invoke-Yara([string[]] $Arguments) { + # Native stderr would end the script under $ErrorActionPreference = 'Stop' (Windows PowerShell 5.1), + # so capture it as text and judge the result by the exit code. + $ErrorActionPreference = 'Continue' + $lines = @(& $YaraExe @Arguments 2>&1 | ForEach-Object { "$_" }) + return [pscustomobject] @{ Code = $LASTEXITCODE; Lines = $lines } +} + +if (([bool] $YaraExe) -ne ([bool] $YaraRule)) { throw 'Pass -YaraExe and -YaraRule together.' } +Write-Output "Scanned (UTC): $((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss'))" +if ($YaraExe) { + foreach ($tool in $YaraExe, $YaraRule) { + if (-not (Test-Path -LiteralPath $tool -PathType Leaf)) { throw "not found: $tool" } + } + $YaraExe = (Resolve-Path -LiteralPath $YaraExe).ProviderPath + $YaraRule = (Resolve-Path -LiteralPath $YaraRule).ProviderPath + Write-Output "YARA: $((Invoke-Yara @('--version')).Lines | Select-Object -First 1) ($YaraExe)" + Write-Output "Rule: $YaraRule" + Write-Output "Rule SHA-256: $((Get-FileHash -LiteralPath $YaraRule -Algorithm SHA256).Hash)" +} else { + Write-Output 'YARA: not run (pass -YaraExe and -YaraRule to scan with an external rule)' +} + +$failed = $false +foreach ($item in $Path) { + if (-not (Test-Path -LiteralPath $item -PathType Leaf)) { + Write-Output "error: not a file: $item" + $failed = $true + continue + } + $file = (Resolve-Path -LiteralPath $item).ProviderPath + $sig = Get-AuthenticodeSignature -LiteralPath $file + $signer = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '(none)' } + $stamper = if ($sig.TimeStamperCertificate) { $sig.TimeStamperCertificate.Subject } else { '(none)' } + + Write-Output '' + Write-Output "== $file" + Write-Output "Size: $((Get-Item -LiteralPath $file).Length) bytes" + Write-Output "SHA-256: $((Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash)" + Write-Output "Authenticode: $($sig.Status) ($($sig.StatusMessage))" + Write-Output "Signer: $signer" + Write-Output "Timestamper: $stamper" + + if ($YaraExe) { + $scan = Invoke-Yara @('-s', '-m', $YaraRule, $file) + if ($scan.Code -ne 0) { + Write-Output "YARA: failed with exit code $($scan.Code)" + $scan.Lines | ForEach-Object { Write-Output " $_" } + $failed = $true + continue + } + $ruleLines = @($scan.Lines | Where-Object { $_ -and $_ -notmatch '^0x' }) + $stringLines = @($scan.Lines | Where-Object { $_ -match '^0x' }) + Write-Output "YARA: $($ruleLines.Count) rule match(es), $($stringLines.Count) string match(es)" + $scan.Lines | Where-Object { $_ } | ForEach-Object { Write-Output " $_" } + } +} + +if ($YaraExe) { + Write-Output '' + Write-Output 'A YARA match is a pattern match, not a verdict. Keep the rule revision, identifiers, offsets and branch with any report.' +} +if ($failed) { exit 1 }