From 8b958d723592ba1eb767569d644f0c925faf450b Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:07:30 +0200 Subject: [PATCH 1/7] Require a Sigstore bundle for every signed release file The integrity check derived the files to authenticate from the bundles attached to the release, so removing a bundle together with replacing its file went unnoticed as long as one bundle remained. The required set is now SHA256SUMS, every file it lists and every attached archive or installer, whichever bundles are present; a missing bundle is reported. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/scripts/release-integrity-test.py | 19 +++++++++++++++++ .github/scripts/release-integrity.py | 25 ++++++++++++++++++----- .gitignore | 1 + 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/scripts/release-integrity-test.py b/.github/scripts/release-integrity-test.py index e5d1abb..8d9d0e6 100644 --- a/.github/scripts/release-integrity-test.py +++ b/.github/scripts/release-integrity-test.py @@ -174,6 +174,25 @@ def test_negative_a_certificate_whose_signature_does_not_verify_does_not_chain(s self.assertFalse(self.trust.certificate_chains(changed, self.entry["integratedTime"])) +class Bundles(unittest.TestCase): + SUMS = {"hodeishield-1.0.0-x86_64.tar.gz": "a" * 64, "hodeishield-1.0.0.pkg": "b" * 64} + + def test_the_required_set_ignores_which_bundles_are_attached(self): + assets = {"SHA256SUMS", "install.sh", "hodeishield-1.0.0.msi", *self.SUMS} + self.assertEqual(ri.must_be_signed(assets, self.SUMS), {"SHA256SUMS", "install.sh", "hodeishield-1.0.0.msi", *self.SUMS}) + + def test_negative_a_deleted_bundle_does_not_shrink_the_required_set(self): + assets = {"SHA256SUMS", "SHA256SUMS.sigstore.json", *self.SUMS} + self.assertEqual(ri.must_be_signed(assets, self.SUMS), {"SHA256SUMS", *self.SUMS}) + + def test_a_file_in_sha256sums_is_required_even_if_its_name_matches_no_pattern(self): + self.assertIn("other.bin", ri.must_be_signed({"SHA256SUMS"}, {"other.bin": "c" * 64})) + + def test_bundles_and_provenance_are_not_required_to_have_bundles(self): + names = {"x.tar.gz.sigstore.json", "multiple.intoto.jsonl"} + self.assertEqual(ri.must_be_signed(names, {}), {"SHA256SUMS"}) + + class Parsing(unittest.TestCase): def test_sha256sums_in_text_and_binary_mode(self): text = "a" * 64 + " one.tar.gz\n" + "b" * 64 + " *two.zip\n\n" diff --git a/.github/scripts/release-integrity.py b/.github/scripts/release-integrity.py index cc15c6e..93ce4fe 100644 --- a/.github/scripts/release-integrity.py +++ b/.github/scripts/release-integrity.py @@ -17,8 +17,10 @@ commit, and from 0.4.0 a successful container-image.yml run; files every release file matches its line in the release's SHA256SUMS, and nothing else is attached; - Rekor every file the release signs (it has a `.sigstore.json` bundle) is looked up in - Rekor by its SHA-256. An entry counts only if its signed entry timestamp + bundles every archive and installer attached, every file in SHA256SUMS and SHA256SUMS + itself must carry a `.sigstore.json` bundle; this set does not depend on which + bundles are attached, so deleting a bundle is reported; + Rekor every one of those files is looked up in Rekor by its SHA-256. An entry counts only if its signed entry timestamp verifies with Rekor's key and its certificate chains to Sigstore's certificate authority at the time it was logged: Rekor accepts any certificate, so one that does not chain could claim any identity. At least @@ -70,6 +72,10 @@ ACCEPTED_RUNS: dict[int, str] = {} VERSION_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$") +# The release assets release.yml signs, by name (the "Sign every archive and SHA256SUMS" step). +SIGNED_ASSET = re.compile( + r"hodeishield-.*\.(tar\.gz|zip|msi|pkg|rpm|cdx\.json)|hodeishield[-_].*\.deb|install\.sh" +) IMAGE_SIGNATURE_TAG = re.compile(r"^sha256-[0-9a-f]{64}(\.sig|\.att|\.sbom)?$") @@ -232,6 +238,12 @@ def download(url: str) -> bytes: return body +def must_be_signed(assets, sums) -> set[str]: + """The files that must carry a Sigstore bundle, whichever bundles are attached: SHA256SUMS, + every file it lists and every attached file that release.yml signs by name.""" + return {"SHA256SUMS", *sums} | {name for name in assets if SIGNED_ASSET.fullmatch(name)} + + def parse_sha256sums(text: str) -> dict[str, str]: sums = {} for line in text.splitlines(): @@ -575,9 +587,12 @@ def problem(text: str): ): problem(f"{tag}: {name} is attached but not in SHA256SUMS") - signed = [name for name in digests if f"{name}.sigstore.json" in assets] - if not signed: - problem(f"{tag}: no file has a Sigstore bundle") + signed = [] + for name in sorted(must_be_signed(assets, sums)): + if f"{name}.sigstore.json" not in assets: + problem(f"{tag}: {name} has no Sigstore bundle") + elif name in digests: + signed.append(name) for name in signed: matched, wrong = judge_rekor( rekor_entries(digests[name], trust), repository=repository, tag=tag, releases=signers diff --git a/.gitignore b/.gitignore index ad67955..c3302fd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +__pycache__/ From 5b9ae756e1454be6119e66041396bfb7a86beb35 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:08:11 +0200 Subject: [PATCH 2/7] Bind macOS signing to an expected Apple Team ID The signing steps accepted any Developer ID certificate with a TeamIdentifier, and never read the installer certificate's team. The expected Team ID is now a constant in the workflows, compared exactly with the binary's TeamIdentifier and the installer certificate's Team ID. An empty or malformed value fails the first step. The constant is empty until a maintainer sets it. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/macos-signing-check.yml | 26 +++++++++++++++++++---- .github/workflows/release.yml | 24 +++++++++++++++++---- docs/release-signing.md | 16 +++++++++++--- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.github/workflows/macos-signing-check.yml b/.github/workflows/macos-signing-check.yml index 0878cfb..4fb6e5b 100644 --- a/.github/workflows/macos-signing-check.yml +++ b/.github/workflows/macos-signing-check.yml @@ -16,6 +16,12 @@ on: permissions: {} +# The Apple Team ID the Developer ID certificates must belong to: repository policy, changed only +# through a reviewed pull request. Empty until a maintainer sets it, so signing fails closed. The +# same value is in release.yml; keep the two in step. +env: + MACOS_TEAM_ID: "" + concurrency: group: macos-signing-check cancel-in-progress: false @@ -42,6 +48,12 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} run: | set -euo pipefail + # The expected Apple Team ID is repository policy (MACOS_TEAM_ID above), not a secret: the + # certificates in the environment must belong to it. Empty or malformed fails closed. + if ! [[ "$MACOS_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then + echo "::error::MACOS_TEAM_ID is not set to a 10-character Apple Team ID in this workflow." + exit 1 + fi missing="" for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGNING_IDENTITY \ MACOS_INSTALLER_CERTIFICATE MACOS_INSTALLER_CERTIFICATE_PASSWORD \ @@ -116,8 +128,8 @@ jobs: exit 1 fi done - if [ -z "$team" ] || [ "$team" = "not set" ]; then - echo "::error::The signature carries no TeamIdentifier." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi @@ -171,8 +183,8 @@ jobs: bin="$RUNNER_TEMP/check/signing-check" pkg="$RUNNER_TEMP/signing-check.pkg" team="$(codesign --display -vv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')" - if [ -z "$team" ] || [ "$team" = "not set" ]; then - echo "::error::The signature carries no TeamIdentifier." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi accepted=false @@ -192,6 +204,12 @@ jobs: echo "::error::The package is not signed with a Developer ID Installer certificate." exit 1 fi + # The installer certificate's Team ID is the last parenthesis of its name. + pkg_team="$(sed -n 's/^ *[0-9]*\. Developer ID Installer: .* (\([A-Z0-9]\{10\}\))$/\1/p' <<< "$sig")" + if [ "$pkg_team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The installer certificate's Team ID is not the expected MACOS_TEAM_ID." + exit 1 + fi xcrun stapler validate "$pkg" spctl --assess --type install --verbose=2 "$pkg" { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28a1251..f46c614 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -276,6 +276,10 @@ jobs: VERSION: ${{ needs.version.outputs.version }} TARGET: universal-apple-darwin SIGN: ${{ needs.version.outputs.sign_macos }} + # The Apple Team ID the Developer ID certificates must belong to: repository policy, changed + # only through a reviewed pull request. Empty until a maintainer sets it, so signing fails + # closed. The same value is in macos-signing-check.yml; keep the two in step. + MACOS_TEAM_ID: "" steps: - name: Download the unsigned binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -338,6 +342,12 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} run: | set -euo pipefail + # The expected Apple Team ID is repository policy (MACOS_TEAM_ID above), not a secret: the + # certificates in the environment must belong to it. Empty or malformed fails closed. + if ! [[ "$MACOS_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then + echo "::error::MACOS_TEAM_ID is not set to a 10-character Apple Team ID in this workflow." + exit 1 + fi missing=0 for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGNING_IDENTITY \ MACOS_INSTALLER_CERTIFICATE MACOS_INSTALLER_CERTIFICATE_PASSWORD \ @@ -397,8 +407,8 @@ jobs: exit 1 fi done - if [ -z "$team" ] || [ "$team" = "not set" ]; then - echo "::error::The signature carries no TeamIdentifier." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi @@ -461,8 +471,8 @@ jobs: bin="stage/${BIN_NAME}" pkg="dist/hodeishield-${VERSION}-${TARGET}.pkg" team="$(codesign --display -vv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')" - if [ -z "$team" ] || [ "$team" = "not set" ]; then - echo "::error::The signature carries no TeamIdentifier." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi accepted=false @@ -482,6 +492,12 @@ jobs: echo "::error::The package is not signed with a Developer ID Installer certificate." exit 1 fi + # The installer certificate's Team ID is the last parenthesis of its name. + pkg_team="$(sed -n 's/^ *[0-9]*\. Developer ID Installer: .* (\([A-Z0-9]\{10\}\))$/\1/p' <<< "$sig")" + if [ "$pkg_team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The installer certificate's Team ID is not the expected MACOS_TEAM_ID." + exit 1 + fi xcrun stapler validate "$pkg" spctl --assess --type install --verbose=2 "$pkg" { diff --git a/docs/release-signing.md b/docs/release-signing.md index 8b2e917..de723cf 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -100,6 +100,12 @@ checks the binary's notarization online on first run. See - **Identities.** A Developer ID Application certificate for the binary and a Developer ID Installer certificate for the package, both issued to Hodeitek S.L. by Apple's Developer ID certification authority. The signature carries the Apple Developer Team ID of the account. +- **Expected Team ID.** The Team ID is public and is kept in the repository, not in a secret: the + `MACOS_TEAM_ID` constant at the top of `release.yml` and `macos-signing-check.yml` (change both + in one reviewed pull request). The signing steps compare it exactly with the `TeamIdentifier` + of the binary's signature and with the Team ID of the installer certificate, and fail if either + differs or if the constant is empty or not ten characters of `A-Z0-9`. Valid Apple credentials of + another team therefore cannot pass, whatever the environment holds. - **Notarization.** `notarytool` authenticates with an App Store Connect API key (a `.p8` private key, its key ID and the issuer ID). - **Where they are kept.** Only as secrets of the `release-signing` environment; none is in the @@ -124,15 +130,19 @@ checks the binary's notarization online on first run. See - **Checking the secrets without a release (optional).** The **macOS signing check** workflow (`macos-signing-check.yml`, started by hand) signs and notarizes a throwaway test executable and package with the stored secrets, verifies them like a release does, and uploads nothing. It fails, - listing the names, if a secret is missing. It is bound to the same environment, so it needs the + listing the names, if a secret is missing, and if the certificates are not those of + `MACOS_TEAM_ID`. It is bound to the same environment, so it needs the same approval, and it has two preconditions: the workflow must be on the default branch to be dispatched, and the environment accepts deployments from `v*` tags only, so a repository administrator adds `main` to its allowed branches for the check and removes it afterwards. To enable it, a maintainer with the right to change the environment: -1. stores the nine secrets in the `release-signing` environment; -2. sets the repository variable `MACOS_SIGNING_ENABLED` to `true`. +1. sets `MACOS_TEAM_ID` in `release.yml` and `macos-signing-check.yml` to the Team ID of the + Hodeitek S.L. Apple Developer account, through a reviewed pull request merged to `main` + (while it is empty, the signing job fails at its first step); +2. stores the nine secrets in the `release-signing` environment; +3. sets the repository variable `MACOS_SIGNING_ENABLED` to `true`. The next release signs and notarizes the macOS binaries, and its draft is published only after the macOS job and its checks pass. If signing or notarization fails, the release run fails and nothing From 35eec1272c2ffae230aa8f81045cb5d74e0116ab Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:09:48 +0200 Subject: [PATCH 3/7] Restrict what the MSI check lets msiexec run The administrative extraction of the MSI runs AdminExecuteSequence, so standard actions such as SelfRegModules could load a DLL shipped under the name of a payload file. The check now also refuses the tables that make such actions load payload code, requires every action of the administrative sequences to be a passive standard action, and compares LICENSE, NOTICE and USER-INFORMATION.md with the repository's copies by SHA-256. The signing job now checks out the commit for that comparison. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f46c614..7ca2ce0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -864,7 +864,8 @@ jobs: retention-days: 1 # Signs the MSI with the same Azure Artifact Signing profile as the exe. Like `sign-windows`, it - # checks nothing out and runs no repository or WiX code. The MSI is published only when signed. + # runs no repository or WiX code: the checkout is only the reference copy of LICENSE, NOTICE and + # user-information.md that the extracted payload is compared with. The MSI is published only when signed. sign-msi: name: msi (sign) needs: [version, sign-windows, windows-msi] @@ -878,6 +879,11 @@ jobs: VERSION: ${{ needs.version.outputs.version }} SIGN: ${{ needs.version.outputs.sign_windows }} steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download the MSI uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -896,9 +902,11 @@ jobs: # The MSI was built by a job that ran WiX, third-party code: do not sign it on trust. Using # only Windows' own Installer API, check that it carries no custom actions or embedded - # binaries (so nothing runs at install time but standard actions), then extract it with an - # administrative install and check its payload is exactly the exe sign-windows signed, plus - # LICENSE, NOTICE and USER-INFORMATION.md. + # binaries (so nothing runs at install time but standard actions), and that its administrative + # sequences hold only passive standard actions: `msiexec /a` runs AdminExecuteSequence, and + # actions such as SelfRegModules would load a DLL shipped as a payload file. Then extract it + # with an administrative install and check its payload is exactly the exe sign-windows signed, + # plus LICENSE, NOTICE and USER-INFORMATION.md byte for byte as in the checked-out commit. - name: Check the MSI before signing it shell: powershell env: @@ -917,8 +925,20 @@ jobs: [void]$view.Close() return [int]$n } + # The first column of every row of a table (an absent table has none). + function Get-Column([string]$table, [string]$column) { + try { $view = $db.OpenView("SELECT ``$column`` FROM ``$table``") } catch { return @() } + [void]$view.Execute() + $values = @() + while ($null -ne ($record = $view.Fetch())) { $values += $record.StringData(1) } + [void]$view.Close() + return $values + } $bad = @() - foreach ($table in @("CustomAction", "Binary", "MsiEmbeddedUI", "MsiEmbeddedChainer")) { + # Tables whose standard actions load code from the payload: SelfReg (SelfRegModules), Class, + # TypeLib, MsiAssembly (RegisterClassInfo, RegisterTypeLibraries, MsiPublishAssemblies), + # ODBC drivers and data sources, and services. + foreach ($table in @("CustomAction", "Binary", "MsiEmbeddedUI", "MsiEmbeddedChainer", "SelfReg", "Class", "TypeLib", "MsiAssembly", "ODBCDriver", "ODBCDataSource", "ServiceInstall", "ServiceControl")) { $n = [int](Count-Rows $table) Write-Host "$table rows: $n" if ($n -ne 0) { $bad += $table } @@ -926,6 +946,19 @@ jobs: $files = [int](Count-Rows "File") Write-Host "File rows: $files" if ($files -ne 4) { $bad += "File ($files rows, expected 4)" } + # Only passive standard actions may run when the MSI is extracted or installed. A subset, so + # a WiX that omits one of them still passes; anything else, or a "Custom" sequence entry, + # does not. + $allowed = @{ + AdminExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallAdminPackage", "InstallFiles", "InstallFinalize") + AdminUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction") + } + foreach ($sequence in $allowed.Keys) { + $found = @(Get-Column $sequence "Action") + Write-Host "$sequence actions: $($found -join ', ')" + $extra = @($found | Where-Object { $allowed[$sequence] -notcontains $_ }) + if ($extra.Count -gt 0) { $bad += "$sequence ($($extra -join ', '))" } + } [System.Runtime.InteropServices.Marshal]::ReleaseComObject($db) | Out-Null if ($bad.Count -gt 0) { Write-Host "::error::the MSI has unexpected content: $($bad -join ', ')"; exit 1 } @@ -938,8 +971,15 @@ jobs: $exe = $payload | Where-Object { $_.Name -eq "hodeishield.exe" } $hash = (Get-FileHash -LiteralPath $exe.FullName -Algorithm SHA256).Hash.ToLower() if ($hash -ne $env:EXE_SHA256) { Write-Host "::error::the exe in the MSI is $hash, not the signed $env:EXE_SHA256"; exit 1 } + # The texts must be the ones in the repository at this commit, which windows-msi packaged. + $reference = @{ "LICENSE" = "LICENSE"; "NOTICE" = "NOTICE"; "USER-INFORMATION.md" = "docs\user-information.md" } + foreach ($name in $reference.Keys) { + $inMsi = (Get-FileHash -LiteralPath ($payload | Where-Object { $_.Name -eq $name }).FullName -Algorithm SHA256).Hash + $inRepo = (Get-FileHash -LiteralPath $reference[$name] -Algorithm SHA256).Hash + if ($inMsi -ne $inRepo) { Write-Host "::error::$name in the MSI ($inMsi) differs from the repository's ($inRepo)"; exit 1 } + } Remove-Item -Recurse -Force $target - Write-Host "MSI content: OK (no custom actions or binaries; payload is the signed exe, LICENSE, NOTICE, USER-INFORMATION.md)" + Write-Host "MSI content: OK (no custom actions or binaries; only passive admin actions; payload is the signed exe and the repository's LICENSE, NOTICE, USER-INFORMATION.md)" - name: Azure login (OIDC) if: env.SIGN == 'true' From 3aae80bc1729ae52723f81bad11507a589c83652 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:15:16 +0200 Subject: [PATCH 4/7] Allow only the tables and actions of the published MSI The MSI check now reads _Tables once and refuses any table the package does not use, reads every row without swallowing errors, allows only the standard actions seen in each sequence (as a subset) and pins the Registry and Environment rows to the InstallDir value and the PATH entry. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/release.yml | 79 ++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ca2ce0..8739124 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -917,48 +917,59 @@ jobs: $installer = New-Object -ComObject WindowsInstaller.Installer $db = $installer.OpenDatabase($msi, 0) # The COM calls return values PowerShell would add to the function's output: discard them. - function Count-Rows([string]$table) { - try { $view = $db.OpenView("SELECT * FROM ``$table``") } catch { return [int]0 } + # Fails closed: only a table that is not in _Tables is empty; any other error stops the job. + function Get-Rows([string]$table, [string[]]$columns) { + $list = ($columns | ForEach-Object { "``$_``" }) -join ", " + $view = $db.OpenView("SELECT $list FROM ``$table``") [void]$view.Execute() - $n = 0 - while ($null -ne $view.Fetch()) { $n++ } - [void]$view.Close() - return [int]$n - } - # The first column of every row of a table (an absent table has none). - function Get-Column([string]$table, [string]$column) { - try { $view = $db.OpenView("SELECT ``$column`` FROM ``$table``") } catch { return @() } - [void]$view.Execute() - $values = @() - while ($null -ne ($record = $view.Fetch())) { $values += $record.StringData(1) } + $rows = @() + while ($null -ne ($record = $view.Fetch())) { + $rows += ,((1..$columns.Count | ForEach-Object { $record.StringData($_) }) -join "|") + } [void]$view.Close() - return $values + return $rows } $bad = @() - # Tables whose standard actions load code from the payload: SelfReg (SelfRegModules), Class, - # TypeLib, MsiAssembly (RegisterClassInfo, RegisterTypeLibraries, MsiPublishAssemblies), - # ODBC drivers and data sources, and services. - foreach ($table in @("CustomAction", "Binary", "MsiEmbeddedUI", "MsiEmbeddedChainer", "SelfReg", "Class", "TypeLib", "MsiAssembly", "ODBCDriver", "ODBCDataSource", "ServiceInstall", "ServiceControl")) { - $n = [int](Count-Rows $table) - Write-Host "$table rows: $n" - if ($n -ne 0) { $bad += $table } + # Every table must be one of those WiX writes for this package (those of the published + # 0.5.1 MSI). Anything else, CustomAction, Binary, SelfReg, Class, TypeLib, MsiAssembly, + # ServiceInstall, RemoveFile, Shortcut, IniFile... included, is refused by not being listed. + $tables = @(Get-Rows "_Tables" @("Name")) + Write-Host "Tables: $($tables -join ', ')" + $knownTables = @("Property", "Upgrade", "LaunchCondition", "Directory", "File", "Component", "Environment", "Registry", "Feature", "FeatureComponents", "AdminUISequence", "AdminExecuteSequence", "AdvtExecuteSequence", "InstallUISequence", "InstallExecuteSequence", "Media", "MsiFileHash", "_Validation") + $extraTables = @($tables | Where-Object { $knownTables -notcontains $_ }) + if ($extraTables.Count -gt 0) { $bad += "tables ($($extraTables -join ', '))" } + # Only these standard actions may appear in a sequence (a subset: a WiX that omits one + # still passes). `msiexec /a` runs AdminExecuteSequence, so a custom or payload-loading + # action there would run code on this runner. + $allowedActions = @{ + AdminExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallAdminPackage", "InstallFiles", "InstallFinalize") + AdminUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction") + AdvtExecuteSequence = @("CostInitialize", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallFinalize", "PublishFeatures", "PublishProduct") + InstallUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction", "FindRelatedProducts", "LaunchConditions", "ValidateProductID", "MigrateFeatureStates") + InstallExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallFiles", "InstallFinalize", "PublishFeatures", "PublishProduct", "FindRelatedProducts", "LaunchConditions", "ValidateProductID", "MigrateFeatureStates", "ProcessComponents", "UnpublishFeatures", "RemoveRegistryValues", "RemoveEnvironmentStrings", "RemoveFiles", "RemoveFolders", "CreateFolders", "WriteRegistryValues", "WriteEnvironmentStrings", "RegisterUser", "RegisterProduct", "RemoveExistingProducts") } - $files = [int](Count-Rows "File") - Write-Host "File rows: $files" - if ($files -ne 4) { $bad += "File ($files rows, expected 4)" } - # Only passive standard actions may run when the MSI is extracted or installed. A subset, so - # a WiX that omits one of them still passes; anything else, or a "Custom" sequence entry, - # does not. - $allowed = @{ - AdminExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallAdminPackage", "InstallFiles", "InstallFinalize") - AdminUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction") - } - foreach ($sequence in $allowed.Keys) { - $found = @(Get-Column $sequence "Action") + foreach ($sequence in $allowedActions.Keys) { + if ($tables -notcontains $sequence) { continue } + $found = @(Get-Rows $sequence @("Action")) Write-Host "$sequence actions: $($found -join ', ')" - $extra = @($found | Where-Object { $allowed[$sequence] -notcontains $_ }) + $extra = @($found | Where-Object { $allowedActions[$sequence] -notcontains $_ }) if ($extra.Count -gt 0) { $bad += "$sequence ($($extra -join ', '))" } } + # The only state the package changes: its InstallDir value and the machine PATH entry. + $pinned = @{ + Registry = @{ Columns = @("Root", "Key", "Name", "Value", "Component_"); Rows = @("2|Software\Hodeitek\HodeiShield CLI|InstallDir|[INSTALLFOLDER]|SystemPath") } + Environment = @{ Columns = @("Environment", "Name", "Value", "Component_"); Rows = @("PathEntry|=-*PATH|[~];[INSTALLFOLDER]|SystemPath") } + } + foreach ($table in $pinned.Keys) { + if ($tables -notcontains $table) { continue } + $rows = @(Get-Rows $table $pinned[$table].Columns) + Write-Host "$table rows: $($rows -join ' ; ')" + $extra = @($rows | Where-Object { $pinned[$table].Rows -notcontains $_ }) + if ($extra.Count -gt 0) { $bad += "$table ($($extra -join ' ; '))" } + } + $files = @(Get-Rows "File" @("File")).Count + Write-Host "File rows: $files" + if ($files -ne 4) { $bad += "File ($files rows, expected 4)" } [System.Runtime.InteropServices.Marshal]::ReleaseComObject($db) | Out-Null if ($bad.Count -gt 0) { Write-Host "::error::the MSI has unexpected content: $($bad -join ', ')"; exit 1 } From 446cd535a443b5d9789c1068d7c888b1b1474d67 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:16:45 +0200 Subject: [PATCH 5/7] Refuse extended ACLs on the plain-text token store on macOS Mode bits do not cover NFSv4-style ACLs, which a parent folder can pass on to the sessions directory and the token file. On macOS the CLI now removes any ACL from the directory and the temporary file it creates, before a token is written, and refuses an existing directory or file that has one. On Linux a POSIX ACL is limited by the mask that chmod 600 sets, so nothing changes there. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/src/auth/file_store.rs | 95 ++++++++++++++++++- .../0001-sign-in-without-a-keychain.md | 2 +- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/hodeishield-cli/src/auth/file_store.rs b/crates/hodeishield-cli/src/auth/file_store.rs index 1262784..e8695f0 100644 --- a/crates/hodeishield-cli/src/auth/file_store.rs +++ b/crates/hodeishield-cli/src/auth/file_store.rs @@ -14,6 +14,12 @@ //! On every read and write the CLI refuses, and never repairs, a file or directory that is a //! symbolic link, that belongs to another user, or that is open to group or others. Windows has no //! mode bits to check and is not supported yet. +//! +//! On macOS the mode bits do not cover extended (NFSv4-style) ACLs, which a folder can pass on to +//! what is created in it. There the directory and the file are stripped of any ACL when the CLI +//! creates them, and one that has an ACL is refused like one open to others. On Linux a POSIX ACL +//! cannot widen access: `chmod 600` sets the ACL mask to no access, which limits every named +//! entry. use super::store::{Payload, StoredToken, TokenStore}; use crate::failure::{Failure, Result}; @@ -193,6 +199,71 @@ mod unix { std::io::Error::from(errno) } + /// Whether `ls -le` output lists ACL entries, which it prints as ` 0: user:name allow read` + /// lines after the entry. (The `+` in the mode column is hidden by `@` when there are extended + /// attributes, so it is not used.) + #[cfg(any(test, target_os = "macos"))] + pub(super) fn lists_acl_entries(output: &str) -> bool { + output.lines().any(|line| { + let line = line.trim_start(); + let digits = line.bytes().take_while(u8::is_ascii_digit).count(); + digits > 0 && line[digits..].starts_with(": ") + }) + } + + /// macOS: refuses a path that carries an extended ACL. Fails closed when `ls` cannot say. + #[cfg(target_os = "macos")] + fn refuse_acl(path: &Path) -> Result<()> { + let output = std::process::Command::new("/bin/ls") + .args(["-lde"]) + .arg(path) + .output() + .map_err(|e| io_failure("Cannot look at the ACL of", path, &e))?; + if !output.status.success() { + return Err(refused( + path, + "has an access control list that cannot be read", + None, + )); + } + if lists_acl_entries(&String::from_utf8_lossy(&output.stdout)) { + return Err(refused( + path, + "has an access control list (ACL), which can give other accounts access", + Some("If nobody else read it, `chmod -N` removes the ACL; a token that may have been read should be revoked from the app.".into()), + )); + } + Ok(()) + } + + /// macOS: removes any ACL, inherited or not, from something this process just created. + #[cfg(target_os = "macos")] + fn strip_acl(path: &Path) -> Result<()> { + let status = std::process::Command::new("/bin/chmod") + .arg("-N") + .arg(path) + .status() + .map_err(|e| io_failure("Cannot remove the ACL of", path, &e))?; + if !status.success() { + return Err(refused( + path, + "has an access control list that cannot be removed", + None, + )); + } + refuse_acl(path) + } + + #[cfg(not(target_os = "macos"))] + fn refuse_acl(_: &Path) -> Result<()> { + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + fn strip_acl(_: &Path) -> Result<()> { + Ok(()) + } + /// What is wrong with a file or directory, judged from its own `stat` (never a followed /// link): `None` when it can be trusted. The second string says how to fix a mode. pub(super) fn problem(stat: &Stat, want_dir: bool, euid: u32) -> Option<(String, String)> { @@ -239,7 +310,8 @@ mod unix { match problem(stat, want_dir, euid()) { None => Ok(()), Some((reason, fix)) => Err(refused(path, &reason, Some(fix).filter(|f| !f.is_empty()))), - } + }?; + refuse_acl(path) } /// The sessions directory as an open handle, checked with `fstat` on the handle itself. Every @@ -262,6 +334,8 @@ mod unix { .mode(0o700) .create(&store.dir) .map_err(|e| io_failure("Cannot create", &store.dir, &e))?; + // A folder's inheritable ACL may have been passed on to the new directory. + strip_acl(&store.dir)?; created = true; } Err(Errno::NOENT) => return Ok(None), @@ -417,9 +491,12 @@ mod unix { fn create_new(dir: &OwnedFd, name: &str, path: &Path) -> Result { let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC; - rustix::fs::openat(dir, name, flags, Mode::RUSR | Mode::WUSR) + let file = rustix::fs::openat(dir, name, flags, Mode::RUSR | Mode::WUSR) .map(File::from) - .map_err(|e| io_failure("Cannot create", path, &io_error(e))) + .map_err(|e| io_failure("Cannot create", path, &io_error(e)))?; + // Before anything is written to it, so a token never sits under an inherited ACL. + strip_acl(path)?; + Ok(file) } /// Writes `bytes` to a new 0600 file in the directory and renames it over `name`. @@ -851,6 +928,18 @@ mod tests { assert!(!store.dir.exists()); } + #[test] + fn ls_output_with_acl_entries_is_recognised() { + let with = + "drwx------+ 2 me staff 64 Oct 21 10:00 sessions\n 0: user:other allow list,search\n"; + let without = "drwx------@ 2 me staff 64 Oct 21 10:00 sessions\n"; + assert!(unix::lists_acl_entries(with)); + assert!(!unix::lists_acl_entries(without)); + assert!(!unix::lists_acl_entries( + "-rw------- 1 me staff 9 Oct 21 10:00 a: 1 file\n" + )); + } + #[test] fn network_file_systems_are_recognised_by_their_magic_number() { assert_eq!(network_file_system_for(0x6969), Some("NFS")); diff --git a/docs/decisions/0001-sign-in-without-a-keychain.md b/docs/decisions/0001-sign-in-without-a-keychain.md index 6de0252..048383c 100644 --- a/docs/decisions/0001-sign-in-without-a-keychain.md +++ b/docs/decisions/0001-sign-in-without-a-keychain.md @@ -36,7 +36,7 @@ token is short-lived. | Who can read it | Keychain | File readable only by its owner | API key in the environment | |---|---|---|---| | Code running as the same user | Yes, if the keychain is unlocked | Yes | Yes (and child processes inherit it) | -| Other users | No | No, if owner and mode are right | Depends on where it is kept | +| Other users | No | No, if owner and mode are right (on macOS also no ACL: the CLI strips ACLs from what it creates and refuses one that has them) | Depends on where it is kept | | `root` | Yes | Yes | Yes | | Backups, snapshots, disk images | Encrypted at rest | Plaintext | Plaintext if kept in a file | | A container layer, if sign-in ran at build time or a signed-in container is published | Not applicable | Plaintext | Same through `ENV` or `ARG` | From 71af291e296103c2183e04a7ac1c2d416925e45d Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:20:43 +0200 Subject: [PATCH 6/7] Stop tracking a compiled Python cache file It was committed by accident; __pycache__/ is now ignored. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .../security-txt-check.cpython-313.pyc | Bin 8437 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .github/scripts/__pycache__/security-txt-check.cpython-313.pyc diff --git a/.github/scripts/__pycache__/security-txt-check.cpython-313.pyc b/.github/scripts/__pycache__/security-txt-check.cpython-313.pyc deleted file mode 100644 index d4a24ccd907fb2e1f7088d56ef8ee8154bbf48ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8437 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 Date: Wed, 21 Oct 2026 13:40:40 +0200 Subject: [PATCH 7/7] Update rustls-webpki and webpki-root-certs for RUSTSEC-2026-0382 and -0383 rustls-webpki 0.103.16 rejects certificate paths through the distrusted intermediates, and webpki-root-certs 1.0.10 drops the root that issued them. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 41a39ba..c4409ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2094,9 +2094,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -2877,9 +2877,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ]