Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769)

Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a
release file's SHA-256, Authenticode status and, with an external YARA binary and rule,
every matched string identifier and offset. Document the procedure in docs/releasing.md
("Scanner detections").

No application, dependency, installer or network behaviour changes: the diff touches only
the helper and the release documentation.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
cyfung1031 2026-10-11 04:39:44 +09:00 committed by GitHub
parent d258764abc
commit ef63ba3680
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 131 additions and 0 deletions

View File

@ -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-<version>-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
<https://github.com/storytold/pdfcraft/issues> with the same facts.

View File

@ -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 }