Compare commits
No files matched your search
@@ -0,0 +1,70 @@
|
||||
name: Windows signing verification
|
||||
description: Prepare explicit owned-file catalogs and verify final Authenticode signatures and preserved binary bytes.
|
||||
inputs:
|
||||
operation:
|
||||
description: prepare, verify, or verify-distribution.
|
||||
required: true
|
||||
root:
|
||||
description: Root directory for the files to sign.
|
||||
default: .
|
||||
signing-files:
|
||||
description: Newline-separated required file names or relative path patterns. No blanket dependency signing.
|
||||
default: ""
|
||||
preserve-other-binaries:
|
||||
description: Record and check binaries outside the signing catalog.
|
||||
default: "false"
|
||||
expected-subject:
|
||||
description: Verified certificate subject read from the deployed Azure profile.
|
||||
default: ""
|
||||
manifest:
|
||||
description: Manifest created by prepare; empty uses RUNNER_TEMP/k22-signing-manifest.json.
|
||||
default: ""
|
||||
directory:
|
||||
description: Directory of final packaged assets to verify.
|
||||
default: ""
|
||||
outputs:
|
||||
catalog:
|
||||
description: File catalog consumed by azure/artifact-signing-action.
|
||||
value: ${{ steps.run.outputs.catalog }}
|
||||
manifest:
|
||||
description: Owned files and third-party preservation manifest.
|
||||
value: ${{ steps.run.outputs.manifest }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: run
|
||||
shell: pwsh
|
||||
env:
|
||||
K22_SIGNING_OPERATION: ${{ inputs.operation }}
|
||||
K22_SIGNING_ROOT: ${{ inputs.root }}
|
||||
K22_SIGNING_FILES: ${{ inputs.signing-files }}
|
||||
K22_SIGNING_PRESERVE: ${{ inputs.preserve-other-binaries }}
|
||||
K22_SIGNING_SUBJECT: ${{ inputs.expected-subject }}
|
||||
K22_SIGNING_MANIFEST: ${{ inputs.manifest }}
|
||||
K22_SIGNING_DIRECTORY: ${{ inputs.directory }}
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module (Join-Path $env:GITHUB_ACTION_PATH '../../../tools/WindowsSigning.psm1') -Force
|
||||
$manifest = $env:K22_SIGNING_MANIFEST
|
||||
if (-not $manifest) { $manifest = Join-Path $env:RUNNER_TEMP 'k22-signing-manifest.json' }
|
||||
$catalog = Join-Path $env:RUNNER_TEMP 'k22-signing-catalog.txt'
|
||||
switch ($env:K22_SIGNING_OPERATION)
|
||||
{
|
||||
'prepare' {
|
||||
$patterns = @($env:K22_SIGNING_FILES -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
if ($patterns.Count -eq 0) { throw 'Explicit owned signing-files are required.' }
|
||||
if ([string]::IsNullOrWhiteSpace($env:K22_SIGNING_SUBJECT)) { throw 'The verified publisher subject is required before selecting signing files.' }
|
||||
New-WindowsSigningCatalog -Root $env:K22_SIGNING_ROOT -Patterns $patterns -CatalogPath $catalog -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT -PreserveOtherBinaries:($env:K22_SIGNING_PRESERVE -eq 'true')
|
||||
}
|
||||
'verify' {
|
||||
Assert-WindowsSigningManifest -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT
|
||||
}
|
||||
'verify-distribution' {
|
||||
Assert-WindowsSigningManifest -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT
|
||||
Assert-WindowsDistribution -Directory $env:K22_SIGNING_DIRECTORY -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT
|
||||
}
|
||||
default { throw "Unsupported signing operation: $env:K22_SIGNING_OPERATION." }
|
||||
}
|
||||
"catalog=$catalog" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
"manifest=$manifest" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
@@ -0,0 +1,12 @@
|
||||
# Plain-language categories for native GitHub release notes.
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- "autorelease: pending"
|
||||
- "autorelease: tagged"
|
||||
- "autorelease: failed"
|
||||
categories:
|
||||
- title: Breaking changes
|
||||
labels: [breaking]
|
||||
- title: Changes
|
||||
labels: ["*"]
|
||||
@@ -93,6 +93,16 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./tools/Test-RepoConformance.ps1 -SelfTest
|
||||
|
||||
- name: Conformance parser fuzzing
|
||||
shell: pwsh
|
||||
env:
|
||||
CI_EVENT_NAME: ${{ github.event_name }}
|
||||
CI_REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
$iterations = if ($env:CI_EVENT_NAME -eq 'schedule' -or $env:CI_REF_TYPE -eq 'tag') { 10000 } else { 1000 }
|
||||
./tools/Test-RepoConformance.Fuzz.ps1 -Seed 22023 -Iterations $iterations
|
||||
./tools/Test-RepoConformance.Fuzz.ps1 -Seed 99173 -Iterations $iterations
|
||||
|
||||
- name: Standards package tests
|
||||
shell: pwsh
|
||||
run: ./tools/Test-StandardsPackage.ps1
|
||||
@@ -102,9 +112,60 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly
|
||||
|
||||
windows-signing:
|
||||
name: windows-signing
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Lint and test Windows signing gates
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
|
||||
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
|
||||
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
|
||||
Import-Module Pester -RequiredVersion 5.7.1
|
||||
# Keep every configured rule; isolate concurrent command-metadata consumers.
|
||||
# https://github.com/PowerShell/PSScriptAnalyzer/issues/1708
|
||||
$settings = 'configs/shell/PSScriptAnalyzerSettings.psd1'
|
||||
$casingFixture = @(Invoke-ScriptAnalyzer -ScriptDefinition "get-content -literalpath 'fixture.txt'" -Settings $settings -IncludeRule PSUseCorrectCasing)
|
||||
if ('PSUseCorrectCasing' -notin $casingFixture.RuleName) { throw 'The casing pass did not detect incorrect casing.' }
|
||||
$reservedFixture = @(Invoke-ScriptAnalyzer -ScriptDefinition 'function Get-Fixture? { [CmdletBinding()] param() }; Export-ModuleMember -Function Get-Fixture?' -Settings $settings -IncludeRule PSReservedCmdletChar)
|
||||
if ('PSReservedCmdletChar' -notin $reservedFixture.RuleName) { throw 'The reserved-character pass did not detect the exported function.' }
|
||||
$remainingFixture = @(Invoke-ScriptAnalyzer -ScriptDefinition "Write-Host 'fixture'" -Settings $settings -ExcludeRule PSUseCorrectCasing, PSReservedCmdletChar)
|
||||
if ('PSAvoidUsingWriteHost' -notin $remainingFixture.RuleName) { throw 'The remaining-rule pass did not detect Write-Host.' }
|
||||
foreach ($path in @('tools/WindowsSigning.psm1', 'tools/WindowsSigning.Tests.ps1', 'tools/VelopackSigning.Tests.ps1'))
|
||||
{
|
||||
Write-Output "Analyzing $path"
|
||||
try
|
||||
{
|
||||
$findings = @(
|
||||
Invoke-ScriptAnalyzer -Path $path -Settings $settings -IncludeRule PSUseCorrectCasing
|
||||
Invoke-ScriptAnalyzer -Path $path -Settings $settings -IncludeRule PSReservedCmdletChar
|
||||
Invoke-ScriptAnalyzer -Path $path -Settings $settings -ExcludeRule PSUseCorrectCasing, PSReservedCmdletChar
|
||||
)
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Output $_.Exception.ToString()
|
||||
throw
|
||||
}
|
||||
if ($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $path" }
|
||||
}
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = @('tools/WindowsSigning.Tests.ps1', 'tools/VelopackSigning.Tests.ps1')
|
||||
$configuration.Run.Exit = $true
|
||||
Invoke-Pester -Configuration $configuration
|
||||
|
||||
release-please:
|
||||
name: release-please
|
||||
needs: [security, codeql, docs, conformance]
|
||||
needs: [security, codeql, docs, conformance, windows-signing]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.RELEASE_APP_CLIENT_ID != ''
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
- name: PR title is a Conventional Commit header
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
# The title becomes the squash commit subject on main (K22-REPO-22), so it
|
||||
# must be a valid Conventional Commits 1.0 header of at most 100 characters.
|
||||
@@ -65,6 +66,15 @@ jobs:
|
||||
printf 'Title was: %s\n' "$PR_TITLE"
|
||||
exit 1
|
||||
fi
|
||||
node - <<'NODE'
|
||||
const emoji = /\p{Emoji_Presentation}|[\u2600-\u27BF]|\p{Extended_Pictographic}\uFE0F|[#*0-9]\uFE0F?\u20E3/u;
|
||||
for (const [field, text] of [['title', process.env.PR_TITLE], ['description', process.env.PR_BODY || '']]) {
|
||||
if (emoji.test(text)) {
|
||||
console.error(`::error title=Unprofessional pull request::The ${field} contains an emoji; use professional text.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
echo "PR title OK"
|
||||
|
||||
dco:
|
||||
|
||||
@@ -64,6 +64,14 @@ on:
|
||||
description: Newline-separated test projects to run with coverage before packing.
|
||||
type: string
|
||||
default: ""
|
||||
sign:
|
||||
description: Authenticode-sign owned Windows binaries before packing, using Azure OIDC in the protected release environment.
|
||||
type: boolean
|
||||
default: false
|
||||
signing-files:
|
||||
description: Newline-separated required owned binary paths relative to the checkout; required when sign is true.
|
||||
type: string
|
||||
default: ""
|
||||
outputs:
|
||||
version:
|
||||
description: The released version (tag without the leading v).
|
||||
@@ -79,8 +87,10 @@ jobs:
|
||||
name: build
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
timeout-minutes: 60
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Azure OIDC is available only in the protected release environment.
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||
@@ -186,12 +196,86 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- name: Prepare owned binary signing catalog
|
||||
if: inputs.sign
|
||||
id: signing
|
||||
uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main
|
||||
with:
|
||||
operation: prepare
|
||||
signing-files: ${{ inputs.signing-files }}
|
||||
expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
|
||||
- name: Require verified Azure signing configuration
|
||||
if: inputs.sign
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }}
|
||||
ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }}
|
||||
ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }}
|
||||
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
run: |
|
||||
foreach ($name in @('AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_SUBSCRIPTION_ID', 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT'))
|
||||
{
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { throw "Missing verified signing variable: $name" }
|
||||
}
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
if: inputs.sign
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign owned binaries before packing
|
||||
if: inputs.sign
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
|
||||
with:
|
||||
endpoint: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }}
|
||||
files-catalog: ${{ steps.signing.outputs.catalog }}
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
cache-dependencies: false
|
||||
exclude-environment-credential: true
|
||||
exclude-workload-identity-credential: true
|
||||
exclude-managed-identity-credential: true
|
||||
exclude-shared-token-cache-credential: true
|
||||
exclude-visual-studio-credential: true
|
||||
exclude-visual-studio-code-credential: true
|
||||
exclude-azure-cli-credential: false
|
||||
exclude-azure-powershell-credential: true
|
||||
exclude-azure-developer-cli-credential: true
|
||||
exclude-interactive-browser-credential: true
|
||||
|
||||
- name: Verify signed binaries
|
||||
if: inputs.sign
|
||||
uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main
|
||||
with:
|
||||
operation: verify
|
||||
manifest: ${{ steps.signing.outputs.manifest }}
|
||||
expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
|
||||
- name: Pack
|
||||
shell: pwsh
|
||||
env:
|
||||
K22_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: dotnet pack $env:K22_SOLUTION -c Release "-p:Platform=$env:K22_PLATFORM" "-p:Version=$env:K22_VERSION" --no-build -o artifacts/nuget
|
||||
|
||||
- name: Verify the exact binaries shipped in NuGet packages
|
||||
if: inputs.sign
|
||||
uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main
|
||||
with:
|
||||
operation: verify-distribution
|
||||
directory: artifacts/nuget
|
||||
manifest: ${{ steps.signing.outputs.manifest }}
|
||||
expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
|
||||
# Maps each package to the project folder whose packages.lock.json describes its
|
||||
# dependencies, for the SBOM job. Package ids come from the produced file names.
|
||||
- name: List packages
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
# app unpackaged and self-contained, and the optional CLI as a single file.
|
||||
# package (Windows, environment `release`): optionally Authenticode-sign the
|
||||
# published binaries with Azure Trusted Signing over OIDC (input `sign`,
|
||||
# off until exception EX-0002 closes), download the previous release on the
|
||||
# enabled after repository signing verification), download the previous release on the
|
||||
# same channel as the delta baseline, `vpk pack` (which signs Setup.exe with
|
||||
# the same identity when `sign` is on), and zip the CLI. Only this build's
|
||||
# Velopack assets (from assets.<channel>.json) are kept, with a
|
||||
@@ -100,7 +100,16 @@ on:
|
||||
Authenticode-sign binaries and Setup.exe with Azure Trusted Signing (OIDC). Requires the
|
||||
variables AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT,
|
||||
TRUSTED_SIGNING_ACCOUNT, and TRUSTED_SIGNING_PROFILE in the caller's `release` environment.
|
||||
Off until exception EX-0002 (signing identity validation) closes.
|
||||
ARTIFACT_SIGNING_SUBJECT must contain the verified publisher subject. Legacy TRUSTED_SIGNING_*
|
||||
names remain accepted; ARTIFACT_SIGNING_* names take precedence.
|
||||
type: boolean
|
||||
default: false
|
||||
signing-files:
|
||||
description: Newline-separated required owned file names or publish-relative patterns, including the optional CLI; required when sign is true.
|
||||
type: string
|
||||
default: ""
|
||||
msi:
|
||||
description: Generate a per-user MSI alongside Setup.exe and portable ZIP; requires the same signing and verification.
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
@@ -325,9 +334,34 @@ jobs:
|
||||
name: publish-cli
|
||||
path: publish/cli
|
||||
|
||||
# Signing is OFF by default: exception EX-0002 records that the Azure Trusted
|
||||
# Signing identity is still being validated. Until then, integrity and origin rest
|
||||
# on SHA256SUMS and the build-provenance attestations (K22-REL-04).
|
||||
- name: Prepare owned binary signing catalog
|
||||
if: inputs.sign
|
||||
id: signing
|
||||
uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main
|
||||
with:
|
||||
operation: prepare
|
||||
root: publish
|
||||
signing-files: ${{ inputs.signing-files }}
|
||||
expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
preserve-other-binaries: "true"
|
||||
|
||||
- name: Require verified Azure signing configuration
|
||||
if: inputs.sign
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }}
|
||||
ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }}
|
||||
ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }}
|
||||
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
run: |
|
||||
foreach ($name in @('AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_SUBSCRIPTION_ID', 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT'))
|
||||
{
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { throw "Missing verified signing variable: $name" }
|
||||
}
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
if: inputs.sign
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
@@ -336,22 +370,52 @@ jobs:
|
||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# The pinned official action installs this tool with --add-source, which
|
||||
# conflicts with NuGet package source mapping. Stage its verified package
|
||||
# in the action's own dependency directory without changing caller policy.
|
||||
- name: Prepare pinned Artifact Signing CLI dependency
|
||||
if: inputs.sign
|
||||
shell: pwsh
|
||||
run: |
|
||||
& {
|
||||
param(
|
||||
[string]$PackageDirectory = $env:RUNNER_TEMP,
|
||||
[string]$InstallRoot = $env:LOCALAPPDATA
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = '0.9.1-beta.26227.3'
|
||||
$package = Join-Path $PackageDirectory "sign.$version.nupkg"
|
||||
$destination = Join-Path $InstallRoot "ArtifactSigning/sign/sign.$version"
|
||||
if (Test-Path -LiteralPath $destination) { throw 'Artifact Signing CLI directory already exists; use a fresh release runner.' }
|
||||
Invoke-WebRequest -Uri "https://api.nuget.org/v3-flatcontainer/sign/$version/sign.$version.nupkg" -OutFile $package
|
||||
if ((Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash -ne '34FD0D4AEABDBC363A48883881865B4DD65E6C11BA916028082FA23F3E1B1BA1')
|
||||
{
|
||||
throw 'Artifact Signing CLI package checksum mismatch.'
|
||||
}
|
||||
dotnet nuget verify $package --all
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Artifact Signing CLI NuGet signature verification failed.' }
|
||||
[IO.Compression.ZipFile]::ExtractToDirectory($package, $destination)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $destination 'tools/net8.0/any/sign.dll') -PathType Leaf))
|
||||
{
|
||||
throw 'Artifact Signing CLI package is missing its executable.'
|
||||
}
|
||||
} @args
|
||||
|
||||
# Signs the app's own executables and DLLs (and the CLI) before packing, so the
|
||||
# binaries inside the installer, the portable zip, and the update packages are
|
||||
# all signed (K22-REL-05).
|
||||
- name: Sign published binaries (Azure Trusted Signing)
|
||||
- name: Sign only owned published binaries (Azure Artifact Signing)
|
||||
if: inputs.sign
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
|
||||
with:
|
||||
endpoint: ${{ vars.TRUSTED_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.TRUSTED_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ vars.TRUSTED_SIGNING_PROFILE }}
|
||||
files-folder: ${{ github.workspace }}\publish
|
||||
files-folder-filter: exe,dll
|
||||
files-folder-recurse: true
|
||||
endpoint: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }}
|
||||
files-catalog: ${{ steps.signing.outputs.catalog }}
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
timeout: 1800
|
||||
cache-dependencies: false
|
||||
exclude-environment-credential: true
|
||||
exclude-workload-identity-credential: true
|
||||
@@ -359,25 +423,60 @@ jobs:
|
||||
exclude-shared-token-cache-credential: true
|
||||
exclude-visual-studio-credential: true
|
||||
exclude-visual-studio-code-credential: true
|
||||
exclude-azure-cli-credential: false
|
||||
exclude-azure-powershell-credential: true
|
||||
exclude-azure-developer-cli-credential: true
|
||||
exclude-interactive-browser-credential: true
|
||||
|
||||
- name: Verify signed binaries and preserved dependencies
|
||||
if: inputs.sign
|
||||
uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main
|
||||
with:
|
||||
operation: verify
|
||||
manifest: ${{ steps.signing.outputs.manifest }}
|
||||
expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
|
||||
# vpk signs what it adds (Setup.exe and the update stub) with the same identity,
|
||||
# reading this metadata file and the Azure CLI login above.
|
||||
- name: Write signing metadata for vpk
|
||||
if: inputs.sign
|
||||
shell: pwsh
|
||||
env:
|
||||
TRUSTED_SIGNING_ENDPOINT: ${{ vars.TRUSTED_SIGNING_ENDPOINT }}
|
||||
TRUSTED_SIGNING_ACCOUNT: ${{ vars.TRUSTED_SIGNING_ACCOUNT }}
|
||||
TRUSTED_SIGNING_PROFILE: ${{ vars.TRUSTED_SIGNING_PROFILE }}
|
||||
TRUSTED_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }}
|
||||
TRUSTED_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }}
|
||||
TRUSTED_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }}
|
||||
run: |
|
||||
@{
|
||||
Endpoint = $env:TRUSTED_SIGNING_ENDPOINT
|
||||
CodeSigningAccountName = $env:TRUSTED_SIGNING_ACCOUNT
|
||||
CertificateProfileName = $env:TRUSTED_SIGNING_PROFILE
|
||||
ExcludeCredentials = @('EnvironmentCredential', 'WorkloadIdentityCredential', 'ManagedIdentityCredential', 'SharedTokenCacheCredential', 'VisualStudioCredential', 'VisualStudioCodeCredential', 'AzurePowerShellCredential', 'AzureDeveloperCliCredential', 'InteractiveBrowserCredential')
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath "$env:RUNNER_TEMP\trusted-signing.json" -Encoding utf8
|
||||
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
|
||||
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
$dlib = Get-ChildItem "$env:LOCALAPPDATA\ArtifactSigning" -Recurse -Filter Azure.CodeSigning.Dlib.dll |
|
||||
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if (-not $signtool -or -not $dlib) { throw 'Installed x64 SignTool or Artifact Signing dlib was not found.' }
|
||||
foreach ($setting in @{
|
||||
K22_VPK_SIGN_TOOL = $signtool.FullName
|
||||
K22_VPK_SIGN_DLIB = $dlib.FullName
|
||||
K22_VPK_SIGN_METADATA = "$env:RUNNER_TEMP\trusted-signing.json"
|
||||
}.GetEnumerator())
|
||||
{
|
||||
"$($setting.Key)=$($setting.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
@'
|
||||
param([Parameter(Mandatory)][string] $File)
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $File
|
||||
if ($signature.Status -eq 'Valid') { exit 0 }
|
||||
if ($signature.Status -ne 'NotSigned') { throw "Refusing to replace an invalid signature on $File." }
|
||||
& $env:K22_VPK_SIGN_TOOL sign /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 /dlib $env:K22_VPK_SIGN_DLIB /dmdf $env:K22_VPK_SIGN_METADATA $File
|
||||
if ($LASTEXITCODE -ne 0) { throw "Artifact Signing failed on $File." }
|
||||
& $env:K22_VPK_SIGN_TOOL verify /pa /all /v $File
|
||||
if ($LASTEXITCODE -ne 0) { throw "Signature verification failed on $File." }
|
||||
'@ | Set-Content -LiteralPath "$env:RUNNER_TEMP\sign-one.ps1" -Encoding utf8NoBOM
|
||||
|
||||
# The vpk version matches the Velopack NuGet package the apps reference (AppKit).
|
||||
- name: Install vpk
|
||||
@@ -399,10 +498,20 @@ jobs:
|
||||
|
||||
- name: Velopack pack
|
||||
shell: pwsh
|
||||
env:
|
||||
VPK_MSI: ${{ inputs.msi }}
|
||||
run: |
|
||||
$sign = @()
|
||||
if ($env:K22_SIGN -eq 'true') { $sign = @('--azureTrustedSignFile', "$env:RUNNER_TEMP\trusted-signing.json") }
|
||||
vpk pack --packId $env:K22_APP_ID --packVersion $env:K22_VERSION --packDir publish/app --mainExe $env:K22_MAIN_EXE --channel $env:K22_CHANNEL --icon $env:K22_ICON --outputDir Releases @sign
|
||||
if ($env:K22_SIGN -eq 'true')
|
||||
{
|
||||
# App/CLI binaries are already signed. Only Velopack's branded helpers
|
||||
# may be signed inside pack; preserve every other dependency signature.
|
||||
$exclude = '(?i)[\\/](?!(?:Squirrel\.exe|.*_ExecutionStub\.exe)$)[^\\/]+$'
|
||||
$command = "pwsh -NoProfile -NonInteractive -File `"$env:RUNNER_TEMP\sign-one.ps1`" `"{{file}}`""
|
||||
$sign = @('--signTemplate', $command, '--signExclude', $exclude)
|
||||
}
|
||||
$env:VELOPACK_TEMP = Join-Path $env:RUNNER_TEMP 'k22-velopack'
|
||||
vpk pack --runtime $env:K22_RUNTIME --packId $env:K22_APP_ID --packVersion $env:K22_VERSION --packDir publish/app --mainExe $env:K22_MAIN_EXE --channel $env:K22_CHANNEL --icon $env:K22_ICON --outputDir Releases --instLocation PerUser @sign
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
# Keeps only what this build produced, as `vpk upload` does: the files listed in
|
||||
@@ -431,11 +540,139 @@ jobs:
|
||||
Write-Output "Assets: $($names -join ', '); feed: $feedName; setup: $setup"
|
||||
"setup=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
|
||||
- name: Extract required MSI payload
|
||||
if: inputs.msi
|
||||
id: msi
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installers = @(Get-ChildItem -LiteralPath release -Filter '*.msi' -File)
|
||||
if ($installers.Count -ne 1) { throw 'Exactly one MSI installer is required.' }
|
||||
$root = Join-Path $env:RUNNER_TEMP ('k22-msi-' + [guid]::NewGuid().ToString('N'))
|
||||
"root=$root" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
$image = Join-Path $root 'image'
|
||||
$assets = Join-Path $root 'assets'
|
||||
New-Item -ItemType Directory -Path $image, $assets -Force | Out-Null
|
||||
$start = [Diagnostics.ProcessStartInfo]::new('msiexec.exe')
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
foreach ($argument in @('/a', $installers[0].FullName, '/qn', "TARGETDIR=$image", '/l*v', (Join-Path $root 'extract.log')))
|
||||
{
|
||||
$start.ArgumentList.Add($argument)
|
||||
}
|
||||
$process = [Diagnostics.Process]::Start($start)
|
||||
try
|
||||
{
|
||||
if (-not $process.WaitForExit(180000)) { $process.Kill(); throw 'MSI extraction timed out.' }
|
||||
if ($process.ExitCode -ne 0) { throw "MSI extraction failed: $($process.ExitCode)." }
|
||||
}
|
||||
finally { $process.Dispose() }
|
||||
$manifests = @(Get-ChildItem -LiteralPath $image -Recurse -File -Filter sq.version)
|
||||
if ($manifests.Count -ne 1 -or $manifests[0].Directory.Name -ne 'current') { throw 'Expected one installed MSI application payload.' }
|
||||
$payload = $manifests[0].Directory.Parent.FullName
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($payload, (Join-Path $assets 'msi-Portable.zip'))
|
||||
"directory=$assets" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
|
||||
# Reuse the existing archive verifier for MSI's administratively extracted
|
||||
# payload: every owned/third-party byte and generated launcher is checked.
|
||||
- name: Verify exact signed MSI payload
|
||||
if: inputs.msi && inputs.sign
|
||||
uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main
|
||||
with:
|
||||
operation: verify-distribution
|
||||
directory: ${{ steps.msi.outputs.directory }}
|
||||
manifest: ${{ steps.signing.outputs.manifest }}
|
||||
expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
|
||||
- name: Verify MSI installs, launches and uninstalls
|
||||
if: inputs.msi
|
||||
shell: pwsh
|
||||
env:
|
||||
MSI_ROOT: ${{ steps.msi.outputs.root }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = @(Get-ChildItem -LiteralPath release -Filter '*.msi' -File)
|
||||
if ($msi.Count -ne 1) { throw 'Exactly one MSI installer is required.' }
|
||||
$installer = New-Object -ComObject WindowsInstaller.Installer
|
||||
$database = $installer.OpenDatabase($msi[0].FullName, 0)
|
||||
$view = $database.OpenView('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode''')
|
||||
$view.Execute()
|
||||
$product = $view.Fetch().StringData(1)
|
||||
if ($product -notmatch '^\{[0-9A-Fa-f-]{36}\}$') { throw 'Invalid MSI product identity.' }
|
||||
$target = Join-Path $env:MSI_ROOT 'installed'
|
||||
$application = $null
|
||||
try
|
||||
{
|
||||
$install = Start-Process msiexec.exe -ArgumentList @('/i', ('"' + $msi[0].FullName + '"'), '/qn', ('VELOPACK_INSTALLDIR="' + $target + '"'), '/l*v', ('"' + (Join-Path $env:MSI_ROOT 'install.log') + '"')) -WindowStyle Hidden -PassThru
|
||||
if (-not $install.WaitForExit(180000)) { $install.Kill(); throw 'MSI installation timed out.' }
|
||||
if ($install.ExitCode -ne 0) { throw "MSI installation failed: $($install.ExitCode)." }
|
||||
$executable = Join-Path $target "current/$env:K22_MAIN_EXE"
|
||||
if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { throw 'MSI did not install the application.' }
|
||||
$application = Start-Process -FilePath $executable -PassThru
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(30)
|
||||
do
|
||||
{
|
||||
$application.Refresh()
|
||||
if ($application.HasExited) { throw 'MSI-installed application exited before opening its window.' }
|
||||
if ($application.MainWindowHandle -ne [IntPtr]::Zero) { break }
|
||||
Start-Sleep -Milliseconds 200
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
if ($application.MainWindowHandle -eq [IntPtr]::Zero) { throw 'MSI-installed application did not open a window.' }
|
||||
if (-not $application.CloseMainWindow() -or -not $application.WaitForExit(10000)) { $application.Kill(); throw 'MSI-installed application did not close normally.' }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $application -and -not $application.HasExited) { $application.Kill() }
|
||||
$uninstall = Start-Process msiexec.exe -ArgumentList @('/x', $product, '/qn', '/l*v', ('"' + (Join-Path $env:MSI_ROOT 'uninstall.log') + '"')) -WindowStyle Hidden -PassThru
|
||||
if (-not $uninstall.WaitForExit(180000)) { $uninstall.Kill(); throw 'MSI uninstall timed out.' }
|
||||
if ($uninstall.ExitCode -notin 0, 1605) { throw "MSI uninstall failed: $($uninstall.ExitCode)." }
|
||||
}
|
||||
if (Test-Path -LiteralPath (Join-Path $target "current/$env:K22_MAIN_EXE")) { throw 'MSI uninstall left the application installed.' }
|
||||
Write-Output "MSI $product installed, opened its window, closed and uninstalled successfully."
|
||||
|
||||
- name: Remove temporary MSI extraction
|
||||
if: always() && inputs.msi
|
||||
shell: pwsh
|
||||
env:
|
||||
MSI_ROOT: ${{ steps.msi.outputs.root }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:MSI_ROOT)) { exit 0 }
|
||||
$root = [IO.Path]::GetFullPath($env:MSI_ROOT)
|
||||
$temporaryRoot = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($env:RUNNER_TEMP))
|
||||
if ([IO.Path]::GetDirectoryName($root) -ne $temporaryRoot -or [IO.Path]::GetFileName($root) -notmatch '^k22-msi-[0-9a-f]{32}$') { throw 'Unexpected MSI scratch directory.' }
|
||||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
|
||||
|
||||
- name: Zip CLI
|
||||
if: inputs.cli-project != ''
|
||||
shell: pwsh
|
||||
run: Compress-Archive -Path publish/cli/* -DestinationPath "release/$env:K22_APP_ID-cli-$env:K22_RUNTIME-$env:K22_VERSION.zip"
|
||||
|
||||
- name: Verify final installer, portable, full update and CLI assets
|
||||
if: inputs.sign
|
||||
uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main
|
||||
with:
|
||||
operation: verify-distribution
|
||||
directory: release
|
||||
manifest: ${{ steps.signing.outputs.manifest }}
|
||||
expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
|
||||
# Verification precedes upload, SBOM attestations, hashes and provenance.
|
||||
- name: Upload signed app output for its SBOM
|
||||
if: inputs.sign
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-publish-app
|
||||
path: publish/app/
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload signed CLI output for its SBOM
|
||||
if: inputs.sign && inputs.cli-project != ''
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-publish-cli
|
||||
path: publish/cli/
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload release files
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
@@ -459,14 +696,14 @@ jobs:
|
||||
- name: Download published app
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: publish-app
|
||||
name: ${{ inputs.sign && 'signed-publish-app' || 'publish-app' }}
|
||||
path: publish/app
|
||||
|
||||
- name: Download published CLI
|
||||
if: inputs.cli-project != ''
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: publish-cli
|
||||
name: ${{ inputs.sign && 'signed-publish-cli' || 'publish-cli' }}
|
||||
path: publish/cli
|
||||
|
||||
- name: Download release files
|
||||
@@ -519,6 +756,13 @@ jobs:
|
||||
subject-path: release/*-cli-*.zip
|
||||
sbom-path: sbom/${{ inputs.app-id }}-cli-${{ needs.build.outputs.version }}.cdx.json
|
||||
|
||||
- name: Attest MSI SBOM
|
||||
if: inputs.msi
|
||||
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
|
||||
with:
|
||||
subject-path: release/*.msi
|
||||
sbom-path: sbom/${{ inputs.app-id }}-${{ needs.build.outputs.version }}.cdx.json
|
||||
|
||||
- name: Upload SBOMs
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.1.2"}
|
||||
{".":"1.2.1"}
|
||||
+79
-3
@@ -1,8 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
<!-- Generated by Release Please; permit its section spacing. -->
|
||||
<!-- markdownlint-disable MD012 -->
|
||||
<!-- markdownlint-configure-file { "MD024": { "siblings_only": true } } -->
|
||||
<!-- Generated by Release Please and GitHub; permit native spacing, links and repeated categories. -->
|
||||
<!-- markdownlint-disable MD012 MD022 MD024 MD032 MD034 -->
|
||||
|
||||
## 1.2.1 (2026-10-13)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Changes
|
||||
* fix(releases): record MSI checklist preflight recovery by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/49
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/KofTwentyTwo/standards/compare/v1.2.0...v1.2.1
|
||||
|
||||
## 1.2.0 (2026-10-13)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Changes
|
||||
* feat(releases): publish signed per-user MSI installers by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/47
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/KofTwentyTwo/standards/compare/v1.1.2...v1.2.0
|
||||
|
||||
## 1.1.2 (2026-10-13)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Changes
|
||||
* fix(releases): scope bootstrap parameters under Actions pwsh by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/45
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/KofTwentyTwo/standards/compare/v1.1.1...v1.1.2
|
||||
|
||||
## 1.1.1 (2026-10-12)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Changes
|
||||
* docs(inventory): register MarkdownViewr and the Windows starter by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/36
|
||||
* fix(releases): bootstrap verified Artifact Signing dependency by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/44
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/KofTwentyTwo/standards/compare/v1.1.0...v1.1.1
|
||||
|
||||
## 1.1.0 (2026-10-12)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Changes
|
||||
* docs(exceptions): close EX-0002 for gclo by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/33
|
||||
* docs(exceptions): add EX-0009 to EX-0012 from the repository audit by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/37
|
||||
* feat(releases): verify Windows Artifact Signing before publication by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/31
|
||||
* fix(signing): pin reviewed helpers reachable from main by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/39
|
||||
* fix(ci): isolate racing signing analyzer rules by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/41
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/KofTwentyTwo/standards/compare/v1.0.0...v1.1.0
|
||||
|
||||
## 1.0.0 (2026-10-10)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Breaking changes
|
||||
* feat(assurance)!: require applicable fuzzing and badge participation by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/19
|
||||
### Changes
|
||||
* fix(releases): use professional repository and automation text by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/23
|
||||
* fix(releases): allow native formatting in generated changelog by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/24
|
||||
* docs(assurance): record OpenSSF passing badge by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/25
|
||||
* docs(exceptions): scope temporary frontend audit exception by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/27
|
||||
* ci(release-velopack): sign only unsigned files; verify before packing by @KofTwentyTwo in https://github.com/KofTwentyTwo/standards/pull/29
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/KofTwentyTwo/standards/compare/v0.1.2...v1.0.0
|
||||
|
||||
## [0.1.2](https://github.com/KofTwentyTwo/standards/compare/v0.1.1...v0.1.2) (2026-10-10)
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ themselves: everything below comes from the [SDLC policy](policies/sdlc.md).
|
||||
|
||||
## What a good change looks like
|
||||
|
||||
- Use professional wording in code comments, documentation, commit/PR messages,
|
||||
release notes and automation output. No emojis, joke copy or bot sound effects.
|
||||
- A new or changed requirement follows the format in
|
||||
[policies/README.md](policies/README.md#requirement-ids): an ID that has never been used,
|
||||
a level, the rule, *Why*, and *Verified by*. A requirement nobody can verify is not
|
||||
@@ -41,6 +43,7 @@ Documentation and package checks:
|
||||
npx markdownlint-cli2 "**/*.md"
|
||||
lychee --offline --include-fragments --no-progress './**/*.md'
|
||||
pwsh tools/Test-RepoConformance.ps1 -SelfTest
|
||||
pwsh tools/Test-RepoConformance.Fuzz.ps1 -Seed 22023 -Iterations 1000
|
||||
pwsh tools/Test-StandardsPackage.ps1
|
||||
pwsh tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly
|
||||
```
|
||||
@@ -59,6 +62,9 @@ pwsh tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/standards -LocalPat
|
||||
```
|
||||
|
||||
The same checks run in CI on every pull request and must pass before merging.
|
||||
Parser changes must pass both CI seeds and retain any discovered failure as a
|
||||
deterministic regression test. See [fuzzing](docs/security/fuzzing.md) for extended
|
||||
campaigns and [badge assessment](docs/security/best-practices.md) for enrollment evidence.
|
||||
|
||||
## License of contributions
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ conformance version in its README once it passes the conformance checker.
|
||||
| --- | --- | --- | --- |
|
||||
| [standards](https://github.com/KofTwentyTwo/standards) | These policies, standards, configs, and reusable workflows | Product | Not yet: rulesets and CI being added |
|
||||
| [AppKit](https://github.com/KofTwentyTwo/AppKit) | Shared foundation packages for Windows desktop apps | Product | Adoption in progress |
|
||||
| [MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows) | Windows Markdown reader based on Darin Kelkhoff's macOS markdownViewr and AppKit | Product | Adoption in progress: release acceptance, signing and maintainer review pending |
|
||||
| [windows-app-template](https://github.com/KofTwentyTwo/windows-app-template) | Reusable AppKit Windows app starter with optional CLI and stable/dev release workflows | Product | Adoption in progress: shared defaults and release controls in review |
|
||||
| [gclo](https://github.com/KofTwentyTwo/gclo) | Clone and update every repository of a GitHub organization (Windows app and CLI) | Product | Adoption in progress: standards gates, governance files, and the Kingsrook layout are in review (gclo #62); settings and exceptions pending |
|
||||
|
||||
### Awaiting classification
|
||||
|
||||
@@ -7,23 +7,23 @@ reusable GitHub Actions workflows, repository templates, and a conformance check
|
||||
|
||||
[](LICENSE-docs)
|
||||
[](LICENSE)
|
||||
[-orange)](#status)
|
||||
[](https://www.bestpractices.dev/projects/15366)
|
||||
[](#status)
|
||||
|
||||
## Status
|
||||
|
||||
> **Draft standards, before `v1.0.0`.** The requirements are written and the
|
||||
> enforcement tooling exists, but no KofTwentyTwo repository has been verified against
|
||||
> them yet; adoption starts with this repository, then
|
||||
> [AppKit](https://github.com/KofTwentyTwo/AppKit) and
|
||||
> [gclo](https://github.com/KofTwentyTwo/gclo). The compliance crosswalks therefore list
|
||||
> controls as **Defined**, not met, and the response times in the security program are
|
||||
> targets until `v1.0.0`. See [how these standards work](policies/README.md).
|
||||
> **Versioned standards with repository-specific adoption.** Immutable releases
|
||||
> identify each published baseline. This repository has completed repository and CI
|
||||
> conformance checks; other repositories record their own checks and exceptions.
|
||||
> Compliance crosswalks describe requirements and evidence for individual controls.
|
||||
> See [how these standards work](policies/README.md) and the
|
||||
> [release checklists](https://github.com/KofTwentyTwo/standards/issues?q=is%3Aissue+%22Release+v%22).
|
||||
|
||||
---
|
||||
|
||||
**Tier:** Product ([scope](policies/README.md#scope)). **Conformance:** this repository is
|
||||
the first to adopt the standards; it will state *Conforms to KofTwentyTwo standards
|
||||
v1.0* once the checker passes on it.
|
||||
**Tier:** Product ([scope](policies/README.md#scope)). **Conformance:** repository and
|
||||
CI checks reviewed on 2026-10-10: zero FAIL or UNKNOWN, with manual review items
|
||||
resolved in the release checklist and approved [exceptions](exceptions/register.md).
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -33,6 +33,11 @@ v1.0* once the checker passes on it.
|
||||
the [reusable workflows](#use-the-reusable-workflows), and keep it green with the
|
||||
[conformance checker](#check-a-repositorys-conformance).
|
||||
|
||||
Security assurance is recorded in the [Best Practices assessment](docs/security/best-practices.md)
|
||||
and [parser fuzzing guide](docs/security/fuzzing.md). The repository earned the
|
||||
[OpenSSF Best Practices passing badge](https://www.bestpractices.dev/projects/15366)
|
||||
on 2026-10-10 through a maintainer self-assessment.
|
||||
|
||||
## Contents
|
||||
|
||||
- [How to use](#how-to-use)
|
||||
@@ -197,13 +202,18 @@ IDE does on save is what CI checks.
|
||||
`protect-release-tags` rulesets, squash-only merging, the security features
|
||||
(private vulnerability reporting, secret scanning with push protection, Dependabot,
|
||||
code scanning, immutable releases), and read-only Actions tokens.
|
||||
6. **Check.** Run the conformance checker and fix every FAIL:
|
||||
6. **Assurance.** Complete `docs/security/assurance.json` and its evidence templates.
|
||||
Public Product repositories register their own Best Practices assessment and
|
||||
link the actual badge. Identify complex untrusted-input parsers; add their fuzz
|
||||
commands to a required PR/main CI job and weekly/release campaigns, or document
|
||||
why none apply. Review scope decisions; copied placeholders do not conform.
|
||||
7. **Check.** Run the conformance checker and fix every FAIL:
|
||||
|
||||
```powershell
|
||||
pwsh ./tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/<repo> -LocalPath ../<repo>
|
||||
```
|
||||
|
||||
7. **Declare it.** Add *"Conforms to KofTwentyTwo standards vX.Y"* to the README and
|
||||
8. **Declare it.** Add *"Conforms to KofTwentyTwo standards vX.Y"* to the README and
|
||||
list the repository in [MAINTAINERS.md](MAINTAINERS.md#repositories).
|
||||
|
||||
### An existing repository
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Windows release signing
|
||||
|
||||
The reusable NuGet and Velopack builders support Azure Artifact Signing over GitHub
|
||||
OIDC. Reuse an existing validated signing account and active PublicTrust profile;
|
||||
retrieve the actual tenant GUID, regional account URI, profile name and certificate
|
||||
subject from Azure before setting variables. Account/domain names and subscription
|
||||
IDs are not tenant GUIDs. Do not export a private key or use an administrator's
|
||||
password in CI.
|
||||
|
||||
## Trust and configuration
|
||||
|
||||
Configure a reviewed Entra application/service principal per repository, or reuse a
|
||||
suitable existing CI identity while preserving other repositories' credentials.
|
||||
Use the issuer `https://token.actions.githubusercontent.com`, audience
|
||||
`api://AzureADTokenExchange` and subject `repo:OWNER/REPOSITORY:environment:release`.
|
||||
Protect the `release` environment against untrusted refs. Assign the built-in
|
||||
**Artifact Signing Certificate Profile Signer** at the existing certificate profile
|
||||
scope; Azure Owner/Contributor is not a signing permission.
|
||||
|
||||
Set these environment variables using verified deployed values:
|
||||
|
||||
| Variable | Value |
|
||||
| --- | --- |
|
||||
| `AZURE_TENANT_ID` | Actual tenant GUID |
|
||||
| `AZURE_CLIENT_ID` | CI application client ID |
|
||||
| `AZURE_SUBSCRIPTION_ID` | Signing subscription ID |
|
||||
| `ARTIFACT_SIGNING_ENDPOINT` | Account's regional HTTPS URI |
|
||||
| `ARTIFACT_SIGNING_ACCOUNT` | Existing account resource name |
|
||||
| `ARTIFACT_SIGNING_PROFILE` | Existing active profile name |
|
||||
| `ARTIFACT_SIGNING_SUBJECT` | Profile certificate's verified subject |
|
||||
|
||||
Callers can retain legacy `TRUSTED_SIGNING_ENDPOINT`, `_ACCOUNT`, `_PROFILE` and
|
||||
`_SUBJECT` names; the new names take precedence. The verified subject is required.
|
||||
Both builders use official full-SHA-pinned `azure/login` v3 and
|
||||
`azure/artifact-signing-action` v2, Windows runners and `AzureCliCredential` only.
|
||||
Signing jobs run in `release` with `contents: read` and `id-token: write`. No
|
||||
pull-request signing path exists. SHA256 file digests and RFC3161 SHA256 timestamps
|
||||
at `http://timestamp.acs.microsoft.com` are mandatory when signing is enabled.
|
||||
|
||||
## Caller inputs and artifact order
|
||||
|
||||
Set `sign: true` and a newline-separated `signing-files` list. Every pattern must
|
||||
match; missing binaries fail rather than quietly omitting a signature.
|
||||
Owned and preserved `.exe`/`.dll` headers must parse before a catalog is written;
|
||||
hash preservation cannot prove an already corrupt source is executable. MSI/MSIX
|
||||
use their package/signature validation paths rather than PE parsing. For NuGet,
|
||||
use exact checkout-relative built DLL paths. For Velopack, use publish-relative
|
||||
paths such as `app/MyApp.exe`, `app/MyApp.dll`, `app/MyApp.Core.dll` and
|
||||
`cli/myapp.exe`. Include all owned modules. Exclude third-party dependencies.
|
||||
|
||||
NuGet signs before `pack --no-build`, then checks the DLLs inside the final packages
|
||||
against their signed originals. Velopack signs owned published binaries, preserves
|
||||
other binary hashes, and uses vpk's signing hook only for its branded Squirrel and
|
||||
execution stubs plus the final Setup/MSI. Final checks validate trust, expected
|
||||
publisher, timestamps and SHA256 for the shipped binaries, require unchanged
|
||||
dependency bytes, and verify Setup's embedded full package matches the verified
|
||||
full update package. Culture-directory satellite assemblies retain their separate
|
||||
paths. Verification precedes upload, SBOM attestations, checksums and provenance.
|
||||
|
||||
Signing or verification failure stops publication when `sign` is true. The default
|
||||
remains false for compatibility; each owner must enable required signing in their
|
||||
caller or block publication during migration. NuGet/ZIP/delta containers are not
|
||||
Authenticode targets. Test reconstructed delta updates on installed channel builds
|
||||
before claiming upgrade coverage. MSIX presets must use the verified subject as
|
||||
their manifest Publisher.
|
||||
|
||||
## Local tools and proof
|
||||
|
||||
Use Microsoft's [current integration instructions](https://learn.microsoft.com/azure/artifact-signing/how-to-signing-integrations)
|
||||
for Artifact Signing Client Tools, matching-architecture SignTool and
|
||||
`Azure.CodeSigning.Dlib.dll`, .NET 8 and the Visual C++ runtime. Metadata fields are
|
||||
`Endpoint`, `CodeSigningAccountName` and `CertificateProfileName`. Authenticate with
|
||||
an identity that has the profile signer role and sign with `/fd SHA256`,
|
||||
`/tr http://timestamp.acs.microsoft.com`, `/td SHA256`, `/dlib` and `/dmdf`.
|
||||
|
||||
Run `signtool verify /pa /all /v` on final installers and extracted shipped binaries.
|
||||
Record publisher, trust, timestamp, exact artifact hashes and the CI run. An active
|
||||
profile, a mocked test or a signed Microsoft tool does not prove an application's
|
||||
signing works. Retain any signing exception until real end-to-end evidence exists.
|
||||
|
||||
## Prepared rollout
|
||||
|
||||
This implementation is prepared against published standards v1.0.0. Preserve that
|
||||
release's metadata, policy changes and signing evidence. Its released Velopack
|
||||
path lacks the explicit owned-file catalog and its NuGet path lacks these signing
|
||||
inputs, so it is not sufficient for an AppKit caller that requires them.
|
||||
|
||||
The prepared workflows reference helper snapshot `4a7f028976163cb80730126eda1b029005803325`,
|
||||
which includes source PE parsing and final missing-dependency checks. These references remain
|
||||
unreleased placeholders until the reviewed helper commit is available on GitHub
|
||||
and the completed builder is published with its proper version comment. Verify
|
||||
that exact helper revision, not an older catalog implementation. Keep consumer
|
||||
publication blocked until the reviewed builder is published, pinned and proven
|
||||
with representative signed artifacts. Local tests do not authorize rollout.
|
||||
@@ -38,7 +38,7 @@ this crosswalk is updated when it becomes final.
|
||||
| **PW.5** Create source code by adhering to secure coding practices | [Coding standards](../standards/coding/README.md), K22-AI-30 |
|
||||
| **PW.6** Configure build processes to improve executable security | Language profiles (warnings as errors, analyzers, deterministic builds), K22-CI-30 |
|
||||
| **PW.7** Review and/or analyze human-readable code | K22-SDLC-12, K22-CI-22 |
|
||||
| **PW.8** Test executable code | [Testing standard](../standards/testing.md) |
|
||||
| **PW.8** Test executable code | [Testing standard](../standards/testing.md), including K22-TEST-25 parser fuzzing where applicable |
|
||||
| **PW.9** Configure software to have secure settings by default | K22-SEC-70, K22-SEC-71 |
|
||||
| **RV.1** Identify and confirm vulnerabilities on an ongoing basis | K22-CI-21, K22-CI-22 (weekly scans), K22-DEP-40, K22-SEC-40 |
|
||||
| **RV.2** Assess, prioritize, and remediate vulnerabilities | K22-SEC-41, K22-SEC-42 |
|
||||
@@ -72,4 +72,5 @@ Scorecard checks and the requirement that drives each:
|
||||
| Vulnerabilities | K22-SEC-41 |
|
||||
| Binary-Artifacts | K22-REPO-05 |
|
||||
| Packaging | K22-REL-11 |
|
||||
| Fuzzing | K22-TEST-23 (where applicable) |
|
||||
| Fuzzing | K22-TEST-25 (mandatory where applicable); K22-TEST-23 negative-test guidance. Detection supports selected integrations and can miss native harnesses |
|
||||
| CII-Best-Practices | K22-REPO-12 (public Product enrollment and maintained assessment); actual badge level is assigned by the provider |
|
||||
@@ -22,7 +22,7 @@ picks them up on open: *Settings → Editor → Code Style* shows **Project** se
|
||||
|
||||
## Per IDE (all projects)
|
||||
|
||||
1. **Code style:** *Settings → Editor → Code Style → ⚙ (Show Scheme Actions) → Import
|
||||
1. **Code style:** *Settings → Editor → Code Style → Show Scheme Actions → Import
|
||||
Scheme → IntelliJ IDEA code style XML* → choose `Kingsrook_Code_Style.xml`.
|
||||
2. **Reformat on save:** *Settings → Tools → Actions on Save* → enable **Reformat code**
|
||||
and **Optimize imports**.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# MSI builder release recovery
|
||||
|
||||
The signed v1.2.0 tag is reserved by a preflight failure: its annotation omitted
|
||||
the required MINOR-release checklist. No1.2.0 assets were published. Preserve
|
||||
that tag and recover through a new1.2.1 release proposal and signed tag.
|
||||
|
||||
The optional MSI implementation is unchanged from merged PR47. Hosted checks
|
||||
and40 signing regressions pass; native MSI compilation and the actual wrapped
|
||||
extraction body preserve all6 signed fixture application binaries. Production
|
||||
MSI signing, installation and distributed-payload verification remain mandatory.
|
||||
|
||||
Before tagging the recovery, complete and link the release checklist, retain full
|
||||
conformance/scanning evidence and verify main/release-proposal gates. Include the
|
||||
checklist URL in the signed annotation even for this PATCH recovery. Publish only
|
||||
after its protected deployment and verify all actual bundle hashes/provenance.
|
||||
|
||||
This preserves existing EXE callers, signing identity/profile/trust, branch/tag
|
||||
protections and public1.1.2 assets. No account onboarding is repeated.
|
||||
+23
-3
@@ -24,10 +24,19 @@ against it before packaging.
|
||||
|
||||
Use `feat` for new guidance and `!` for new obligations or removed allowances as
|
||||
defined in [the versioning policy](../policies/README.md#versioning-of-the-standards).
|
||||
Infrastructure changes appear in release notes and can produce PATCH releases;
|
||||
`test`, `style`, and `chore` alone do not produce release notes. Review the proposed
|
||||
Infrastructure changes can produce PATCH releases;
|
||||
`test`, `style`, and `chore` alone do not trigger a version proposal. Review the proposed
|
||||
version and notes before merging. A `breaking` label alone does not change the version.
|
||||
|
||||
PR headers and footers are explicitly configured in `release-please-config.json`.
|
||||
Native GitHub release notes use the plain-language categories in `.github/release.yml`,
|
||||
keeping breaking changes visible without decorative symbols. Versions still come
|
||||
from Conventional Commits; note entries link to reviewed PRs. Maintainers review
|
||||
breaking-change descriptions before publication. Repository text and automation
|
||||
output use professional wording, without emojis, joke copy or bot sound effects.
|
||||
Only generated `CHANGELOG.md` permits native spacing, bare links and repeated
|
||||
category headings; other documentation retains the normal Markdown rules.
|
||||
|
||||
## One-time maintainer setup
|
||||
|
||||
1. Keep **Settings → General → Releases → Enable release immutability** enabled.
|
||||
@@ -123,7 +132,7 @@ same Git/archive tool versions. The builder refuses an existing output directory
|
||||
The initial `v0.1.0` tag was reserved by a workflow-startup failure; no bundle was
|
||||
published for it. The `v0.1.1` publisher then stopped before draft creation because
|
||||
the caller omitted the environment secret contract. Both tags remain reserved;
|
||||
`v0.1.2` is the planned first published bundle. Keep these tags unchanged; use a new
|
||||
`v0.1.2` is the first published bundle. Keep these tags unchanged; use a new
|
||||
version when a tagged workflow needs a source fix.
|
||||
|
||||
Never replace published assets or move/reuse a version tag. Fix a published error
|
||||
@@ -136,3 +145,14 @@ from `autorelease: pending` to `autorelease: tagged`; do not rebuild the release
|
||||
References: [GitHub immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases),
|
||||
[enabling immutability](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/establish-provenance-and-integrity/prevent-release-changes),
|
||||
and [Release Please token behavior](https://github.com/googleapis/release-please-action#other-actions-on-release-please-prs).
|
||||
|
||||
## Known limitations
|
||||
|
||||
[Issue #22](https://github.com/KofTwentyTwo/standards/issues/22) tracks Windows App SDK
|
||||
publish resources and the missing shared launch-smoke gate in `release-velopack.yml`.
|
||||
Windows consumers must keep `WindowsAppSDKSelfContained` and `WindowsPackageType`
|
||||
consistent between build and publish, or clean intermediate output before publish.
|
||||
Run smoke tests against the published executable and test the installer on a clean
|
||||
machine before a stable application release. This remains a consumer-side check
|
||||
until the shared workflow implements it. The standards bundle contains source and
|
||||
configuration; it does not contain a compiled Windows application.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"badge": {
|
||||
"applicability": "required",
|
||||
"reason": "Public open-source Product repository distributing standards and executable tooling.",
|
||||
"projectUrl": "https://www.bestpractices.dev/projects/15366",
|
||||
"evidence": "docs/security/best-practices.md"
|
||||
},
|
||||
"fuzzing": {
|
||||
"applicability": "required",
|
||||
"reason": "The conformance checker parses contributor-controlled workflows and exception records; unsafe acceptance can hide failed requirements.",
|
||||
"targets": ["tools/Test-RepoConformance.ps1"],
|
||||
"command": "./tools/Test-RepoConformance.Fuzz.ps1",
|
||||
"workflow": ".github/workflows/ci.yml",
|
||||
"evidence": "docs/security/fuzzing.md"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# OpenSSF Best Practices evidence
|
||||
|
||||
## Enrollment and review
|
||||
|
||||
**Repository:** <https://github.com/KofTwentyTwo/standards>. **Owner:** @KofTwentyTwo.
|
||||
**Assessment:** [project 15366](https://www.bestpractices.dev/en/projects/15366/passing).
|
||||
**Level:** Passing. **Reviewed and earned:** 2026-10-10.
|
||||
**Next review:** 2027-01-08, or before the next MINOR or MAJOR release, whichever
|
||||
occurs first. Reassess at least every 90 days.
|
||||
|
||||
The public assessment identifies this repository and displays the passing badge.
|
||||
The maintainer completed the self-assessment, including personal attestations.
|
||||
This badge records voluntary maintainer self-certification.
|
||||
The enrollment-only exception [EX-0007](../../exceptions/register.md#ex-0007) is
|
||||
closed with the actual README badge and project URL recorded in
|
||||
[assurance.json](assurance.json).
|
||||
|
||||
## Criterion evidence matrix
|
||||
|
||||
IDs below omit the provider's `0.` level prefix. **Evidence** means source exists;
|
||||
**Review** marks ongoing operational evidence or human judgment to revalidate;
|
||||
**Proposed N/A** preserves applicability reasoning for the next review. These are
|
||||
local evidence notes; the linked public assessment holds the submitted answers.
|
||||
|
||||
| Criteria | Evidence and ongoing review | State |
|
||||
| --- | --- | --- |
|
||||
| `description_good`, `documentation_basics` | [README](../../README.md) describes standards, tooling and adoption | Evidence |
|
||||
| `interact`, `discussion`, `report_process`, `report_tracker`, `report_archive` | Public [issues](https://github.com/KofTwentyTwo/standards/issues), templates and [CONTRIBUTING](../../CONTRIBUTING.md) | Evidence |
|
||||
| `contribution`, `contribution_requirements` | CONTRIBUTING documents proposals, signed commits, DCO, review and gates | Evidence |
|
||||
| `floss_license`, `floss_license_osi`, `license_location` | Root [MIT](../../LICENSE) for code, configs and templates; [CC BY 4.0](../../LICENSE-docs) for prose; attributed Apache-2.0 material in [NOTICE](../../NOTICE) | Evidence |
|
||||
| `documentation_interface` | [Tool help and local commands](../../CONTRIBUTING.md#running-the-checks-locally), [release instructions](../releases.md); owner checks shipped tools' help completeness | Review |
|
||||
| `sites_https`, `delivery_mitm` | GitHub repository and release delivery use HTTPS; [verification](../../standards/releases.md#verifying-a-release) checks provenance | Evidence |
|
||||
| `english` | Repository documentation and contribution guidance are in English | Evidence |
|
||||
| `maintained`, `report_responses`, `enhancement_responses` | Recent PRs/issues exist; a new project has limited response history. Assess actual responsiveness, not a policy promise | Review |
|
||||
| `repo_public`, `repo_track`, `repo_interim`, `repo_distributed` | Public Git repository retains reviewed changes; signed history and protected main | Evidence |
|
||||
| `version_unique`, `version_semver`, `version_tags`, `release_notes` | Immutable [v0.1.2](https://github.com/KofTwentyTwo/standards/releases/tag/v0.1.2), committed version and [changelog](../../CHANGELOG.md); failed unpublished tags are reserved | Evidence |
|
||||
| `release_notes_vulns` | [Release standard](../../standards/releases.md) requires security fix notes; no published vulnerability fix history to attest to yet | Review |
|
||||
| `vulnerability_report_process`, `vulnerability_report_private` | [SECURITY](../../SECURITY.md) links private GitHub reporting and supported versions | Evidence |
|
||||
| `vulnerability_report_response` | Maintainer confirmed actual response compliance on 2026-10-10 in the public assessment; revalidate report history, not just SECURITY targets | Review |
|
||||
| `build`, `build_common_tools`, `build_floss_tools` | [Bundle builder](../../tools/Build-StandardsPackage.ps1) uses Git archives and PowerShell; prerequisites and release steps are documented | Evidence |
|
||||
| `test`, `test_invocation`, `test_policy`, `tests_are_added`, `tests_documented_added` | CONTRIBUTING requires behavioral regression tests and documents checker, package and fuzz commands | Evidence |
|
||||
| `test_continuous_integration` | [CI](../../.github/workflows/ci.yml) runs tests on PR/main, schedules and tag publication gates | Evidence |
|
||||
| `test_most` | Parser properties, helper self-tests and package tests exist; measured whole-tool coverage is not established | Suggested: Unmet |
|
||||
| `warnings`, `warnings_fixed` | Markdown/workflow gates reject findings; revalidate applicable PowerShell warning handling | Review |
|
||||
| `warnings_strict` | Maximal PowerShell warning/linter coverage across shipped tooling is not established by Actions CodeQL | Suggested: Unmet |
|
||||
| `know_secure_design`, `know_common_errors` | Primary maintainer personally attested on 2026-10-10; supporting [threat model](threat-model.md), [security policy](../../policies/security-program.md) and parser regressions remain review evidence | Review |
|
||||
| `crypto_published`, `crypto_call`, `crypto_floss`, `crypto_keylength`, `crypto_working`, `crypto_weaknesses` | No custom product cryptography. GitHub TLS, SSH signing and attestations are delegated mechanisms; assess whether delegation meets each criterion or permits N/A | Review |
|
||||
| `crypto_pfs` | No application server or product-controlled TLS configuration; document the hosted-service boundary if N/A is permitted | Proposed N/A |
|
||||
| `crypto_password_storage` | No application passwords, password database or credential-storage implementation | Proposed N/A |
|
||||
| `crypto_random` | The seeded fuzz PRNG generates only synthetic tests, never security keys/nonces. Cryptographic randomness is delegated to signing/hosting providers | Review |
|
||||
| `delivery_unsigned` | Release assets have checksums, verified source, build provenance and GitHub immutable-release attestation; see [verification commands](../releases.md) | Evidence |
|
||||
| `vulnerabilities_fixed_60_days`, `vulnerabilities_critical_fixed` | [Security program](../../policies/security-program.md#vulnerability-management) defines deadlines; maintainer must check current findings and known unpatched issues before answering | Review |
|
||||
| `no_leaked_credentials` | Required [gitleaks and secret scans](../../.github/workflows/security.yml); scans support, but cannot prove, a universal absence claim | Review |
|
||||
| `static_analysis`, `static_analysis_common_vulnerabilities`, `static_analysis_often` | [CodeQL Actions](../../.github/workflows/codeql.yml), zizmor and actionlint in required security gates; document PowerShell analysis coverage accurately | Evidence |
|
||||
| `static_analysis_fixed` | Gates reject applicable findings; review unresolved alerts and [Scorecard dispositions](scorecard-dispositions.md) before claiming all important findings fixed | Review |
|
||||
| `dynamic_analysis`, `dynamic_analysis_enable_assertions` | Executed [fuzz properties](fuzzing.md), deterministic helper and isolated package tests; assertions fail the job | Evidence |
|
||||
| `dynamic_analysis_unsafe` | Shipped tooling is managed PowerShell, not unsafe native-language implementation; owner validates applicability | Proposed N/A |
|
||||
| `dynamic_analysis_fixed` | Generated-input findings gained deterministic regressions; review all future campaign failures before assessment updates | Review |
|
||||
|
||||
## Maintaining the assessment
|
||||
|
||||
1. Review the public answers against current criteria and repository evidence by the
|
||||
next review date and before each MINOR or MAJOR release.
|
||||
2. Reconfirm human knowledge, unresolved findings and actual response history with
|
||||
the maintainer; update answers when evidence or applicability changes.
|
||||
3. Keep the provider's live badge, project URL and this review record synchronized.
|
||||
Downstream repositories need their own assessment and badge.
|
||||
4. Improve the unmet suggested criteria `test_most` and `warnings_strict`; passing
|
||||
does not establish whole-tool coverage or maximal warning coverage. Track the
|
||||
shared release smoke-test gap in [issue #22](https://github.com/KofTwentyTwo/standards/issues/22).
|
||||
|
||||
Downstream repositories use the [assessment template](../../templates/repo/docs/security/best-practices.md)
|
||||
and their own evidence and project ID; EX-0007 does not apply to them.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Conformance parser fuzzing
|
||||
|
||||
## Scope and properties
|
||||
|
||||
K22-TEST-25 applies to shipped tooling even though most repository content is prose.
|
||||
`tools/Test-RepoConformance.Fuzz.ps1` generates synthetic workflow and exception input
|
||||
for the pure helpers in `tools/Test-RepoConformance.ps1`. It varies quotes, indentation,
|
||||
comments, LF/CRLF, scalar indicators, action references and attacker expressions.
|
||||
Independent construction supplies the expected security decision: only full SHA
|
||||
pins pass; credential persistence and untrusted expressions are detected; expressions
|
||||
in environment fields remain outside script bodies; exceptions match their exact
|
||||
scope and must be open and unexpired. Bounded malformed ASCII inputs also exercise
|
||||
parser robustness. These properties test unsafe acceptance, not only crashes.
|
||||
|
||||
This is native generated-input fuzzing, not coverage-guided instrumentation or a
|
||||
complete YAML validator. Zizmor remains the authoritative workflow security gate.
|
||||
Scorecard currently detects selected integrations; it may continue reporting zero
|
||||
for this PowerShell harness. Do not claim OSS-Fuzz or ClusterFuzzLite enrollment.
|
||||
|
||||
## Running and replaying
|
||||
|
||||
PowerShell 7.4 or later is the only dependency; no network or profile is used.
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File tools/Test-RepoConformance.Fuzz.ps1 -Seed 22023 -Iterations 1000
|
||||
pwsh -NoProfile -File tools/Test-RepoConformance.Fuzz.ps1 -Seed 99173 -Iterations 1000
|
||||
# Weekly and pre-MINOR/MAJOR campaigns: run both seeds at 10000 iterations.
|
||||
pwsh -NoProfile -File tools/Test-RepoConformance.Fuzz.ps1 -Seed 22023 -Iterations 10000
|
||||
pwsh -NoProfile -File tools/Test-RepoConformance.Fuzz.ps1 -Seed 99173 -Iterations 10000
|
||||
```
|
||||
|
||||
Failures print synthetic input, property, iteration and seed as JSON. Replay with
|
||||
that seed and at least that iteration count against the same checkout and PowerShell
|
||||
runtime. Minimize the input into a named `-SelfTest` regression, then fix the parser
|
||||
and rerun both seeds. Report suspected vulnerabilities through [SECURITY.md](../../SECURITY.md).
|
||||
|
||||
Inputs are bounded to small workflow fragments and fewer than 128 random ASCII
|
||||
characters; iterations are limited to 100000. Generated scripts are never executed.
|
||||
The harness does not write files, launch tools, or request repository credentials.
|
||||
The required `ci / conformance` job runs both seeds on PRs and `main`, and 10000
|
||||
iterations per seed on weekly schedules and release-tag validation, within its
|
||||
10-minute timeout. Future failing seeds must be retained alongside regression cases.
|
||||
|
||||
## Review record
|
||||
|
||||
- **Owner:** @KofTwentyTwo; **reviewed:** 2026-10-10; **next review:** 2027-01-08.
|
||||
- Seed 22023 exposed quoted-checkout and explicit-indentation block-scalar gaps;
|
||||
both now have deterministic self-test regressions. This is not a coverage claim.
|
||||
- Reassess targets when parser boundaries change; generated data alone does not
|
||||
establish completeness or substitute for ordinary package and checker tests.
|
||||
@@ -14,8 +14,14 @@ changes or predict future maintenance.
|
||||
|
||||
Keep the accepted risk visible; dismissal is not a claim that the missing second
|
||||
maintainer exists. Do not disable Scorecard or weaken protections. Reopen and
|
||||
reassess these dispositions when their assumptions change. The LOW best-practices
|
||||
badge and MEDIUM fuzzing recommendations remain open for follow-up.
|
||||
reassess these dispositions when their assumptions change. The
|
||||
[badge assessment](best-practices.md) records the passing badge for
|
||||
[project 15366](https://www.bestpractices.dev/projects/15366), earned on 2026-10-10;
|
||||
EX-0007 is closed. Let the next Scorecard scan reassess the badge finding rather
|
||||
than claiming a new score before it runs. The MEDIUM fuzzing recommendation remains
|
||||
visible: [native parser fuzzing](fuzzing.md) supplies generated security properties and
|
||||
regressions. Scorecard may not recognize this PowerShell harness; do not label a
|
||||
score of zero as proof that tests did not run or claim a supported integration.
|
||||
|
||||
No OpenVEX statement is fabricated for these process findings: no vulnerable shipped
|
||||
component or exploit was identified. Any actual vulnerability present but not
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Threat model: standards distribution
|
||||
|
||||
- **Version reviewed:** v0.1.0 release-bundle implementation
|
||||
- **Version reviewed:** v1.0.0 standards baseline
|
||||
- **Last updated:** 2026-10-10
|
||||
- **Status:** Implementation reviewed; provisioning is verified in the release checklist.
|
||||
|
||||
@@ -71,6 +71,33 @@ trust boundaries change.
|
||||
|
||||
## Review log
|
||||
|
||||
- **Prepared AppKit signing extension, 2026-10-10:** Explicit owned-file selection
|
||||
excludes third-party binaries from re-signing, retains their hashes and requires
|
||||
their presence in final archives. Catalog preparation parses owned/preserved
|
||||
`.exe`/`.dll` headers before writes; already corrupt dependencies cannot pass
|
||||
solely because hashes are unchanged. Final owned binaries and installers require
|
||||
publisher, trust, SHA256 and timestamp checks before upload or publication. The
|
||||
SignTool packaging wrapper preserves valid signatures and refuses invalid ones.
|
||||
NuGet signs before packing and checks embedded DLL bytes. Existing protected
|
||||
release-environment OIDC boundaries remain; real AppKit identity/profile access
|
||||
and signatures are still unproved. The helper reference and consumer rollout
|
||||
remain pending review/publication; this entry is not deployment evidence.
|
||||
|
||||
- **2026-10-10, v1.0.0:** Reviewed changes since v0.1.2: mandatory applicable badge
|
||||
participation and parser fuzzing, completed passing self-assessment, professional
|
||||
release text, scoped frontend audit exception, and unsigned-file-only Windows
|
||||
signing with verification before packing. Distribution credentials, channels,
|
||||
environment restrictions and provenance boundaries remain unchanged. Badge
|
||||
metadata adds a public provider link; fuzzing executes synthetic local inputs.
|
||||
Signing continues to use the Windows consumer's existing Azure OIDC boundary.
|
||||
The most likely release interruption remains a failed gate or unpublished draft;
|
||||
recovery preserves tags and immutable assets. The highest-impact distribution
|
||||
threat remains a compromised credential or pinned Action, constrained by the
|
||||
existing scoped tokens, protected environments and required checks. Windows
|
||||
publish/resource and launch-smoke limitations remain tracked in
|
||||
[issue #22](https://github.com/KofTwentyTwo/standards/issues/22); consumer workarounds
|
||||
and rehearsal expectations are recorded in [release guidance](../releases.md#known-limitations).
|
||||
|
||||
- **2026-10-10, v0.1.2:** Reviewed the explicit reusable-workflow secret contract
|
||||
after the unpublished v0.1.1 publisher failed. Only the named App key is passed;
|
||||
its value remains restricted to the reviewer-protected tag environment. Nested
|
||||
|
||||
+175
-1
@@ -6,11 +6,17 @@ Open and closed exceptions to the KofTwentyTwo standards. The process is in
|
||||
| ID | Status | Requirements | Scope | Expires |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [EX-0001](#ex-0001) | Open | K22-REPO-20 (approval count), OSPS-QA-07.01 | All repositories | When a second maintainer joins; reviewed 2027-10-09 |
|
||||
| [EX-0002](#ex-0002) | Open | K22-REL-05 (Authenticode signing of Windows binaries) | Windows desktop apps (gclo, AppKit samples, future apps) | When the Azure Trusted Signing identity is validated, at the latest 2027-04-09 |
|
||||
| [EX-0002](#ex-0002) | Open (closed for gclo 2026-10-11) | K22-REL-05 (Authenticode signing of Windows binaries) | Windows desktop apps not yet on `sign: true` (AppKit samples, future apps) | When each app turns on `sign: true` in the shared release workflow, at the latest 2027-04-09 |
|
||||
| [EX-0003](#ex-0003) | Open | K22-DEP-02 (license allowlist) | gclo | Reviewed yearly, next on 2027-10-09 |
|
||||
| [EX-0004](#ex-0004) | Open | K22-CI-30, K22-REL-04 (shared release workflow) | gclo | When the shared reusable release workflow ships, at the latest 2027-04-09 |
|
||||
| [EX-0005](#ex-0005) | Open | K22-AI-10 (agents via PR only), K22-AI-11 (in part: unconfirmed pushes) | second-brain, nix, Praetor, Jellyfin | Reviewed yearly, next on 2027-10-10 |
|
||||
| [EX-0006](#ex-0006) | Open | K22-CI-16 (tag-only environment secrets) | standards | 2027-04-10; review 2027-01-10 |
|
||||
| [EX-0007](#ex-0007) | Closed | K22-REPO-12 (enrollment) | KofTwentyTwo/standards | Closed 2026-10-10 |
|
||||
| [EX-0008](#ex-0008) | Open | K22-CI-21 (HIGH dependency finding) | Website-Frontend, development-only braces 3.0.3 | 2026-11-10; review 2026-10-17 |
|
||||
| [EX-0009](#ex-0009) | Open | K22-REPO-20 (linear history), K22-REPO-22 (squash only) | Website-Backend, Website-Frontend, Munitor, cluster-gitops | Reviewed yearly, next on 2027-10-12 |
|
||||
| [EX-0010](#ex-0010) | Open | K22-CI-01, K22-CI-10 to K22-CI-24, K22-CI-30 (GitHub Actions gates and reusable workflows) | Website-Backend, Website-Frontend, Munitor | 2027-04-12; review 2027-01-12 |
|
||||
| [EX-0011](#ex-0011) | Open | K22-CODE-TS-01, K22-CODE-SH-02 (Kingsrook 3-space layout) | Website-Frontend (TypeScript), Munitor (shell) | 2027-04-12; review 2027-01-12 |
|
||||
| [EX-0012](#ex-0012) | Open | K22-CODE-TF-04 (state never committed), K22-CODE-TF-05 (no secrets in `.tfvars`) | Praetor | Reviewed yearly, next on 2027-10-12 |
|
||||
|
||||
## EX-0001
|
||||
|
||||
@@ -59,6 +65,13 @@ Open and closed exceptions to the KofTwentyTwo standards. The process is in
|
||||
EX-0004); releases before that have neither.
|
||||
- **Expires:** when the signing identity is validated and the shared release workflow
|
||||
signs Windows binaries; at the latest 2027-04-09.
|
||||
- **Closed for gclo:** 2026-10-11. The signing identity (Azure Artifact Signing account
|
||||
`kof22signing`, Public Trust profile, publisher "James Maes") is validated and the
|
||||
shared workflow signs on `sign: true` (standards#28/#29): gclo v1.0.2-beta.3,
|
||||
v1.1.0-beta.1 and v1.1.0 ship Setup.exe, Update.exe and every exe/dll
|
||||
Authenticode-signed (SHA-256, RFC 3161), verified with `signtool verify /pa /all` by
|
||||
gclo's `verify-signatures` job and by hand (289 files, 0 failures). The exception
|
||||
stays open only for Windows apps that have not turned `sign: true` on yet.
|
||||
- **Owner:** the maintainer.
|
||||
|
||||
## EX-0003
|
||||
@@ -169,3 +182,164 @@ Open and closed exceptions to the KofTwentyTwo standards. The process is in
|
||||
- **Expires:** 2027-04-10, or earlier when version proposals use credential federation.
|
||||
- **Owner:** the maintainer.
|
||||
- **Review date:** 2027-01-10.
|
||||
|
||||
## EX-0007
|
||||
|
||||
**Best Practices enrollment temporarily blocked by provider anti-spam**
|
||||
|
||||
- **Requirements:** `K22-REPO-12` enrollment and the actual README badge only.
|
||||
- **Scope:** `KofTwentyTwo/standards`; no downstream repository is covered.
|
||||
- **Reason:** on 2026-10-10 the maintainer could not sign in because of the provider's
|
||||
anti-spam restriction and explicitly deferred enrollment. No project ID existed
|
||||
when this exception was approved.
|
||||
- **Risk:** consumers could not inspect a registered provider assessment during the
|
||||
enrollment deferral.
|
||||
- **Compensating controls (while open):** publish the [criterion evidence](../docs/security/best-practices.md)
|
||||
and pending answers; retain existing security/PR gates and parser fuzzing. Do not
|
||||
claim participation or a passing badge. Retry normal login once the restriction
|
||||
clears, register the exact repository URL, and close this exception through a PR.
|
||||
- **Expires:** 2026-11-10 (UTC), or successful enrollment and README badge publication,
|
||||
whichever occurs first. **Review:** 2026-10-17.
|
||||
- **Owner:** @KofTwentyTwo. Approval is recorded by the normal exception-register PR;
|
||||
this entry does not authorize bypassing CI or publishing an unreviewed assessment.
|
||||
- **Closed:** 2026-10-10. [Project 15366](https://www.bestpractices.dev/projects/15366)
|
||||
identifies this repository and has earned the passing badge. The actual linked
|
||||
badge is in [README](../README.md), the project URL is in
|
||||
[assurance.json](../docs/security/assurance.json), and the
|
||||
[assessment record](../docs/security/best-practices.md) sets the next review date.
|
||||
|
||||
## EX-0008
|
||||
|
||||
**Temporary development dependency audit exception for Website-Frontend**
|
||||
|
||||
- **Requirements:** `K22-CI-21` blocks HIGH and CRITICAL findings in the resolved
|
||||
dependency graph.
|
||||
- **Scope:** `KofTwentyTwo/Website-Frontend` only; development-only `braces` 3.0.3,
|
||||
[GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm)
|
||||
/ CVE-2026-93687 and npm's propagated findings for that same advisory. No other
|
||||
advisory, runtime dependency or CRITICAL finding is covered.
|
||||
- **Reason:** the Next ESLint tooling depends on the affected package and no patched
|
||||
npm release exists as of 2026-10-10. npm suggests an unrelated parent downgrade;
|
||||
that does not establish a patched braces release.
|
||||
- **Risk:** a deliberately deeply nested caller-supplied brace pattern can exhaust
|
||||
the stack and stop a lint/build process. Website requests and article Markdown do
|
||||
not control the lint glob configuration. The dependency remains vulnerable.
|
||||
- **Compensating controls:** the complete audit still runs. A separate production
|
||||
audit must have zero HIGH/CRITICAL findings. The exception checker validates the
|
||||
advisory ID, package, locked version, development-only package paths and expiry;
|
||||
rejects other HIGH/CRITICAL findings, malformed reports and audit errors; and
|
||||
requires reassessment if the registry reports a new braces release. Lint, tests,
|
||||
build, secret scanning, SAST and Trivy remain enabled. No global scanner exclusion
|
||||
is permitted. Website-Frontend configuration cites this ID.
|
||||
- **Expires:** 2026-11-10 at 00:00 UTC, or a patched upstream release, whichever
|
||||
occurs first. A newer upstream release blocks acceptance pending reassessment.
|
||||
- **Owner:** James Maes, repository maintainer.
|
||||
- **Review date:** 2026-10-17. Remove the consumer exception and close this entry once
|
||||
the affected dependency is replaced or patched.
|
||||
|
||||
## EX-0009
|
||||
|
||||
**GitFlow with merge commits for Munitor-released and GitOps repositories**
|
||||
|
||||
- **Requirements:** `K22-REPO-20` (linear history on `main`) and `K22-REPO-22`
|
||||
(squash-only merges).
|
||||
- **Scope:** `KofTwentyTwo/Website-Backend`, `KofTwentyTwo/Website-Frontend`,
|
||||
`KofTwentyTwo/Munitor` (`develop` and `main`, `feature/*` and `hotfix/*` branches)
|
||||
and `KofTwentyTwo/cluster-gitops` (`main`, merge commits).
|
||||
- **Reason:** these repositories run GitFlow with merge commits. In Website-Backend
|
||||
and Website-Frontend, `feature/*` and `hotfix/*` branches (and `develop`
|
||||
promotions) merge to `main`, which releases and deploys through Munitor, and a
|
||||
`main` to `develop` back-merge follows. Munitor itself merges `feature/*` into
|
||||
`develop`, `release/X.Y.Z` into `main`, back-merges `main` into `develop`, and a
|
||||
`vX.Y.Z` tag publishes the orb. Squash
|
||||
or rebase merges break the ancestry between `develop` and `main` that the pipeline
|
||||
relies on. cluster-gitops keeps merge commits so each change set applied by ArgoCD
|
||||
stays one reviewable unit next to the automated image-bump commits.
|
||||
- **Risk:** non-linear history is harder to bisect and to read, and a merge commit can
|
||||
carry several commits that were never reviewed individually.
|
||||
- **Compensating controls:** every human change to the Website repositories and
|
||||
Munitor reaches `main` through a pull request; repository rulesets require signed
|
||||
commits and the CircleCI checks on `main` and `develop`. cluster-gitops (Internal
|
||||
tier, no CI) requires signed commits and blocks deletion and force pushes on `main`;
|
||||
the Munitor deploy step writes image bumps to it directly as GitHub-signed commits
|
||||
(GraphQL `createCommitOnBranch`), with no bypass actor; release tags stay protected
|
||||
(`K22-REPO-21`); merged branches are deleted.
|
||||
- **Expires:** reviewed yearly, next on 2027-10-12, or earlier if Munitor gains a
|
||||
squash-compatible release mode.
|
||||
- **Owner:** the maintainer.
|
||||
|
||||
## EX-0010
|
||||
|
||||
**CircleCI instead of the GitHub Actions reusable workflows**
|
||||
|
||||
- **Requirements:** `K22-CI-01` (required checks run by the reusable workflows),
|
||||
`K22-CI-10` to `K22-CI-24` as written for GitHub Actions, and `K22-CI-30`
|
||||
(releases built by the shared reusable release workflow).
|
||||
- **Scope:** `KofTwentyTwo/Website-Backend`, `KofTwentyTwo/Website-Frontend` and
|
||||
`KofTwentyTwo/Munitor` (the CircleCI orb that builds, releases and deploys them).
|
||||
- **Reason:** these repositories build, publish container images and deploy to the
|
||||
k8s-galaxy cluster through the Munitor CircleCI orb, which predates the reusable
|
||||
GitHub Actions workflows and holds the deploy integration (image publishing,
|
||||
cluster-gitops version bumps). Porting the pipeline is a separate project.
|
||||
- **Risk:** the gates are implemented per orb rather than by the audited reusable
|
||||
workflows, so a gate can be missing or weaker than the standard (for example
|
||||
CodeQL, zizmor, Scorecard and SLSA provenance have no CircleCI equivalent here).
|
||||
- **Compensating controls:** the CircleCI checks are required by the repository
|
||||
rulesets (Website: `build-and-test`, `secrets-scan`, `sast-scan`; Munitor:
|
||||
`orb-tools/lint`, `pack`, `review`, `test`); the orb runs the test suite, gitleaks and Trivy where configured;
|
||||
GitHub-side controls still apply (secret scanning with push protection, Dependabot
|
||||
alerts and security updates, code scanning default setup where available); orbs are
|
||||
pinned to exact versions; signed commits are required.
|
||||
- **Expires:** 2027-04-12, or when the repositories move to the reusable workflows;
|
||||
review 2027-01-12.
|
||||
- **Owner:** the maintainer.
|
||||
|
||||
## EX-0011
|
||||
|
||||
**Website-Frontend and Munitor keep 2-space formatting**
|
||||
|
||||
- **Requirements:** `K22-CODE-TS-01` (Kingsrook layout through the KofTwentyTwo ESLint
|
||||
configuration: 3-space indent, Allman braces) and `K22-CODE-SH-02` (3-space shell
|
||||
indentation).
|
||||
- **Scope:** `KofTwentyTwo/Website-Frontend` TypeScript, JavaScript, CSS and JSON
|
||||
sources; `KofTwentyTwo/Munitor` Bash scripts and templates.
|
||||
- **Reason:** the Next.js code base was written in the framework's default 2-space
|
||||
layout and is formatted by Prettier 3 (with `prettier-plugin-tailwindcss`) plus
|
||||
ESLint 9 with `eslint-config-next`, which the TypeScript profile does not use.
|
||||
Munitor's 100+ orb scripts and templates use 2-space indentation. Reformatting
|
||||
either is a large whitespace-only diff that triggers releases (a Website deploy or
|
||||
a new orb version for every consumer) and obscures history.
|
||||
- **Risk:** inconsistent layout across KofTwentyTwo TypeScript code; no functional
|
||||
risk.
|
||||
- **Compensating controls:** `.editorconfig` comes from the standard, with only the
|
||||
indent size overridden for these file types and a comment citing this exception;
|
||||
the existing lint, type and orb checks still gate CI.
|
||||
- **Expires:** 2027-04-12, or when the code base is reformatted and adopts
|
||||
`configs/typescript/eslint.config.mjs` (Website-Frontend) and the orb scripts are
|
||||
reformatted with shfmt (Munitor); review 2027-01-12.
|
||||
- **Owner:** the maintainer.
|
||||
|
||||
## EX-0012
|
||||
|
||||
**Praetor commits git-crypt-encrypted OpenTofu state and variable files**
|
||||
|
||||
- **Requirements:** `K22-CODE-TF-04` (state is never committed; remote state with
|
||||
locking and encryption) and `K22-CODE-TF-05` (no secrets in `.tfvars` files).
|
||||
- **Scope:** `KofTwentyTwo/Praetor` (Internal tier, private): the local-backend roots
|
||||
under `terraform/` and `unifi/` (17 `*.tfstate*` files) and their 10 `*.tfvars`
|
||||
files.
|
||||
- **Reason:** Praetor provisions the homelab that would host any remote state backend
|
||||
(the cluster, the NAS and Infisical are themselves managed here), so a remote backend
|
||||
creates a bootstrap dependency on the infrastructure it builds. Committed, encrypted
|
||||
local state is the recovery path when that infrastructure is down.
|
||||
- **Risk:** state and variable files contain credentials; anyone holding the git-crypt
|
||||
key or a decrypted clone can read them. Local state has no locking, so two
|
||||
concurrent applies could corrupt it.
|
||||
- **Compensating controls:** `.gitattributes` encrypts every `*.tfstate*`, `*.tfvars*`,
|
||||
kubeconfig, talosconfig and `*secret*` path with git-crypt (GPG key held by the
|
||||
maintainer); `git-crypt status` reports no unencrypted secret files; the repository
|
||||
is private; applies are human-gated by the maintainer in session (`EX-0005`), which
|
||||
serializes them; secrets are moving into Infisical with `sensitive = true` outputs.
|
||||
- **Expires:** reviewed yearly, next on 2027-10-12, or when the roots move to an
|
||||
encrypted remote backend.
|
||||
- **Owner:** the maintainer.
|
||||
+7
-11
@@ -1,16 +1,12 @@
|
||||
# How these standards work
|
||||
|
||||
> **Status: draft, pre-v1.0.0.** The requirements are written but have not been
|
||||
> released. Until the first release (`v1.0.0`):
|
||||
>
|
||||
> - the automated checks and conformance tooling are still being built, so no
|
||||
> requirement is yet *Enforced* or *Verified* (see [compliance](../compliance/README.md));
|
||||
> - no KofTwentyTwo repository claims conformance; adoption is in progress, starting
|
||||
> with this repository, [AppKit](https://github.com/KofTwentyTwo/AppKit), and
|
||||
> [gclo](https://github.com/KofTwentyTwo/gclo);
|
||||
> - commitments to people outside the project (vulnerability response and fix times in
|
||||
> the [security program](security-program.md#vulnerability-management)) are targets
|
||||
> that the maintainer works to, and become binding commitments at `v1.0.0`.
|
||||
> **Status: versioned baseline.** Published tags identify immutable requirements.
|
||||
> Each repository adopts a specific version and records its own conformance evidence
|
||||
> and approved exceptions. [Compliance](../compliance/README.md) labels describe the
|
||||
> evidence for each mapped control; publication alone does not verify every consumer.
|
||||
> External vulnerability response and fix deadlines in the
|
||||
> [security program](security-program.md#vulnerability-management) become binding
|
||||
> commitments with adoption of the stable `v1.0.0` baseline.
|
||||
|
||||
This repository is the single source of truth for how KofTwentyTwo builds, secures,
|
||||
and ships software. It has three layers, each more specific than the last:
|
||||
|
||||
@@ -2,18 +2,11 @@
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"release-type": "simple",
|
||||
"include-component-in-tag": false,
|
||||
"bump-minor-pre-major": false,
|
||||
"changelog-type": "github",
|
||||
"pull-request-header": "Release proposal generated from reviewed changes on main. Review the version, changes, and validation before merging.",
|
||||
"pull-request-footer": "Generated by [Release Please](https://github.com/googleapis/release-please) using this repository's release configuration.",
|
||||
"initial-version": "0.1.0",
|
||||
"changelog-sections": [
|
||||
{ "type": "security", "section": "Security", "hidden": false },
|
||||
{ "type": "feat", "section": "Features", "hidden": false },
|
||||
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
|
||||
{ "type": "docs", "section": "Documentation", "hidden": false },
|
||||
{ "type": "perf", "section": "Performance", "hidden": false },
|
||||
{ "type": "refactor", "section": "Refactoring", "hidden": false },
|
||||
{ "type": "build", "section": "Build", "hidden": false },
|
||||
{ "type": "ci", "section": "Automation", "hidden": false },
|
||||
{ "type": "revert", "section": "Reverts", "hidden": false }
|
||||
],
|
||||
"packages": {
|
||||
".": { "version-file": "version.txt" }
|
||||
}
|
||||
|
||||
@@ -117,7 +117,10 @@ the pull request description discloses it, per
|
||||
|
||||
**K22-AGENT-41 (SHOULD)** An agent reports in concise plain language: what changed,
|
||||
why, what was verified, and what limitations remain. Document length scales with the
|
||||
task; decoration (emojis, filler praise) is omitted unless asked for.
|
||||
task. Repository text, including code comments, documentation, commits, PRs, release
|
||||
notes and generated automation messages, uses professional wording. Omit emojis,
|
||||
joke copy, filler praise and bot sound effects. Configure third-party automation to
|
||||
follow the same rule; its default templates are not an exception.
|
||||
*Why:* the report is the maintainer's primary review input. *Verified by:* review.
|
||||
|
||||
## Context economy
|
||||
|
||||
@@ -88,6 +88,20 @@ released software, links to the latest release and the verification instructions
|
||||
[releases](releases.md#verifying-a-release).
|
||||
*Why:* users can see the security posture before they install. *Verified by:* review.
|
||||
|
||||
**K22-REPO-12 (MUST)** Public, open-source Product repositories enroll in the
|
||||
[OpenSSF Best Practices program](https://www.bestpractices.dev/en), link their actual
|
||||
project badge from the README, and maintain an evidence-backed assessment in
|
||||
`docs/security/best-practices.md`. Reassess at least every 90 days and before each
|
||||
MINOR or MAJOR release. Record applicability, evidence path, and the actual project
|
||||
URL in `docs/security/assurance.json` using the [starter record](../templates/repo/docs/security/assurance.json).
|
||||
Private repositories record their scope and reason instead; Internal repositories
|
||||
follow the tier rules above. Registration obstacles require a dated exception, not
|
||||
a placeholder badge or a claim of passing. A passing badge is the adoption target;
|
||||
enrollment alone does not demonstrate that every criterion is met.
|
||||
*Why:* a public assessment makes security practice gaps visible and accountable.
|
||||
*Verified by:* conformance checker (record, evidence and badge link); maintainer
|
||||
review of the provider's project page, repository identity, and criterion answers.
|
||||
|
||||
## Branch and tag protection
|
||||
|
||||
Protection is configured with **repository rulesets**, never with legacy branch
|
||||
|
||||
@@ -80,3 +80,29 @@ release checklist.
|
||||
applications expose a data-directory override (for example an environment variable)
|
||||
that tests use.
|
||||
*Why:* running tests must be safe on any machine. *Verified by:* review.
|
||||
|
||||
## Parser fuzzing
|
||||
|
||||
**K22-TEST-25 (MUST)** Repositories with complex parsers of untrusted input (including
|
||||
workflow, configuration, protocol, archive, and document parsers) maintain fuzz
|
||||
targets for those boundaries. Use coverage-guided fuzzing where supported; otherwise
|
||||
use generated or mutated inputs with independent security properties. A crash-only
|
||||
smoke test is insufficient when acceptance of unsafe input is the relevant failure.
|
||||
|
||||
Run bounded campaigns in a merge-gating CI job on every PR and push to `main`, and
|
||||
longer campaigns at least weekly and before each MINOR or MAJOR release. Document
|
||||
targets, input limits, invariants, a local replay command, seeds or corpus, CI job,
|
||||
and failure handling in `docs/security/fuzzing.md`; declare the evidence, targets,
|
||||
command and workflow in `docs/security/assurance.json`. Preserve minimized failures
|
||||
as regression tests and fix or triage them before merging. Never execute generated
|
||||
attacker scripts, use real secrets, or write to a user's profile.
|
||||
|
||||
Repositories without a complex untrusted-input parser document that decision and
|
||||
its rationale in the same files; review applicability when input boundaries change
|
||||
and at least every 90 days. Documentation-only status does not exempt shipped tooling.
|
||||
*Why:* malformed inputs can cause unsafe acceptance as well as crashes; repeatable
|
||||
fuzzing exercises combinations ordinary examples miss. *Verified by:* conformance
|
||||
checker (scope record, target paths, command and CI reference); required CI execution
|
||||
and review of properties, corpus and applicability. See the
|
||||
[native PowerShell example](../docs/security/fuzzing.md). Scorecard recognition is
|
||||
useful evidence, but is not a substitute for running appropriate targets.
|
||||
@@ -1,5 +1,8 @@
|
||||
# Contributing to {{PROJECT_NAME}}
|
||||
|
||||
Use professional wording in code comments, documentation, commit/PR messages,
|
||||
release notes and automation output. No emojis, joke copy or bot sound effects.
|
||||
|
||||
Thanks for helping. This project follows the
|
||||
[KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards); this page is the
|
||||
short version of what they ask of a contribution.
|
||||
|
||||
@@ -51,6 +51,11 @@ sha256sum --check SHA256SUMS
|
||||
## Security
|
||||
|
||||
Report vulnerabilities privately; see [SECURITY.md](SECURITY.md).
|
||||
Maintain the [Best Practices assessment](docs/security/best-practices.md) and
|
||||
[parser fuzzing assessment](docs/security/fuzzing.md), with their actual scope and
|
||||
configuration in [assurance.json](docs/security/assurance.json). After enrollment,
|
||||
insert the provider's actual linked badge here; placeholders do not demonstrate
|
||||
participation.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"badge": {
|
||||
"applicability": "required",
|
||||
"reason": "Replace with this repository's public Product scope, or a reviewed private/Internal rationale.",
|
||||
"projectUrl": null,
|
||||
"evidence": "docs/security/best-practices.md"
|
||||
},
|
||||
"fuzzing": {
|
||||
"applicability": "required",
|
||||
"reason": "Replace with actual untrusted-input boundaries, or a reviewed not-applicable rationale.",
|
||||
"targets": [],
|
||||
"command": "",
|
||||
"workflow": "",
|
||||
"evidence": "docs/security/fuzzing.md"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# OpenSSF Best Practices assessment
|
||||
|
||||
## Project and ownership
|
||||
|
||||
For a public open-source Product repository, register its exact canonical URL at
|
||||
<https://www.bestpractices.dev/en>. Replace `badge.projectUrl` in `assurance.json`
|
||||
with the actual `https://www.bestpractices.dev/projects/<numeric-id>` URL and add the
|
||||
provider's real linked badge to the README. Record the owner, review date and next
|
||||
review within 90 days. Private/Internal scope decisions need a documented rationale.
|
||||
|
||||
## Criterion evidence
|
||||
|
||||
Use the current [passing criteria](https://www.bestpractices.dev/en/criteria/0).
|
||||
For each answer, link actual source, policy, test or observed operational evidence.
|
||||
Separate implemented practice from intentions, pending human attestations, and
|
||||
justified not-applicable answers. Passing is the target, not an automatic claim
|
||||
made by a copied template. Record remaining gaps with owners and follow-up dates.
|
||||
|
||||
## Maintenance
|
||||
|
||||
Reassess at least every 90 days and before each MINOR or MAJOR release. Keep the
|
||||
provider's answers consistent with this evidence. A registration restriction needs
|
||||
a dated approved exception; never use another repository's badge or invent an ID.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Parser fuzzing assessment
|
||||
|
||||
## Applicability
|
||||
|
||||
Record the reviewed complex untrusted-input boundaries and their owner. If none
|
||||
exist, explain why shipped tooling, importers, configuration and protocol handling
|
||||
do not introduce one. Set `fuzzing.applicability` in `assurance.json` to `required`
|
||||
or `not-applicable` with the actual rationale. Unfilled templates do not conform.
|
||||
|
||||
## Targets and properties
|
||||
|
||||
List each target, synthetic input limits, security invariants and corpus or seeds.
|
||||
Use the language's native coverage-guided fuzzer where practical. Generated-input
|
||||
property tests are an alternative when instrumentation is unavailable; assertions
|
||||
must detect unsafe acceptance as well as crashes.
|
||||
|
||||
## Run and replay
|
||||
|
||||
Provide exact local commands, prerequisites, bounded PR/main campaign budgets,
|
||||
weekly and pre-MINOR/MAJOR budgets, and the required CI job. Reference the target
|
||||
paths, command and workflow in `assurance.json`; ensure the command actually runs
|
||||
there rather than merely appearing in a comment. Explain how to replay and minimize
|
||||
failures into regression tests. Never execute generated attacker scripts.
|
||||
|
||||
## Review
|
||||
|
||||
Name the owner, review date and next review (within 90 days). Reassess input
|
||||
boundaries when they change. Do not claim a Scorecard-supported integration unless
|
||||
it is genuinely configured and running; a score alone is not a test result.
|
||||
@@ -35,6 +35,10 @@ jobs:
|
||||
coverage-packages: "{{COVERAGE_PACKAGES}}"
|
||||
test-projects: |
|
||||
{{TEST_PROJECT}}
|
||||
# For Windows libraries, enable only after verified Azure OIDC configuration:
|
||||
# sign: true
|
||||
# signing-files: |
|
||||
# src/My.Library/bin/Release/net10.0/My.Library.dll
|
||||
|
||||
publish:
|
||||
name: publish (nuget.org)
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
# One-time owner setup:
|
||||
# - Environment `release` (Settings -> Environments) with a deployment rule that allows
|
||||
# only tags matching v*.
|
||||
# - Code signing (when exception EX-0002 closes): add the environment variables
|
||||
# AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT,
|
||||
# TRUSTED_SIGNING_ACCOUNT, TRUSTED_SIGNING_PROFILE (a federated credential for this
|
||||
# repository's `release` environment on the Azure app registration), then set
|
||||
# `sign: true` below.
|
||||
# - Code signing: verify the existing Artifact Signing profile, configure repository
|
||||
# OIDC and its Certificate Profile Signer role, then add AZURE_CLIENT_ID,
|
||||
# AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, ARTIFACT_SIGNING_ENDPOINT,
|
||||
# ARTIFACT_SIGNING_ACCOUNT, ARTIFACT_SIGNING_PROFILE and ARTIFACT_SIGNING_SUBJECT
|
||||
# to the protected release environment. Enable sign and enumerate owned files.
|
||||
# See architecture/windows-release-signing.md; an Active profile alone is not proof.
|
||||
# - winget (optional, stable releases only): the package must already exist in
|
||||
# microsoft/winget-pkgs. Submit it once by hand with
|
||||
# wingetcreate new <Setup.exe URL of the first stable release>
|
||||
@@ -51,7 +52,13 @@ jobs:
|
||||
test-projects: |
|
||||
{{TEST_PROJECT}}
|
||||
cli-project: "{{CLI_PROJECT}}" # remove for apps without a CLI
|
||||
sign: false # exception EX-0002: true once the Azure Trusted Signing identity is validated
|
||||
sign: false # enable only after CI authentication and real signed-artifact verification
|
||||
# Required when sign is true; include all owned app/CLI binaries, not dependencies:
|
||||
# signing-files: |
|
||||
# app/MyApp.exe
|
||||
# app/MyApp.dll
|
||||
# app/MyApp.Core.dll
|
||||
# cli/myapp.exe
|
||||
|
||||
# winget lives here, not in the shared workflow, because its token belongs to this
|
||||
# repository's `release` environment. It runs only for stable releases, and only when
|
||||
|
||||
@@ -44,7 +44,9 @@ if($committedVersion -ne $Version -or $releaseManifest['.'] -ne $Version)
|
||||
|
||||
$changelog = (git -C $repository show "${commit}:CHANGELOG.md") -join "`n"
|
||||
$escapedVersion = [regex]::Escape($Version)
|
||||
$section = [regex]::Match($changelog, "(?ms)^## (?:\[$escapedVersion\](?:\([^\r\n]*\))?|$escapedVersion)(?=\s|$)[^\r\n]*\r?\n.*?(?=^## |\z)")
|
||||
# Native release-note categories also use H2; only a version heading ends a release.
|
||||
$releaseHeading = '^## (?:\[\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\](?:\([^\r\n]*\))?|\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?=\s|$)'
|
||||
$section = [regex]::Match($changelog, "(?ms)^## (?:\[$escapedVersion\](?:\([^\r\n]*\))?|$escapedVersion)(?=\s|$)[^\r\n]*\r?\n.*?(?=$releaseHeading|\z)")
|
||||
if(-not $section.Success)
|
||||
{
|
||||
throw "CHANGELOG.md has no release notes for $Version at $commit."
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#Requires -Version 7.4
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs bounded, reproducible property tests against the conformance parsers.
|
||||
.DESCRIPTION
|
||||
Constructs workflows with independently known security properties, varying quotes,
|
||||
indentation, comments, line endings, block scalars, and attacker expressions. Inputs
|
||||
are data only: no generated script is executed and no GitHub access is needed.
|
||||
A failure prints the seed, iteration, property, and synthetic input for replay.
|
||||
.PARAMETER Seed
|
||||
Seed for a deterministic sequence of generated cases.
|
||||
.PARAMETER Iterations
|
||||
Number of generated cases; bounded for pull-request CI.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int] $Seed = 22023,
|
||||
[ValidateRange(1, 100000)]
|
||||
[int] $Iterations = 1000
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'Test-RepoConformance.ps1') -LoadHelpers
|
||||
$random = [System.Random]::new($Seed)
|
||||
$assertions = 0
|
||||
$caseNumber = 0
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Fail with replay data when an independently generated security property is violated.
|
||||
#>
|
||||
function Assert-Property
|
||||
{
|
||||
param([bool] $Condition, [string] $Property, [string] $InputText)
|
||||
if(-not $Condition)
|
||||
{
|
||||
$replay = [ordered]@{ seed = $Seed; iteration = $caseNumber; property = $Property; input = $InputText }
|
||||
throw ($replay | ConvertTo-Json -Compress)
|
||||
}
|
||||
$script:assertions++
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generate synthetic hexadecimal action refs using the injected deterministic PRNG.
|
||||
#>
|
||||
function Get-HexString
|
||||
{
|
||||
param([int] $Length)
|
||||
$characters = for($position = 0; $position -lt $Length; $position++)
|
||||
{
|
||||
'0123456789abcdef'[$random.Next(16)]
|
||||
}
|
||||
return -join $characters
|
||||
}
|
||||
|
||||
for($caseNumber = 1; $caseNumber -le $Iterations; $caseNumber++)
|
||||
{
|
||||
$newline = @("`n", "`r`n")[$random.Next(2)]
|
||||
$indent = ' ' * $random.Next(2, 5)
|
||||
$quote = @('', "'", '"')[$random.Next(3)]
|
||||
$sha = Get-HexString 40
|
||||
$uses = 'actions/checkout@' + $sha
|
||||
$pinnedWorkflow = @(
|
||||
'name: generated'
|
||||
'on: pull_request'
|
||||
'permissions: {}'
|
||||
'jobs:'
|
||||
($indent + 'check:')
|
||||
($indent * 2 + 'runs-on: ubuntu-24.04')
|
||||
($indent * 2 + 'steps:')
|
||||
($indent * 3 + '- uses: ' + $quote + $uses + $quote + ' # pinned')
|
||||
($indent * 3 + ' with:')
|
||||
($indent * 3 + ' persist-credentials: false')
|
||||
) -join $newline
|
||||
$parsedUses = @(Get-WorkflowUses $pinnedWorkflow)
|
||||
Assert-Property -Condition ($parsedUses.Count -eq 1) -Property 'one generated action is found' -InputText $pinnedWorkflow
|
||||
Assert-Property -Condition ((Get-UsesPinState $parsedUses[0].Uses) -eq 'PINNED') -Property 'quoted full SHA remains pinned' -InputText $pinnedWorkflow
|
||||
Assert-Property -Condition (@(Find-PersistedCheckout $pinnedWorkflow).Count -eq 0) -Property 'explicit credential disabling is found' -InputText $pinnedWorkflow
|
||||
$unsafeCheckout = $pinnedWorkflow.Replace('persist-credentials: false', 'persist-credentials: true')
|
||||
Assert-Property -Condition (@(Find-PersistedCheckout $unsafeCheckout).Count -eq 1) -Property 'quoted unsafe checkout is never missed' -InputText $unsafeCheckout
|
||||
|
||||
$badRef = @('v7', 'main', (Get-HexString 39), (Get-HexString 41))[$random.Next(4)]
|
||||
Assert-Property -Condition ((Get-UsesPinState ('actions/checkout@' + $badRef)) -ne 'PINNED') -Property 'non-SHA refs never pass pinning' -InputText $badRef
|
||||
|
||||
$expression = @('github.event.pull_request.title', 'github.head_ref', 'inputs.payload')[$random.Next(3)]
|
||||
$indicator = @('|', '|-', '|+', '>', '>-', '>+', '|2', '|2-', '>2+')[$random.Next(9)]
|
||||
$expressionText = '${{ ' + $expression + ' }}'
|
||||
$injectionWorkflow = @(
|
||||
'permissions: {}'
|
||||
'jobs:'
|
||||
($indent + 'check:')
|
||||
($indent * 2 + 'steps:')
|
||||
($indent * 3 + '- run: ' + $indicator + ' # generated scalar')
|
||||
($indent * 3 + ' echo "' + $expressionText + '"')
|
||||
) -join $newline
|
||||
$findings = @(Find-TemplateInjection $injectionWorkflow -IncludeInputs)
|
||||
Assert-Property -Condition ($findings.Count -eq 1) -Property 'untrusted expression in a block scalar is found' -InputText $injectionWorkflow
|
||||
Assert-Property -Condition ($findings[0].Expression -eq $expression) -Property 'finding preserves the controlled expression' -InputText $injectionWorkflow
|
||||
$safeWorkflow = $injectionWorkflow.Replace($expressionText, '${{ github.sha }}')
|
||||
Assert-Property -Condition (@(Find-TemplateInjection $safeWorkflow -IncludeInputs).Count -eq 0) -Property 'trusted expression is not reported' -InputText $safeWorkflow
|
||||
$envWorkflow = @(
|
||||
'jobs:'
|
||||
($indent + 'check:')
|
||||
($indent * 2 + 'steps:')
|
||||
($indent * 3 + '- env:')
|
||||
($indent * 3 + ' TITLE: ' + $expressionText)
|
||||
($indent * 3 + ' run: echo "$TITLE"')
|
||||
) -join $newline
|
||||
Assert-Property -Condition (@(Find-TemplateInjection $envWorkflow -IncludeInputs).Count -eq 0) -Property 'expressions in env stay outside run bodies' -InputText $envWorkflow
|
||||
|
||||
$shortName = 'generated-' + $random.Next(100000).ToString([Globalization.CultureInfo]::InvariantCulture)
|
||||
$requirement = 'K22-CI-' + $random.Next(10, 25).ToString('00', [Globalization.CultureInfo]::InvariantCulture)
|
||||
$exception = '| [EX-0099](#ex-0099) | Open | ' + $requirement + ' | KofTwentyTwo/' + $shortName + ' | 2099-01-01 |'
|
||||
$register = @(Read-ExceptionRegister $exception)
|
||||
Assert-Property -Condition ($register.Count -eq 1) -Property 'generated exception row is parsed' -InputText $exception
|
||||
Assert-Property -Condition ($null -ne (Find-Exception $register $requirement ('KofTwentyTwo/' + $shortName))) -Property 'exact exception scope matches' -InputText $exception
|
||||
Assert-Property -Condition ($null -eq (Find-Exception $register $requirement ('KofTwentyTwo/' + $shortName + '-other'))) -Property 'partial repository names cannot gain an exception' -InputText $exception
|
||||
Assert-Property -Condition ($null -eq (Find-Exception $register $requirement ('AnotherOwner/' + $shortName))) -Property 'explicit scope cannot authorize another owner' -InputText $exception
|
||||
$closed = @(Read-ExceptionRegister $exception.Replace('| Open |', '| Closed |'))
|
||||
Assert-Property -Condition ($null -eq (Find-Exception $closed $requirement ('KofTwentyTwo/' + $shortName))) -Property 'closed exceptions cannot authorize a failure' -InputText $exception
|
||||
$expired = @(Read-ExceptionRegister $exception.Replace('2099-01-01', '2026-11-10'))
|
||||
Assert-Property -Condition ($null -eq (Find-Exception $expired $requirement ('KofTwentyTwo/' + $shortName) -Today ([datetime] '2026-11-10'))) -Property 'expired exceptions cannot authorize a failure' -InputText $exception
|
||||
|
||||
$noise = -join (1..$random.Next(1, 128) | ForEach-Object { [char] $random.Next(32, 127) })
|
||||
foreach($parser in 'Get-WorkflowUses', 'Get-TopLevelPermissions', 'Get-RunBlocks', 'Find-TemplateInjection', 'Find-PersistedCheckout', 'Read-ExceptionRegister')
|
||||
{
|
||||
& $parser $noise | Out-Null
|
||||
}
|
||||
Get-AssuranceCheck $noise @() @{} '' | Out-Null
|
||||
$assertions++
|
||||
}
|
||||
|
||||
Write-Output "PASS: conformance fuzz seed=$Seed iterations=$Iterations assertions=$assertions"
|
||||
@@ -4,8 +4,9 @@
|
||||
Checks a repository against the KofTwentyTwo repository and CI/CD standards.
|
||||
|
||||
.DESCRIPTION
|
||||
Evaluates every automatable requirement in standards/repository.md (K22-REPO-*)
|
||||
and standards/ci-cd.md (K22-CI-*) and prints one row per requirement:
|
||||
Evaluates automatable requirements in standards/repository.md (K22-REPO-*),
|
||||
standards/ci-cd.md (K22-CI-*), and the K22-TEST-25 fuzzing scope record, printing
|
||||
one row per requirement:
|
||||
|
||||
PASS the requirement is met
|
||||
FAIL a MUST is not met (or a SHOULD, reported as WARN instead)
|
||||
@@ -45,6 +46,9 @@
|
||||
.PARAMETER SelfTest
|
||||
Run the checker's own unit tests and exit.
|
||||
|
||||
.PARAMETER LoadHelpers
|
||||
Load pure parser helpers for the fuzz harness without checking a repository.
|
||||
|
||||
.EXAMPLE
|
||||
./tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/AppKit -LocalPath ../AppKit
|
||||
|
||||
@@ -58,7 +62,8 @@ param(
|
||||
[switch] $StaticOnly,
|
||||
[string] $StandardsPath = (Split-Path -Parent $PSScriptRoot),
|
||||
[switch] $Json,
|
||||
[switch] $SelfTest
|
||||
[switch] $SelfTest,
|
||||
[switch] $LoadHelpers
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -72,7 +77,8 @@ $script:Levels = [ordered]@{
|
||||
'K22-REPO-01' = 'MUST'; 'K22-REPO-02' = 'MUST'; 'K22-REPO-03' = 'MUST'
|
||||
'K22-REPO-04' = 'MUST'; 'K22-REPO-05' = 'MUST'; 'K22-REPO-06' = 'MUST'
|
||||
'K22-REPO-07' = 'MUST'; 'K22-REPO-08' = 'MUST'; 'K22-REPO-09' = 'MUST'
|
||||
'K22-REPO-10' = 'SHOULD'; 'K22-REPO-11' = 'SHOULD'; 'K22-REPO-20' = 'MUST'
|
||||
'K22-REPO-10' = 'SHOULD'; 'K22-REPO-11' = 'SHOULD'; 'K22-REPO-12' = 'MUST'
|
||||
'K22-TEST-25' = 'MUST'; 'K22-REPO-20' = 'MUST'
|
||||
'K22-REPO-21' = 'MUST'; 'K22-REPO-22' = 'MUST'; 'K22-REPO-30' = 'MUST'
|
||||
'K22-REPO-31' = 'MUST'; 'K22-REPO-32' = 'SHOULD'; 'K22-REPO-40' = 'MUST'
|
||||
'K22-REPO-41' = 'SHOULD'; 'K22-REPO-42' = 'MUST'
|
||||
@@ -169,7 +175,7 @@ function Get-RunBlocks
|
||||
{
|
||||
$indent = $Matches[1].Length
|
||||
$rest = $Matches[2]
|
||||
if ($rest -and $rest -notmatch '^[|>][+-]?\s*$')
|
||||
if ($rest -and $rest -notmatch '^[|>](?:[1-9][+-]?|[+-][1-9]?|[+-]?)\s*(?:#.*)?$')
|
||||
{
|
||||
[pscustomobject]@{ Line = $i + 1; Body = $rest }
|
||||
continue
|
||||
@@ -226,7 +232,7 @@ function Find-PersistedCheckout
|
||||
$lines = $Text -split "`r?`n"
|
||||
for ($i = 0; $i -lt $lines.Count; $i++)
|
||||
{
|
||||
if ($lines[$i] -match '^(\s*)(-\s+)?uses:\s*actions/checkout@')
|
||||
if ($lines[$i] -match '^(\s*)(-\s+)?uses:\s*["'']?actions/checkout@')
|
||||
{
|
||||
$stepIndent = $Matches[1].Length
|
||||
$ok = $false
|
||||
@@ -263,6 +269,7 @@ function Read-ExceptionRegister
|
||||
Requirements = @($qualifiers.Keys)
|
||||
Qualifiers = $qualifiers
|
||||
Scope = $cells[3]
|
||||
Expires = if ($cells.Count -ge 5) { $cells[4] } else { '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,10 +277,18 @@ function Read-ExceptionRegister
|
||||
# Returns the open exception covering a requirement for a repository, or $null.
|
||||
function Find-Exception
|
||||
{
|
||||
param([object[]] $Register, [string] $RequirementId, [string] $RepositoryName, [string[]] $Aspects = @())
|
||||
param([object[]] $Register, [string] $RequirementId, [string] $RepositoryName, [string[]] $Aspects = @(), [datetime] $Today = [datetime]::UtcNow.Date)
|
||||
foreach ($ex in $Register)
|
||||
{
|
||||
if ($ex.Status -notmatch '^Open$') { continue }
|
||||
# Review dates are not expiry dates. Enforce an explicit ISO expiry or an
|
||||
# "at the latest" deadline; condition-only expiry still needs human review.
|
||||
if ($ex.Expires -match '(?i)(?:^|at (?:the )?latest\s+)(\d{4}-\d{2}-\d{2})')
|
||||
{
|
||||
$deadline = [datetime]::MinValue
|
||||
if (-not [datetime]::TryParseExact($Matches[1], 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref] $deadline)) { continue }
|
||||
if ($Today.Date -ge $deadline.Date) { continue }
|
||||
}
|
||||
if ($ex.Requirements -notcontains $RequirementId) { continue }
|
||||
# A qualified exception covers a failure only when every failing aspect is
|
||||
# the qualified one.
|
||||
@@ -285,7 +300,8 @@ function Find-Exception
|
||||
}
|
||||
$short = if ($RepositoryName) { ($RepositoryName -split '/')[-1] } else { '' }
|
||||
if ($ex.Scope -match '(?i)^all repositories' -or
|
||||
($short -and $ex.Scope -match "(?i)(^|[\s/,(])$([regex]::Escape($short))($|[\s,)])"))
|
||||
($RepositoryName -and $ex.Scope -match "(?i)(^|[\s,(])$([regex]::Escape($RepositoryName))($|[\s,)])") -or
|
||||
($short -and $ex.Scope -match "(?i)(^|[\s,(])$([regex]::Escape($short))($|[\s,)])"))
|
||||
{
|
||||
return $ex
|
||||
}
|
||||
@@ -293,6 +309,78 @@ function Find-Exception
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validate declared assurance evidence without executing commands or claiming certification.
|
||||
#>
|
||||
function Get-AssuranceCheck
|
||||
{
|
||||
param([string] $Text, [string[]] $Paths, [hashtable] $Workflows, [string] $Readme)
|
||||
try
|
||||
{
|
||||
$record = ConvertFrom-Json -InputObject $Text -AsHashtable -ErrorAction Stop
|
||||
if ($record -isnot [System.Collections.IDictionary] -or $record['schemaVersion'] -isnot [long] -or $record['schemaVersion'] -ne 1) { throw 'unsupported schema' }
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach ($id in 'K22-REPO-12', 'K22-TEST-25')
|
||||
{
|
||||
[pscustomobject]@{ Id = $id; Status = 'FAIL'; Detail = 'missing or invalid docs/security/assurance.json (schemaVersion 1)'; Aspects = @('record') }
|
||||
}
|
||||
return
|
||||
}
|
||||
foreach ($pair in @(@('badge', 'K22-REPO-12'), @('fuzzing', 'K22-TEST-25')))
|
||||
{
|
||||
$kind, $id = $pair
|
||||
$entry = $record[$kind]
|
||||
$status, $detail, $aspect = 'FAIL', 'invalid assurance scope, rationale or evidence path', 'record'
|
||||
if ($entry -is [System.Collections.IDictionary] -and
|
||||
$entry['applicability'] -in @('required', 'not-applicable') -and
|
||||
$entry['reason'] -is [string] -and -not [string]::IsNullOrWhiteSpace($entry['reason']) -and
|
||||
$entry['evidence'] -is [string] -and $entry['evidence'] -cmatch '^docs/security/[A-Za-z0-9._/-]+\.md$' -and
|
||||
$entry['evidence'] -notmatch '(^|/)\.\.?(/|$)' -and $Paths -ccontains $entry['evidence'])
|
||||
{
|
||||
if ($entry['applicability'] -eq 'not-applicable')
|
||||
{
|
||||
$status, $detail = 'REVIEW', "$kind scope declared not applicable; verify rationale and input/public-Product scope in $($entry.evidence)"
|
||||
}
|
||||
elseif ($kind -eq 'badge')
|
||||
{
|
||||
$aspect = 'enrollment'
|
||||
$detail = 'badge enrollment missing; register this repository or obtain a dated enrollment exception'
|
||||
if ($entry['projectUrl'] -is [string] -and $entry['projectUrl'] -cmatch '^https://www\.bestpractices\.dev/(?:en/)?projects/([1-9][0-9]*)/?$')
|
||||
{
|
||||
$projectId = $Matches[1]
|
||||
$badgePattern = '\[!\[[^\]]*\]\(https://www\.bestpractices\.dev/projects/' + $projectId + '/badge\)\]\(https://www\.bestpractices\.dev/(?:en/)?projects/' + $projectId + '/?\)'
|
||||
if ($Readme -cmatch $badgePattern)
|
||||
{
|
||||
$status, $detail = 'REVIEW', "badge linked; verify $($entry.projectUrl) belongs to this repository, current answers and 90-day review in $($entry.evidence)"
|
||||
}
|
||||
else { $detail = 'README lacks the actual linked badge matching the declared project ID' }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$aspect = 'fuzzing'
|
||||
$targets = @($entry['targets'])
|
||||
$validTargets = $targets.Count -gt 0 -and @($targets | Where-Object {
|
||||
$_ -isnot [string] -or $_ -match '(^[/\\]|^[A-Za-z]:|(^|[/\\])\.\.?([/\\]|$))' -or $Paths -cnotcontains $_
|
||||
}).Count -eq 0
|
||||
$validWorkflow = $entry['workflow'] -is [string] -and $entry['workflow'] -cmatch '^\.github/workflows/[^/]+\.ya?ml$' -and $Workflows.ContainsKey($entry['workflow'])
|
||||
$validCommand = $entry['command'] -is [string] -and -not [string]::IsNullOrWhiteSpace($entry['command']) -and $entry['command'] -notmatch '[\r\n]'
|
||||
$runs = if ($validWorkflow) { @(Get-RunBlocks $Workflows[$entry['workflow']] | ForEach-Object Body) -join "`n" } else { '' }
|
||||
$commandInRun = $validCommand -and $runs -cmatch ('(?m)^\s*' + [regex]::Escape($entry['command'].Trim()) + '(?:\s|$)')
|
||||
if ($validTargets -and $validWorkflow -and $commandInRun)
|
||||
{
|
||||
$status, $detail = 'REVIEW', "fuzz targets and run command found; verify required PR/main gate, weekly/release budgets, properties and replay in $($entry.evidence)"
|
||||
}
|
||||
else { $detail = 'declared fuzz targets must exist and the command must begin a run line in the declared workflow' }
|
||||
}
|
||||
}
|
||||
[pscustomobject]@{ Id = $id; Status = $status; Detail = $detail; Aspects = @($aspect) }
|
||||
}
|
||||
}
|
||||
|
||||
# Lock files expected for each manifest found (K22-REPO-09).
|
||||
$script:LockRules = @(
|
||||
@{ Manifest = '(^|/)[^/]+\.(cs|fs|vb)proj$|(^|/)Directory\.Packages\.props$'; Locks = 'packages\.lock\.json$'; Name = 'NuGet (packages.lock.json)' }
|
||||
@@ -395,6 +483,8 @@ jobs:
|
||||
Assert ($inj[1].Expression -eq 'github.head_ref') 'injection: head_ref'
|
||||
$persisted = @(Find-PersistedCheckout $wf)
|
||||
Assert ($persisted.Count -eq 1 -and $persisted[0] -eq 9) "checkout: persisted ($($persisted -join ','))"
|
||||
Assert (@(Find-PersistedCheckout "jobs:`n check:`n steps:`n - uses: 'actions/checkout@v7'`n with:`n persist-credentials: true").Count -eq 1) 'checkout: quoted unsafe action'
|
||||
Assert (@(Find-TemplateInjection "jobs:`n check:`n steps:`n - run: |2 # explicit indentation`n echo `${{ github.head_ref }}").Count -eq 1) 'injection: block scalar indentation and comment'
|
||||
|
||||
$register = @"
|
||||
| ID | Status | Requirements | Scope | Expires |
|
||||
@@ -416,6 +506,24 @@ jobs:
|
||||
Assert ($null -eq (Find-Exception $parsed 'K22-REPO-05' 'KofTwentyTwo/AppKit')) 'exception: closed'
|
||||
Assert ((Find-Exception $parsed 'K22-CODE-CS-03' 'KofTwentyTwo/AppKit').Id -eq 'EX-0004') 'exception: short name scope'
|
||||
Assert ($null -eq (Find-Exception $parsed 'K22-CODE-CS-03' 'KofTwentyTwo/AppKitExtras')) 'exception: no partial name match'
|
||||
$expired = @(Read-ExceptionRegister '| [EX-0099](#ex-0099) | Open | K22-REPO-12 | standards | 2026-11-10; review 2026-10-17 |')
|
||||
Assert ($null -ne (Find-Exception $expired 'K22-REPO-12' 'standards' -Today ([datetime] '2026-11-09'))) 'exception: valid before deadline'
|
||||
Assert ($null -eq (Find-Exception $expired 'K22-REPO-12' 'standards' -Today ([datetime] '2026-11-10'))) 'exception: expiry cannot silently authorize failure'
|
||||
$exactScope = @(Read-ExceptionRegister '| [EX-0099](#ex-0099) | Open | K22-REPO-12 | KofTwentyTwo/standards | 2099-01-01 |')
|
||||
Assert ($null -eq (Find-Exception $exactScope 'K22-REPO-12' 'AnotherOwner/standards')) 'exception: explicit owner scope cannot match another owner'
|
||||
Assert (@(Get-AssuranceCheck '{}' @() @{} '').Count -eq 2) 'assurance: missing schema returns both failures'
|
||||
Assert (@(Get-AssuranceCheck '{"schemaVersion":true}' @() @{} '' | Where-Object Status -eq 'FAIL').Count -eq 2) 'assurance: boolean cannot masquerade as schema version 1'
|
||||
Assert (@(Get-AssuranceCheck '{"schemaVersion":1,"badge":{},"fuzzing":{}}' @() @{} '' | Where-Object Status -eq 'FAIL').Count -eq 2) 'assurance: incomplete entries fail without crashing'
|
||||
$assurance = '{"schemaVersion":1,"badge":{"applicability":"required","reason":"public Product","projectUrl":"https://www.bestpractices.dev/projects/123","evidence":"docs/security/best-practices.md"},"fuzzing":{"applicability":"required","reason":"parser","targets":["tools/parser.ps1"],"command":"./tools/fuzz.ps1","workflow":".github/workflows/ci.yml","evidence":"docs/security/fuzzing.md"}}'
|
||||
$assurancePaths = @('tools/parser.ps1', 'docs/security/best-practices.md', 'docs/security/fuzzing.md')
|
||||
$assuranceWorkflow = @{ '.github/workflows/ci.yml' = "jobs:`n tests:`n steps:`n - run: ./tools/fuzz.ps1 -Iterations 10" }
|
||||
$assuranceReadme = '[](https://www.bestpractices.dev/projects/123)'
|
||||
$assuranceRows = @(Get-AssuranceCheck $assurance $assurancePaths $assuranceWorkflow $assuranceReadme)
|
||||
Assert (@($assuranceRows | Where-Object Status -ne 'REVIEW').Count -eq 0) 'assurance: evidence needs human verification, never fabricated certification'
|
||||
Assert (@(Get-AssuranceCheck $assurance $assurancePaths $assuranceWorkflow '' | Where-Object { $_.Id -eq 'K22-REPO-12' -and $_.Status -eq 'FAIL' }).Count -eq 1) 'assurance: missing badge fails'
|
||||
$assuranceWorkflow['.github/workflows/ci.yml'] = "jobs:`n tests:`n steps:`n - run: |`n # ./tools/fuzz.ps1"
|
||||
Assert (@(Get-AssuranceCheck $assurance $assurancePaths $assuranceWorkflow $assuranceReadme | Where-Object { $_.Id -eq 'K22-TEST-25' -and $_.Status -eq 'FAIL' }).Count -eq 1) 'assurance: comment does not execute a fuzz command'
|
||||
Assert (@(Get-AssuranceCheck ($assurance.Replace('tools/parser.ps1', '../parser.ps1')) $assurancePaths $assuranceWorkflow $assuranceReadme | Where-Object { $_.Id -eq 'K22-TEST-25' -and $_.Status -eq 'FAIL' }).Count -eq 1) 'assurance: traversal paths cannot satisfy targets'
|
||||
|
||||
Assert (@(Get-MissingLocks @('src/a/a.csproj', 'README.md')) -contains 'NuGet (packages.lock.json)') 'locks: missing nuget'
|
||||
Assert (@(Get-MissingLocks @('src/a/a.csproj', 'src/a/packages.lock.json')).Count -eq 0) 'locks: nuget ok'
|
||||
@@ -450,6 +558,7 @@ jobs:
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($LoadHelpers) { return }
|
||||
if ($SelfTest) { Invoke-SelfTest }
|
||||
|
||||
# =================================================================================
|
||||
@@ -534,6 +643,13 @@ function Find-FirstPath
|
||||
}
|
||||
|
||||
$repoName = if ($Repository) { $Repository } else { Split-Path -Leaf $script:LocalRoot }
|
||||
if (-not $Repository)
|
||||
{
|
||||
# Local static checks can identify exact exception scope without contacting
|
||||
# GitHub. A clone with no canonical GitHub origin keeps its directory name.
|
||||
$origin = & git -C $script:LocalRoot remote get-url origin 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $origin -match '^(?:https://github\.com/|git@github\.com:|ssh://git@(?:github\.com(?::22)?|ssh\.github\.com:443)/)([^/]+/[^/]+?)(?:\.git)?$') { $repoName = $Matches[1] }
|
||||
}
|
||||
|
||||
# =================================================================================
|
||||
# Static checks: required files (K22-REPO-01 .. 11)
|
||||
@@ -662,6 +778,15 @@ $workflows = @{}
|
||||
foreach ($p in $workflowPaths) { $workflows[$p] = Get-FileText $p }
|
||||
$allWorkflowText = ($workflows.Values -join "`n")
|
||||
|
||||
# Assurance records fail closed when required evidence is absent. A positive static
|
||||
# check is REVIEW: identity, applicability and actual CI execution need verification.
|
||||
$assuranceReadme = if ($readmePath) { Get-FileText $readmePath } else { '' }
|
||||
if ($assuranceReadme -match '(?im)\bTier(?:\*\*)?\s*[:|]\s*(?:\*\*)?\s*Internal\b') { $script:Levels['K22-REPO-12'] = 'SHOULD' }
|
||||
foreach ($check in Get-AssuranceCheck (Get-FileText 'docs/security/assurance.json') $script:Paths $workflows $assuranceReadme)
|
||||
{
|
||||
Add-Result $check.Id $check.Status $check.Detail $check.Aspects
|
||||
}
|
||||
|
||||
function Test-Calls
|
||||
{
|
||||
# True when any workflow calls the named standards reusable workflow (by remote
|
||||
|
||||
@@ -63,6 +63,14 @@ Future notes.
|
||||
|
||||
Current notes.
|
||||
|
||||
## Changes
|
||||
|
||||
Native category notes.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
Current breaking details.
|
||||
|
||||
## 1.2.2
|
||||
|
||||
Older notes.
|
||||
@@ -100,6 +108,7 @@ $manifest = Get-Content -LiteralPath (Join-Path $first "$prefix.manifest.json")
|
||||
Test-Condition ($manifest.version -eq '1.2.3' -and $manifest.commit -eq (git -C $source rev-parse HEAD)) 'manifest identifies version and exact source commit'
|
||||
$notes = Get-Content -LiteralPath (Join-Path $first "$prefix.notes.md") -Raw
|
||||
Test-Condition ($notes -match 'Current notes' -and $notes -notmatch 'Older notes|Future notes' -and $notes.Contains($checklist)) 'notes contain only this release and its checklist'
|
||||
Test-Condition ($notes -match 'Native category notes' -and $notes -match 'Current breaking details') 'notes preserve native H2 categories within a release'
|
||||
foreach($file in (Get-ChildItem -LiteralPath $first -File))
|
||||
{
|
||||
Test-Condition ((Get-FileHash -LiteralPath $file.FullName).Hash -eq (Get-FileHash -LiteralPath (Join-Path $second $file.Name)).Hash) "$($file.Name) is reproducible"
|
||||
@@ -115,6 +124,14 @@ Test-Exception { & $builder -Version '1.2.4' -RepositoryPath $source -OutputPath
|
||||
Test-Exception { & $builder -Version '01.2.3' -RepositoryPath $source } 'ValidatePattern|validation|pattern'
|
||||
Test-Exception { & $builder -Version '1.2.3' -RepositoryPath $source -OutputPath $first } 'already exists'
|
||||
Test-Exception { & $builder -Version '1.2.3' -RepositoryPath $source -OutputPath (Join-Path $fixtureRoot 'invalid-url') -ChecklistUrl 'http://example.com/123' } 'GitHub issue URL'
|
||||
$nativeChangelog = (Get-Content -LiteralPath (Join-Path $source 'CHANGELOG.md') -Raw).Replace('## [1.2.3](https://example.com/current)', '## 1.2.3')
|
||||
Set-Content -LiteralPath (Join-Path $source 'CHANGELOG.md') -Value $nativeChangelog
|
||||
git -C $source add -- CHANGELOG.md
|
||||
git -C $source -c user.name=Fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false commit --quiet -m 'test: native release heading fixture'
|
||||
$nativeOutput = Join-Path $fixtureRoot 'native-headings'
|
||||
& $builder -Version '1.2.3' -RepositoryPath $source -OutputPath $nativeOutput
|
||||
$nativeNotes = Get-Content -LiteralPath (Join-Path $nativeOutput "$prefix.notes.md") -Raw
|
||||
Test-Condition ($nativeNotes -match 'Native category notes' -and $nativeNotes -match 'Current breaking details' -and $nativeNotes -notmatch 'Older notes|Future notes') 'native unlinked version heading retains only its release categories'
|
||||
Set-Content -LiteralPath (Join-Path $source 'CHANGELOG.md') -Value '# Changelog without release notes'
|
||||
git -C $source add -- CHANGELOG.md
|
||||
git -C $source -c user.name=Fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false commit --quiet -m 'test: missing release notes fixture'
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
BeforeAll `
|
||||
{
|
||||
$lines = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../.github/workflows/release-velopack.yml')
|
||||
$start = [Array]::IndexOf($lines, " @'")
|
||||
$end = $start + 1
|
||||
while($end -lt $lines.Count -and -not $lines[$end].StartsWith(" '@ | Set-Content")) { $end++ }
|
||||
if($start -lt 0 -or $end -ge $lines.Count) { throw 'Cannot locate the actual Velopack signing wrapper.' }
|
||||
$script:Wrapper = Join-Path $TestDrive 'sign-one.ps1'
|
||||
[IO.File]::WriteAllLines($script:Wrapper, @($lines[($start + 1)..($end - 1)] | ForEach-Object { $_.Substring(10) }))
|
||||
$dependencyStart = [Array]::IndexOf($lines, ' - name: Prepare pinned Artifact Signing CLI dependency')
|
||||
if($dependencyStart -lt 0) { throw 'Cannot locate the actual signing dependency bootstrap.' }
|
||||
$dependencyStart += 4
|
||||
$dependencyEnd = $dependencyStart
|
||||
while($dependencyEnd -lt $lines.Count -and $lines[$dependencyEnd].StartsWith(' ')) { $dependencyEnd++ }
|
||||
$script:Bootstrap = Join-Path $TestDrive 'sign-dependency.ps1'
|
||||
# GitHub's default pwsh shell prepends this statement to every inline run body.
|
||||
[IO.File]::WriteAllLines($script:Bootstrap, @('$ErrorActionPreference = "stop"') + @($lines[$dependencyStart..($dependencyEnd - 1)] | ForEach-Object { $_.Substring(10) }))
|
||||
$script:OldTool = $env:K22_VPK_SIGN_TOOL
|
||||
$env:K22_VPK_SIGN_TOOL = 'Invoke-WrapperTool'
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Simulates signing and verification without using credentials.
|
||||
#>
|
||||
function Invoke-WrapperTool
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param([Parameter(ValueFromRemainingArguments)] [string[]] $ToolArguments)
|
||||
|
||||
$ToolCalls.Add($ToolArguments)
|
||||
$global:LASTEXITCODE = if($ToolArguments[0] -eq 'sign') { $SignExit } else { $VerifyExit }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'The actual Artifact Signing dependency bootstrap' `
|
||||
{
|
||||
BeforeEach `
|
||||
{
|
||||
$script:InstallRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
|
||||
$script:PackageDirectory = Join-Path $script:InstallRoot 'downloads'
|
||||
[void][IO.Directory]::CreateDirectory($script:PackageDirectory)
|
||||
$script:Destination = Join-Path $script:InstallRoot 'ArtifactSigning/sign/sign.0.9.1-beta.26227.3'
|
||||
Mock Invoke-WebRequest `
|
||||
{
|
||||
$fixture = Join-Path (Split-Path -Parent $OutFile) 'fixture'
|
||||
[void][IO.Directory]::CreateDirectory((Join-Path $fixture 'tools/net8.0/any'))
|
||||
[IO.File]::WriteAllText((Join-Path $fixture 'tools/net8.0/any/sign.dll'), 'fixture')
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($fixture, $OutFile)
|
||||
}
|
||||
Mock Get-FileHash { [pscustomobject]@{ Hash = '34FD0D4AEABDBC363A48883881865B4DD65E6C11BA916028082FA23F3E1B1BA1' } }
|
||||
Mock dotnet { $global:LASTEXITCODE = 0 }
|
||||
}
|
||||
|
||||
It 'stages the verified tool where the official action finds it' `
|
||||
{
|
||||
& $script:Bootstrap -InstallRoot $script:InstallRoot -PackageDirectory $script:PackageDirectory
|
||||
Get-Content -LiteralPath (Join-Path $script:Destination 'tools/net8.0/any/sign.dll') | Should -Be 'fixture'
|
||||
Should -Invoke dotnet -Exactly 1
|
||||
}
|
||||
|
||||
It 'rejects a changed download before verification or extraction' `
|
||||
{
|
||||
Mock Get-FileHash { [pscustomobject]@{ Hash = 'bad' } }
|
||||
{ & $script:Bootstrap -InstallRoot $script:InstallRoot -PackageDirectory $script:PackageDirectory } | Should -Throw '*checksum mismatch*'
|
||||
Test-Path -LiteralPath $script:Destination | Should -BeFalse
|
||||
Should -Invoke dotnet -Exactly 0
|
||||
}
|
||||
|
||||
It 'rejects an invalid NuGet signature before extraction' `
|
||||
{
|
||||
Mock dotnet { $global:LASTEXITCODE = 1 }
|
||||
{ & $script:Bootstrap -InstallRoot $script:InstallRoot -PackageDirectory $script:PackageDirectory } | Should -Throw '*signature verification failed*'
|
||||
Test-Path -LiteralPath $script:Destination | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'preserves a preexisting dependency directory' `
|
||||
{
|
||||
[void][IO.Directory]::CreateDirectory($script:Destination)
|
||||
$marker = Join-Path $script:Destination 'marker.txt'
|
||||
[IO.File]::WriteAllText($marker, 'preserve')
|
||||
{ & $script:Bootstrap -InstallRoot $script:InstallRoot -PackageDirectory $script:PackageDirectory } | Should -Throw '*already exists*'
|
||||
Get-Content -LiteralPath $marker | Should -Be 'preserve'
|
||||
Should -Invoke Invoke-WebRequest -Exactly 0
|
||||
}
|
||||
|
||||
It 'rejects a package without the expected executable' `
|
||||
{
|
||||
Mock Invoke-WebRequest `
|
||||
{
|
||||
$emptyFixture = Join-Path (Split-Path -Parent $OutFile) 'empty'
|
||||
[void][IO.Directory]::CreateDirectory($emptyFixture)
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($emptyFixture, $OutFile)
|
||||
}
|
||||
{ & $script:Bootstrap -InstallRoot $script:InstallRoot -PackageDirectory $script:PackageDirectory } | Should -Throw '*missing its executable*'
|
||||
}
|
||||
}
|
||||
|
||||
AfterAll `
|
||||
{
|
||||
$env:K22_VPK_SIGN_TOOL = $script:OldTool
|
||||
}
|
||||
|
||||
Describe 'The actual Velopack signing wrapper' `
|
||||
{
|
||||
BeforeEach `
|
||||
{
|
||||
$script:ToolCalls = [Collections.Generic.List[object]]::new()
|
||||
$script:SignExit = 0
|
||||
$script:VerifyExit = 0
|
||||
Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = 'NotSigned' } }
|
||||
}
|
||||
|
||||
It 'preserves a valid existing signature without signing again' `
|
||||
{
|
||||
Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = 'Valid' } }
|
||||
& $script:Wrapper -File 'a file.dll'
|
||||
$script:ToolCalls.Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'refuses an invalid existing signature' `
|
||||
{
|
||||
Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = 'HashMismatch' } }
|
||||
{ & $script:Wrapper -File 'a file.dll' } | Should -Throw '*invalid signature*'
|
||||
$script:ToolCalls.Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'stops on signing failure before verification' `
|
||||
{
|
||||
$script:SignExit = 1
|
||||
{ & $script:Wrapper -File 'a file.dll' } | Should -Throw '*Artifact Signing failed*'
|
||||
$script:ToolCalls.Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'stops when signature verification fails' `
|
||||
{
|
||||
$script:VerifyExit = 1
|
||||
{ & $script:Wrapper -File 'a file.dll' } | Should -Throw '*Signature verification failed*'
|
||||
$script:ToolCalls.Count | Should -Be 2
|
||||
}
|
||||
|
||||
It 'uses SHA256, RFC3161 timestamping and all-signature trust verification' `
|
||||
{
|
||||
& $script:Wrapper -File 'a file.dll'
|
||||
$script:ToolCalls.Count | Should -Be 2
|
||||
($script:ToolCalls[0] -join ' ') | Should -BeLike 'sign /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256* a file.dll'
|
||||
($script:ToolCalls[1] -join ' ') | Should -Be 'verify /pa /all /v a file.dll'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
BeforeDiscovery `
|
||||
{
|
||||
Import-Module (Join-Path $PSScriptRoot 'WindowsSigning.psm1') -Force -Global
|
||||
}
|
||||
|
||||
Describe 'Owned binary catalog' `
|
||||
{
|
||||
BeforeEach `
|
||||
{
|
||||
$script:Root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $script:Root | Out-Null
|
||||
$script:Catalog = Join-Path $script:Root 'catalog.txt'
|
||||
$script:Manifest = Join-Path $script:Root 'manifest.json'
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $script:Root 'Owned.dll')
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $script:Root 'Microsoft.dll')
|
||||
}
|
||||
|
||||
It 'includes only explicit owned names and inventories other binaries' `
|
||||
{
|
||||
New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -PreserveOtherBinaries
|
||||
@(Get-Content -LiteralPath $script:Catalog) | Should -HaveCount 1
|
||||
Get-Content -LiteralPath $script:Catalog | Should -Be 'Owned.dll'
|
||||
$entries = @(Get-Content -LiteralPath $script:Manifest -Raw | ConvertFrom-Json)
|
||||
$entries | Should -HaveCount 2
|
||||
@($entries | Where-Object owned).relativePath | Should -Be 'Owned.dll'
|
||||
@($entries | Where-Object { -not $_.owned }).relativePath | Should -Be 'Microsoft.dll'
|
||||
}
|
||||
|
||||
It 'rejects a missing required binary rather than silently skipping signing' `
|
||||
{
|
||||
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Missing.exe') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } |
|
||||
Should -Throw '*No binary matches*'
|
||||
}
|
||||
|
||||
It 'rejects parent traversal' `
|
||||
{
|
||||
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('../Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } |
|
||||
Should -Throw '*relative paths*'
|
||||
}
|
||||
|
||||
It 'supports WhatIf without writing signing inputs' `
|
||||
{
|
||||
New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -WhatIf
|
||||
Test-Path -LiteralPath $script:Catalog | Should -BeFalse
|
||||
Test-Path -LiteralPath $script:Manifest | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'rejects malformed owned PE input before writing a catalog' `
|
||||
{
|
||||
[IO.File]::WriteAllBytes((Join-Path $script:Root 'Owned.dll'), [byte[]]::new(128))
|
||||
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Throw '*Invalid published PE binary: Owned.dll*'
|
||||
Test-Path -LiteralPath $script:Catalog | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'rejects an already malformed preserved dependency' `
|
||||
{
|
||||
[IO.File]::WriteAllBytes((Join-Path $script:Root 'Microsoft.dll'), [byte[]]::new(128))
|
||||
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -PreserveOtherBinaries } | Should -Throw '*Invalid published PE binary: Microsoft.dll*'
|
||||
Test-Path -LiteralPath $script:Manifest | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not treat package-format signing targets as PE images' `
|
||||
{
|
||||
Set-Content -LiteralPath (Join-Path $script:Root 'Setup.msi') -Value 'package fixture; signature validation is a later gate'
|
||||
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Setup.msi') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Not -Throw
|
||||
Get-Content -LiteralPath $script:Catalog | Should -Be 'Setup.msi'
|
||||
}
|
||||
|
||||
It 'ignores excluded binaries when dependency preservation is disabled' `
|
||||
{
|
||||
[IO.File]::WriteAllBytes((Join-Path $script:Root 'Microsoft.dll'), [byte[]]::new(128))
|
||||
{ New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Not -Throw
|
||||
@(Get-Content -LiteralPath $script:Manifest -Raw | ConvertFrom-Json) | Should -HaveCount 1
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Signature and distributed-byte gates' `
|
||||
{
|
||||
InModuleScope WindowsSigning `
|
||||
{
|
||||
BeforeAll `
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Supplies deterministic SignTool results for rejection-path tests.
|
||||
#>
|
||||
function Invoke-ProbeSignTool
|
||||
{
|
||||
$global:LASTEXITCODE = $script:ProbeExitCode
|
||||
return $script:ProbeOutput
|
||||
}
|
||||
}
|
||||
|
||||
BeforeEach `
|
||||
{
|
||||
$script:ProbeExitCode = 0
|
||||
$script:ProbeOutput = 'Hash of file (sha256): 0123456789'
|
||||
$script:ProbeSignature = [pscustomobject]@{
|
||||
Status = 'Valid'
|
||||
SignerCertificate = [pscustomobject]@{ Subject = 'CN=Verified Publisher'; SubjectName = [pscustomobject]@{ Name = 'CN=Verified Publisher' } }
|
||||
TimeStamperCertificate = [pscustomobject]@{ Subject = 'CN=Timestamp Authority' }
|
||||
}
|
||||
Mock Get-AuthenticodeSignature { return $script:ProbeSignature }
|
||||
}
|
||||
|
||||
It 'accepts a trusted timestamped SHA256 signature for the verified subject' `
|
||||
{
|
||||
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'rejects a native verification failure' `
|
||||
{
|
||||
$script:ProbeExitCode = 1
|
||||
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*verification failed*'
|
||||
}
|
||||
|
||||
It 'rejects a different publisher' `
|
||||
{
|
||||
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Other Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Unexpected publisher*'
|
||||
}
|
||||
|
||||
It 'rejects a missing timestamp' `
|
||||
{
|
||||
$script:ProbeSignature.TimeStamperCertificate = $null
|
||||
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*timestamped*'
|
||||
}
|
||||
|
||||
It 'rejects an untrusted certificate' `
|
||||
{
|
||||
$script:ProbeSignature.Status = 'NotTrusted'
|
||||
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*timestamped*'
|
||||
}
|
||||
|
||||
It 'rejects a non-SHA256 digest' `
|
||||
{
|
||||
$script:ProbeOutput = 'Hash of file (sha1): 0123456789'
|
||||
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*SHA256*'
|
||||
}
|
||||
|
||||
It 'rejects a SHA1 signature appended alongside a SHA256 signature' `
|
||||
{
|
||||
$script:ProbeOutput = "Hash of file (sha256): abc`nHash of file (sha1): def"
|
||||
{ Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*SHA256*'
|
||||
}
|
||||
|
||||
It 'rejects modified third-party binaries' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'preserve'
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Microsoft.dll')
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
|
||||
Set-Content -LiteralPath (Join-Path $root 'Microsoft.dll') -Value 'modified'
|
||||
{ Assert-WindowsSigningManifest -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Third-party binary was modified*'
|
||||
}
|
||||
|
||||
It 'refuses to catalog another publisher signature for replacement' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'other-publisher'
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
|
||||
$script:ProbeSignature.SignerCertificate.SubjectName.Name = 'CN=Other Publisher'
|
||||
{ New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath (Join-Path $root 'manifest.json') -ExpectedSubject 'CN=Verified Publisher' } | Should -Throw '*another publisher*'
|
||||
Test-Path -LiteralPath (Join-Path $root 'catalog.txt') | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'rejects changed owned bytes in the final package' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'changed'
|
||||
$assets = Join-Path $root 'assets'
|
||||
$packed = Join-Path $root 'packed'
|
||||
New-Item -ItemType Directory -Path $root, $assets, $packed | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
|
||||
Set-Content -LiteralPath (Join-Path $packed 'Owned.dll') -Value 'modified after signing'
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg'))
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Distributed binary changed*'
|
||||
}
|
||||
|
||||
It 'rejects an archive missing its required owned binary' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'missing'
|
||||
$assets = Join-Path $root 'assets'
|
||||
$packed = Join-Path $root 'packed'
|
||||
New-Item -ItemType Directory -Path $root, $assets, $packed | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
|
||||
Set-Content -LiteralPath (Join-Path $packed 'README.txt') -Value 'no binary'
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg'))
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Owned binary missing*'
|
||||
}
|
||||
|
||||
It 'rejects a missing dependency from <Archive>' -TestCases @(
|
||||
@{ Archive = 'Owned-Portable.zip'; Head = 'app' },
|
||||
@{ Archive = 'Owned-1.0.0-full.nupkg'; Head = 'app' },
|
||||
@{ Archive = 'Owned-cli-win-x64.zip'; Head = 'cli' }
|
||||
) `
|
||||
{
|
||||
param($Archive, $Head)
|
||||
$root = Join-Path $TestDrive ('missing-dependency-' + [guid]::NewGuid().ToString('N'))
|
||||
$publish = Join-Path $root 'publish'
|
||||
$assets = Join-Path $root 'assets'
|
||||
$packed = Join-Path $root 'packed'
|
||||
New-Item -ItemType Directory -Path "$publish/$Head", $assets, $packed | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/$Head/Owned.dll"
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/$Head/Microsoft.dll"
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $publish -Patterns @("$Head/Owned.dll") -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
|
||||
Copy-Item -LiteralPath "$publish/$Head/Owned.dll" -Destination $packed
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets $Archive))
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Third-party binary missing*'
|
||||
}
|
||||
|
||||
It 'allows only the default excluded foreign runtime helpers to be omitted' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'excluded-runtime'
|
||||
$publish = Join-Path $root 'publish'
|
||||
$assets = Join-Path $root 'assets'
|
||||
$packed = Join-Path $root 'packed'
|
||||
New-Item -ItemType Directory -Path "$publish/app", $assets, $packed | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll"
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Microsoft.dll"
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/createdump.exe"
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.vshost.exe"
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
|
||||
Copy-Item -LiteralPath "$publish/app/Owned.dll", "$publish/app/Microsoft.dll" -Destination $packed
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned-Portable.zip'))
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'rejects changed dependency bytes in the final archive' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'changed-dependency'
|
||||
$publish = Join-Path $root 'publish'
|
||||
$assets = Join-Path $root 'assets'
|
||||
$packed = Join-Path $root 'packed'
|
||||
New-Item -ItemType Directory -Path "$publish/app", $assets, $packed | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll"
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Microsoft.dll"
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
|
||||
Copy-Item -LiteralPath "$publish/app/Owned.dll" -Destination $packed
|
||||
Set-Content -LiteralPath (Join-Path $packed 'Microsoft.dll') -Value 'changed'
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned-Portable.zip'))
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Distributed binary changed*'
|
||||
}
|
||||
|
||||
It 'accepts the exact signed bytes shipped in a package' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'valid'
|
||||
$assets = Join-Path $root 'assets'
|
||||
New-Item -ItemType Directory -Path $root, $assets | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
|
||||
$packed = Join-Path $root 'packed'
|
||||
New-Item -ItemType Directory -Path $packed | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $root 'Owned.dll') -Destination $packed
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg'))
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'verifies separate NuGet packages whose IDs share the core package prefix' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'prefix'
|
||||
$assets = Join-Path $root 'assets'
|
||||
New-Item -ItemType Directory -Path $root, $assets | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll')
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.Adapter.dll')
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll', 'Owned.Adapter.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest
|
||||
foreach($name in @('Owned', 'Owned.Adapter'))
|
||||
{
|
||||
$packed = Join-Path $root $name
|
||||
New-Item -ItemType Directory -Path $packed | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $root "$name.dll") -Destination $packed
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets "$name.1.0.0.nupkg"))
|
||||
}
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'preserves same-name third-party satellite binaries in different culture directories' `
|
||||
{
|
||||
$root = Join-Path $TestDrive 'cultures'
|
||||
$publish = Join-Path $root 'publish'
|
||||
$assets = Join-Path $root 'assets'
|
||||
New-Item -ItemType Directory -Path $assets, "$publish/app/en", "$publish/app/fr" | Out-Null
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll"
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/en/Library.resources.dll"
|
||||
[IO.File]::AppendAllText("$publish/app/en/Library.resources.dll", 'English')
|
||||
Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/fr/Library.resources.dll"
|
||||
[IO.File]::AppendAllText("$publish/app/fr/Library.resources.dll", 'French')
|
||||
$manifest = Join-Path $root 'manifest.json'
|
||||
New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory("$publish/app", (Join-Path $assets 'Owned-Portable.zip'))
|
||||
{ Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Velopack installer payload' `
|
||||
{
|
||||
BeforeEach `
|
||||
{
|
||||
$root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
$script:Installer = Join-Path $root 'Probe-Setup.exe'
|
||||
$script:FullPackage = Join-Path $root 'Probe-full.nupkg'
|
||||
$payload = [Text.Encoding]::UTF8.GetBytes('exact full package payload')
|
||||
[IO.File]::WriteAllBytes($script:FullPackage, $payload)
|
||||
Copy-Item -LiteralPath (Get-WindowsSignTool) -Destination $script:Installer
|
||||
$stream = [IO.File]::Open($script:Installer, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite)
|
||||
$reader = [Reflection.PortableExecutable.PEReader]::new($stream, [Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen)
|
||||
try
|
||||
{
|
||||
$headerPosition = $reader.PEHeaders.SectionHeaders[0].PointerToRawData
|
||||
$packageOffset = $stream.Length
|
||||
$stream.Position = $headerPosition
|
||||
$stream.Write([BitConverter]::GetBytes($packageOffset))
|
||||
$stream.Write([BitConverter]::GetBytes([long] $payload.Length))
|
||||
$stream.Write([Convert]::FromHexString('94F0B17B6893E02937EB34EF53AAE7D42B54F5707EF5D6F57854983E5E94ED7D'))
|
||||
$stream.Position = $packageOffset
|
||||
$stream.Write($payload)
|
||||
}
|
||||
finally
|
||||
{
|
||||
$reader.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
It 'accepts the exact embedded full-package bytes' `
|
||||
{
|
||||
{ Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'rejects a different embedded payload of the same length' `
|
||||
{
|
||||
$bytes = [IO.File]::ReadAllBytes($script:FullPackage)
|
||||
$bytes[0] = 0
|
||||
[IO.File]::WriteAllBytes($script:FullPackage, $bytes)
|
||||
{ Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Throw '*different full package*'
|
||||
}
|
||||
|
||||
It 'rejects a different full-package length' `
|
||||
{
|
||||
[IO.File]::WriteAllText($script:FullPackage, 'short')
|
||||
{ Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Throw '*length differs*'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
#Requires -Version 7.4
|
||||
# Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolves the newest installed x64 Windows SDK SignTool.
|
||||
#>
|
||||
function Get-WindowsSignTool
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$sdk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin'
|
||||
$tools = @(Get-ChildItem -Path "$sdk/*/x64/signtool.exe" -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object { [version] $_.Directory.Parent.Name } -Descending)
|
||||
if($tools.Count -eq 0) { throw 'Install the Windows SDK with x64 SignTool.' }
|
||||
return $tools[0].FullName
|
||||
}
|
||||
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds an explicit signing catalog and records other binaries for preservation checks.
|
||||
#>
|
||||
function New-WindowsSigningCatalog
|
||||
{
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Root,
|
||||
[Parameter(Mandatory)] [string[]] $Patterns,
|
||||
[Parameter(Mandatory)] [string] $CatalogPath,
|
||||
[Parameter(Mandatory)] [string] $ManifestPath,
|
||||
[string] $ExpectedSubject = '',
|
||||
[switch] $PreserveOtherBinaries
|
||||
)
|
||||
|
||||
$rootPath = (Resolve-Path -LiteralPath $Root).Path
|
||||
$binaries = @(Get-ChildItem -LiteralPath $rootPath -File -Recurse |
|
||||
Where-Object Extension -In '.exe', '.dll', '.msi', '.msix')
|
||||
$owned = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach($pattern in $Patterns)
|
||||
{
|
||||
$pattern = $pattern.Trim().Replace('\', '/')
|
||||
if([string]::IsNullOrWhiteSpace($pattern) -or $pattern.Split('/') -contains '..' -or [IO.Path]::IsPathRooted($pattern))
|
||||
{
|
||||
throw "Signing patterns must be nonempty relative paths or file names: '$pattern'."
|
||||
}
|
||||
$catalogMatches = @($binaries | Where-Object {
|
||||
$candidate = if($pattern.Contains('/')) { [IO.Path]::GetRelativePath($rootPath, $_.FullName).Replace('\', '/') } else { $_.Name }
|
||||
$candidate -like $pattern
|
||||
})
|
||||
if($catalogMatches.Count -eq 0) { throw "No binary matches required signing pattern '$pattern'." }
|
||||
foreach($file in $catalogMatches)
|
||||
{
|
||||
if($ExpectedSubject)
|
||||
{
|
||||
$expected = [Security.Cryptography.X509Certificates.X500DistinguishedName]::new($ExpectedSubject).Name
|
||||
$existing = Get-AuthenticodeSignature -LiteralPath $file.FullName
|
||||
if($null -ne $existing.SignerCertificate -and $existing.SignerCertificate.SubjectName.Name -cne $expected)
|
||||
{
|
||||
throw "Refusing to replace another publisher's signature on '$($file.FullName)'. Keep it outside the owned catalog."
|
||||
}
|
||||
if($existing.Status -notin 'Valid', 'NotSigned') { throw "Invalid existing signature on '$($file.FullName)'." }
|
||||
}
|
||||
$null = $owned.Add($file.FullName)
|
||||
}
|
||||
}
|
||||
foreach($file in $binaries | Where-Object { ($PreserveOtherBinaries -or $owned.Contains($_.FullName)) -and $_.Extension -in '.exe', '.dll' })
|
||||
{
|
||||
$stream = [IO.File]::OpenRead($file.FullName)
|
||||
try
|
||||
{
|
||||
$reader = [Reflection.PortableExecutable.PEReader]::new($stream)
|
||||
try
|
||||
{
|
||||
if($null -eq $reader.PEHeaders.PEHeader) { throw 'Missing executable header.' }
|
||||
}
|
||||
finally { $reader.Dispose() }
|
||||
}
|
||||
catch { throw "Invalid published PE binary: $($file.Name). $($_.Exception.Message)" }
|
||||
finally { $stream.Dispose() }
|
||||
}
|
||||
$entries = @($binaries | Where-Object { $PreserveOtherBinaries -or $owned.Contains($_.FullName) } | ForEach-Object {
|
||||
[ordered]@{
|
||||
path = $_.FullName
|
||||
relativePath = [IO.Path]::GetRelativePath($rootPath, $_.FullName).Replace('\', '/')
|
||||
owned = $owned.Contains($_.FullName)
|
||||
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
|
||||
}
|
||||
})
|
||||
$catalogDirectory = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($CatalogPath))
|
||||
if(-not $PSCmdlet.ShouldProcess("$CatalogPath; $ManifestPath", 'Write signing catalog and preservation manifest')) { return }
|
||||
New-Item -ItemType Directory -Force -Path $catalogDirectory | Out-Null
|
||||
@($owned | Sort-Object | ForEach-Object { [IO.Path]::GetRelativePath($catalogDirectory, $_) }) |
|
||||
Set-Content -LiteralPath $CatalogPath -Encoding utf8NoBOM
|
||||
ConvertTo-Json -InputObject $entries -Depth 5 | Set-Content -LiteralPath $ManifestPath -Encoding utf8NoBOM
|
||||
}
|
||||
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Requires all Authenticode signatures to validate, with the configured publisher and a timestamp.
|
||||
#>
|
||||
function Assert-WindowsSignature
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string[]] $Files,
|
||||
[Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ExpectedSubject,
|
||||
[string] $SignTool = (Get-WindowsSignTool)
|
||||
)
|
||||
|
||||
if($Files.Count -eq 0) { throw 'No shipped binaries were supplied for verification.' }
|
||||
$expected = [Security.Cryptography.X509Certificates.X500DistinguishedName]::new($ExpectedSubject).Name
|
||||
foreach($file in $Files)
|
||||
{
|
||||
$output = (& $SignTool verify /pa /all /v $file 2>&1) -join "`n"
|
||||
Write-Output $output
|
||||
if($LASTEXITCODE -ne 0) { throw "SignTool verification failed for '$file'." }
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $file
|
||||
if($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or $null -eq $signature.TimeStamperCertificate)
|
||||
{
|
||||
throw "Missing valid timestamped Authenticode signature on '$file'."
|
||||
}
|
||||
if($signature.SignerCertificate.SubjectName.Name -cne $expected)
|
||||
{
|
||||
throw "Unexpected publisher on '$file': $($signature.SignerCertificate.Subject)."
|
||||
}
|
||||
$digests = @([regex]::Matches($output, '(?i)Hash of file \(([^)]+)\)') | ForEach-Object { $_.Groups[1].Value })
|
||||
if($digests.Count -eq 0 -or @($digests | Where-Object { $_ -ne 'sha256' }).Count -gt 0)
|
||||
{
|
||||
throw "SignTool did not report a SHA256 file digest for '$file'. Use the English Windows SDK verification output."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Verifies signed catalog entries and checks that other binary bytes were preserved.
|
||||
#>
|
||||
function Assert-WindowsSigningManifest
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $ManifestPath,
|
||||
[Parameter(Mandatory)] [string] $ExpectedSubject,
|
||||
[string] $SignTool = (Get-WindowsSignTool)
|
||||
)
|
||||
|
||||
$entries = @(Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json)
|
||||
$owned = @($entries | Where-Object owned | ForEach-Object path)
|
||||
Assert-WindowsSignature -Files $owned -ExpectedSubject $ExpectedSubject -SignTool $SignTool
|
||||
foreach($entry in $entries | Where-Object { -not $_.owned })
|
||||
{
|
||||
if((Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash -ne $entry.sha256)
|
||||
{
|
||||
throw "Third-party binary was modified: $($entry.relativePath)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks signed binaries inside the final ZIP/NuGet assets and their bytes against the signed build.
|
||||
#>
|
||||
function Assert-WindowsDistribution
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Directory,
|
||||
[Parameter(Mandatory)] [string] $ManifestPath,
|
||||
[Parameter(Mandatory)] [string] $ExpectedSubject,
|
||||
[string] $SignTool = (Get-WindowsSignTool)
|
||||
)
|
||||
|
||||
$entries = @(Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json)
|
||||
$owned = @($entries | Where-Object owned)
|
||||
if($owned.Count -eq 0) { throw 'The signing manifest has no owned binaries.' }
|
||||
$assets = @(Get-ChildItem -LiteralPath $Directory -File)
|
||||
$archives = @($assets | Where-Object { $_.Extension -in '.zip', '.nupkg' -and $_.Name -notlike '*-delta.nupkg' })
|
||||
if($archives.Count -eq 0) { throw 'No final ZIP or full NuGet package was produced.' }
|
||||
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach($archive in $archives)
|
||||
{
|
||||
$scratch = Join-Path ([IO.Path]::GetTempPath()) ('k22-signing-' + [guid]::NewGuid().ToString('N'))
|
||||
try
|
||||
{
|
||||
[IO.Compression.ZipFile]::ExtractToDirectory($archive.FullName, $scratch)
|
||||
$files = @(Get-ChildItem -LiteralPath $scratch -File -Recurse)
|
||||
$archiveSeen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach($entry in $entries)
|
||||
{
|
||||
$name = [IO.Path]::GetFileName($entry.path)
|
||||
$packageRelative = if($entry.relativePath.StartsWith('app/') -or $entry.relativePath.StartsWith('cli/')) { $entry.relativePath.Substring(4) } else { $name }
|
||||
$copies = @($files | Where-Object {
|
||||
$relative = [IO.Path]::GetRelativePath($scratch, $_.FullName).Replace('\', '/')
|
||||
$_.Name -eq $name -and ($relative -eq $packageRelative -or $relative.EndsWith('/' + $packageRelative, [StringComparison]::OrdinalIgnoreCase))
|
||||
})
|
||||
foreach($copy in $copies)
|
||||
{
|
||||
$hash = (Get-FileHash -LiteralPath $copy.FullName -Algorithm SHA256).Hash
|
||||
$expectedHash = if($entry.owned) { (Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash } else { $entry.sha256 }
|
||||
if($hash -ne $expectedHash)
|
||||
{
|
||||
# The portable archive also carries a generated launcher at its root.
|
||||
if($entry.owned -and $copy.Extension -eq '.exe' -and $copy.DirectoryName -eq $scratch -and $archive.Name -like '*-Portable.zip')
|
||||
{
|
||||
Assert-WindowsSignature -Files @($copy.FullName) -ExpectedSubject $ExpectedSubject -SignTool $SignTool
|
||||
continue
|
||||
}
|
||||
throw "Distributed binary changed in $($archive.Name): $name."
|
||||
}
|
||||
if($entry.owned)
|
||||
{
|
||||
Assert-WindowsSignature -Files @($copy.FullName) -ExpectedSubject $ExpectedSubject -SignTool $SignTool
|
||||
$null = $seen.Add($entry.path)
|
||||
}
|
||||
$null = $archiveSeen.Add($entry.path)
|
||||
}
|
||||
}
|
||||
$required = @($entries | Where-Object {
|
||||
if($archive.Name -like '*-cli-*.zip') { return $_.relativePath.StartsWith('cli/') }
|
||||
if($archive.Name -like '*-Portable.zip' -or $archive.Name -like '*-full.nupkg')
|
||||
{
|
||||
# Match Velopack's default exclusions for runtime helpers.
|
||||
if(-not $_.owned -and $_.relativePath -match '(?:^|/)createdump[^/]*|\.vshost\.') { return $false }
|
||||
return $_.relativePath.StartsWith('app/')
|
||||
}
|
||||
$packageId = [regex]::Escape([IO.Path]::GetFileNameWithoutExtension($_.path))
|
||||
return $_.owned -and $archive.Name -match "^$packageId\.[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?\.nupkg$"
|
||||
})
|
||||
foreach($entry in $required)
|
||||
{
|
||||
if(-not $archiveSeen.Contains($entry.path))
|
||||
{
|
||||
$kind = if($entry.owned) { 'Owned' } else { 'Third-party' }
|
||||
throw "$kind binary missing from $($archive.Name): $($entry.relativePath)."
|
||||
}
|
||||
}
|
||||
# Velopack creates these branded helpers before its signing hook.
|
||||
$updates = @($files | Where-Object { $_.Name -in 'Update.exe', 'Squirrel.exe' -or $_.Name -like '*_ExecutionStub.exe' } | ForEach-Object FullName)
|
||||
if($updates.Count -gt 0) { Assert-WindowsSignature -Files $updates -ExpectedSubject $ExpectedSubject -SignTool $SignTool }
|
||||
}
|
||||
finally
|
||||
{
|
||||
$temporaryRoot = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath([IO.Path]::GetTempPath()))
|
||||
if([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($scratch)) -ne $temporaryRoot -or [IO.Path]::GetFileName($scratch) -notlike 'k22-signing-*')
|
||||
{
|
||||
throw 'Refusing to remove a directory outside the signing scratch location.'
|
||||
}
|
||||
if(Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force }
|
||||
}
|
||||
}
|
||||
foreach($entry in $owned)
|
||||
{
|
||||
if(-not $seen.Contains($entry.path)) { throw "Owned binary is missing from distributed archives: $($entry.relativePath)." }
|
||||
}
|
||||
$installers = @($assets | Where-Object Extension -In '.exe', '.msi', '.msix' | ForEach-Object FullName)
|
||||
if($installers.Count -gt 0) { Assert-WindowsSignature -Files $installers -ExpectedSubject $ExpectedSubject -SignTool $SignTool }
|
||||
foreach($setup in $assets | Where-Object Name -Like '*-Setup.exe')
|
||||
{
|
||||
$fullPackages = @($assets | Where-Object Name -Like '*-full.nupkg')
|
||||
if($fullPackages.Count -ne 1) { throw 'Require one current full package to verify the installer payload.' }
|
||||
Assert-VelopackInstallerPayload -Installer $setup.FullName -FullPackage $fullPackages[0].FullName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Requires the signed Velopack installer to embed the exact verified full update package.
|
||||
#>
|
||||
function Assert-VelopackInstallerPayload
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Installer,
|
||||
[Parameter(Mandatory)] [string] $FullPackage
|
||||
)
|
||||
|
||||
# Velopack 1.2.161 SetupBundle: two little-endian Int64s followed by this marker.
|
||||
$marker = '94F0B17B6893E02937EB34EF53AAE7D42B54F5707EF5D6F57854983E5E94ED7D'
|
||||
$stream = [IO.File]::OpenRead($Installer)
|
||||
$reader = [Reflection.PortableExecutable.PEReader]::new($stream, [Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen)
|
||||
$hasher = [Security.Cryptography.IncrementalHash]::CreateHash([Security.Cryptography.HashAlgorithmName]::SHA256)
|
||||
try
|
||||
{
|
||||
$offset = 0L
|
||||
$length = 0L
|
||||
$headersFound = 0
|
||||
foreach($section in $reader.PEHeaders.SectionHeaders)
|
||||
{
|
||||
[byte[]] $bytes = $reader.GetSectionData($section.VirtualAddress).GetContent()
|
||||
$position = [Convert]::ToHexString($bytes).IndexOf($marker, [StringComparison]::Ordinal)
|
||||
if($position -lt 32 -or $position % 2 -ne 0) { continue }
|
||||
$headersFound++
|
||||
$offset = [BitConverter]::ToInt64($bytes, $position / 2 - 16)
|
||||
$length = [BitConverter]::ToInt64($bytes, $position / 2 - 8)
|
||||
}
|
||||
if($headersFound -ne 1 -or $offset -le 0 -or $length -le 0 -or $offset -gt $stream.Length -or $length -gt $stream.Length - $offset)
|
||||
{
|
||||
throw 'The Velopack installer bundle header is missing, ambiguous or outside the file.'
|
||||
}
|
||||
if($length -ne (Get-Item -LiteralPath $FullPackage).Length) { throw 'Installer payload length differs from the full package.' }
|
||||
$stream.Position = $offset
|
||||
$buffer = [byte[]]::new(1048576)
|
||||
$remaining = $length
|
||||
while($remaining -gt 0)
|
||||
{
|
||||
$count = $stream.Read($buffer, 0, [int] [math]::Min($buffer.Length, $remaining))
|
||||
if($count -eq 0) { throw 'Unexpected end of the installer payload.' }
|
||||
$hasher.AppendData($buffer, 0, $count)
|
||||
$remaining -= $count
|
||||
}
|
||||
$hash = [Convert]::ToHexString($hasher.GetHashAndReset())
|
||||
if($hash -ne (Get-FileHash -LiteralPath $FullPackage -Algorithm SHA256).Hash) { throw 'Installer embeds a different full package.' }
|
||||
}
|
||||
finally
|
||||
{
|
||||
$hasher.Dispose()
|
||||
$reader.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-WindowsSignTool, New-WindowsSigningCatalog, Assert-WindowsSignature, Assert-WindowsSigningManifest, Assert-WindowsDistribution, Assert-VelopackInstallerPayload
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.1.2
|
||||
1.2.1
|
||||
Reference in new issue
Block a user