Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ scripts/test-ca-trust-guard.sh text eol=lf
scripts/install-tool.sh text eol=lf
scripts/install-tool.ps1 text eol=lf
scripts/gon-source.sh text eol=lf
scripts/verify-locked-go.sh text eol=lf
70 changes: 22 additions & 48 deletions .github/actions/ca-trust-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## What it checks

CA Trust Guard packages the gated SH10/v3.11 offline incident-chain probe.
CA Trust Guard measures offline incident chains against runner trust stores.
Use it as a job's first step, before checkout or credential setup:

```yaml
Expand Down Expand Up @@ -49,22 +49,19 @@ only failure of every candidate emits a probe ERROR. The selected instrument
emits an `isolation-self-check` / `VERIFIED-DISTRUST` row before the two positive
controls. This is instrument-control evidence, not physical-store retirement
evidence. Its path and `openssl version` propagate to probe, OS-deny/env records
and summary rows. Action inputs and outputs are unchanged.
and summary rows. **Outputs: none.**

Fixture bytes remain SHA-256-pinned with checkout text conversion disabled.
Generated native-tool text is a different boundary: Windows `jq.exe`
[converts LF output to CRLF by default](https://jqlang.org/manual/v1.7/#invoking-jq).
The deny leg normalizes generated TSV/fingerprint output and strips trailing CR
at path consumption; it does not normalize or waive checksums on fixture bytes.

**Why OpenSSL remains the measurement instrument:** Go 1.27.3 `crypto/x509`
applies the incident SPKI blacklist unconditionally through
`containsDistrustedSPKIHash`, even with explicit roots and historical capture
time. A native `x509.Verify` would reject even `vulnerable-roots.pem`, fail the
positive control, and erase BEFORE/residual distinctions and the R3 retirement
signal (the rejection is `CertificateInvalidError/NoValidChains`, not a missing
issuer). Do not replace the independent instrument with `x509.Verify` as a
cleanup. A regression test preserves this constraint as living documentation.
File-store measurement uses OpenSSL because Go 1.27.3 `crypto/x509` applies the
incident SPKI blacklist unconditionally through `containsDistrustedSPKIHash`,
even with explicit roots and historical capture time. Native `x509.Verify`
rejects `vulnerable-roots.pem` with `CertificateInvalidError/NoValidChains`,
preventing the positive control and BEFORE/AFTER store comparisons.

The stdlib-only `cmd/ca-trust-guard` is built using the runner-image Go toolchain
with `GOTOOLCHAIN=local`, `GOPROXY=off` and `GOSUMDB=off`. **Only the bootstrap
Expand All @@ -88,7 +85,7 @@ summarize --mode report|enforce --apply-distrust=true|false --os-deny=true|false
summarize also accepts --os-deny-elevation fail|warn (default fail)
```

`--stores auto` discovers the gated platform stores. Python certifi, Homebrew,
`--stores auto` discovers the platform stores. Python certifi, Homebrew,
and Windows Git-Bash OpenSSL discovery subprocesses return **paths only**;
these paths do not decide trust. Go drives the same explicit-store OpenSSL
measurement for every bundle. SecTrust (`security verify-cert`), Node's effective
Expand All @@ -102,32 +99,23 @@ same common name but different cross-signs:

| Certificate | SHA-1 | Source |
| --- | --- | --- |
| emSign ECC Root CA - G3 | `3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1` | multipass #951 |
| emSign Root TLS CA - G3 | `39D3236E4A609AB6190CBFA1AD02B3CC61DFC2C3` | multipass #951 |
| emSign Root CS CA - G3 | `1F74B6010A219FD9CA058D07AA053D15EFD9300D` | multipass #951 |
| emSign ECC Root CA - G3 | `3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1` | pinned `vulnerable-roots.pem` |
| emSign Root TLS CA - G3 | `39D3236E4A609AB6190CBFA1AD02B3CC61DFC2C3` | pinned `vulnerable-roots.pem` |
| emSign Root CS CA - G3 | `1F74B6010A219FD9CA058D07AA053D15EFD9300D` | pinned `vulnerable-roots.pem` |
| AAA Certificate Services | `D1EB23A46D17D68FD92564C2F1F1601764D8E349` | pinned incident `vulnerable-roots.pem` |
| EM DV TLS CA - G3B (emSign cross-sign) | `A99B289464F3B44343270A7A1E910F02370483FE` | pinned `intermediates-1.pem`, first certificate |
| EM DV TLS CA - G3B (AAA cross-sign) | `1396BE1372BC93DFA2A433CA964DBA91548A6E57` | pinned `intermediates-2.pem`, first certificate |

AAA was explicitly excluded from #951; it is fixture-derived, not a fingerprint
claimed from that diff. The original incident `roots.json` citation resolves to
this pinned fixture. The four roots are scoped to the incident, not #951's
wider emSign inventory. The two fixture intermediates are denied directly on
macOS and Windows because their keys were compromised; Linux ca-certificates
cannot blacklist intermediates, so root removal is its evidence.
The deny list contains the four roots and two intermediates in the pinned
fixtures. macOS and Windows deny the intermediate certificates directly;
Linux ca-certificates blacklists the roots and verifies their absence from
the compiled bundle.
Both fingerprints are checked for every candidate before the first mutation.
Unexpected same-name certificates are refused; authorized cross-signed variants
are distinguished by fingerprint. Linux retains #951's compiled-bundle
verification loop. No package upgrade or online fetch is performed.
Unexpected same-name certificates are refused; cross-signed variants are
distinguished by fingerprint. No package upgrade or online fetch is performed.
`bash scripts/deny-os.sh --dry-run` validates candidates and prints mutation
commands without executing them or claiming successful distrust.

Credit to [gsilvestrin](https://github.com/gsilvestrin) and
[ductone/multipass#951](https://github.com/ductone/multipass/pull/951) for the
adopted OS-level deny design: per-OS deny mechanics, fingerprint-keyed
refusal-before-mutation, and the Linux verification loop; also for the enforce
post-condition design point of verifying each mitigation where it was applied.

## Verdict table

| Verdict | Meaning |
Expand All @@ -145,7 +133,7 @@ Known SecTrust explicit-deny outcomes include `kSecTrustResultDeny`,
`SecTrustEvaluate result: kSecTrustResultDeny` and other `result: ...Deny...`
variants. `CSSMERR_TP_NOT_TRUSTED` remains verified distrust;
`CSSMERR_TP_VERIFY_ACTION_FAILED` remains unknown-cause rejection; other failures
remain errors. This preserves the canary-gated explicit-deny extension.
remain errors.

## Inputs

Expand Down Expand Up @@ -210,24 +198,10 @@ BEFORE baseline; AFTER rows are the actual per-store retirement observations.
remove only the incident thumbprints from `Cert:\LocalMachine\Disallowed`.
Clean-bundle application also sets global Git CA config and job environment.

Low-priority follow-up: the incident `impacted-intermediates.json` inventory
contains 16 compromised intermediates, but only two PEMs are available in these
pinned fixtures. Extend the deny set to all 16 after obtaining the remaining
PEMs from CT/crt.sh and verifying each inventory SHA-256. This is not silently
claimed as complete intermediate coverage and does not block this guard.

## Retirement rule

AFTER **per-store** `VERIFIED-DISTRUST` rows are the R3 retirement signal, not
an assertion that every client has been repaired. SecTrust denial and the
AFTER **per-store** `VERIFIED-DISTRUST` rows identify repaired physical stores;
they do not assert that every client is repaired. SecTrust denial and the
clean-bundle environment cover different clients. Untouched physical bundles
remain residual until the runner image changes or their specific store is
repaired; environment coverage alone is not physical-store retirement proof.

Scoped verification: `go vet ./cmd/ca-trust-guard/...`,
`go test ./cmd/ca-trust-guard/...`, `bash scripts/test-deny-os.sh`,
`bash scripts/test-ca-trust-guard.sh`, and shellcheck on the new Bash scripts.
Repository-owned `.github/workflows/ci.yaml` runs Go vet/tests, the script suites,
and ShellCheck on relevant pull requests and pushes to main. The reusable
`verify.yaml` remains caller-scoped; it does not run workflow-repository checks
for connector callers.
remain residual until the runner image or that specific store changes;
environment coverage alone does not establish physical-store distrust.
2 changes: 1 addition & 1 deletion .github/actions/ca-trust-guard/fixtures/SHA256SUMS
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ a32e9d07c2c6a7c8c82002cbe124f192c59a7af517caec209c58e967fa182f78 leaf-2.pem
7fec291ee3087852310d8dc78c7ab7f21220873b17c671edeeb1a68e0a347844 expected-1.json
598e42aa0b94e05ed1a4292f15e51be2017c491ea26d8efb84c8a7c628b45f8c expected-2.json
9c279a9a98d48f92ec67a0380ddaffc7af6014fc32df2703be77e1052e8e454a chains.json
54f1ab7a002b9f410a057ef9b5df82c5db309bcb720d919ed5d42e26e1346a4a incident-certs.json
f8e43fbfc1985bee58002c2865fed7c796b948b27a390fba7e172a8aeff821ba incident-certs.json
3e15c4c950355f53d04624fd6adf0264e0f3e814ce7ce684e652c76d4e5eaa51 isolation-ca.pem
8 changes: 4 additions & 4 deletions .github/actions/ca-trust-guard/fixtures/incident-certs.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,31 @@
"name": "emSign ECC Root CA - G3",
"sha1": "3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1",
"sha256": "86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B",
"source": "multipass #951; cross-checked against incident fixture vulnerable-roots.pem",
"source": "SHA256SUMS-pinned vulnerable-roots.pem",
"kind": "root",
"pem": "vulnerable-roots.pem"
},
{
"name": "emSign Root TLS CA - G3",
"sha1": "39D3236E4A609AB6190CBFA1AD02B3CC61DFC2C3",
"sha256": "7DD78D5F4F13459A83DFF9ABBBA62EDBAF6F2D102BF257FD712F4D9F2746ED8D",
"source": "multipass #951; cross-checked against incident fixture vulnerable-roots.pem",
"source": "SHA256SUMS-pinned vulnerable-roots.pem",
"kind": "root",
"pem": "vulnerable-roots.pem"
},
{
"name": "emSign Root CS CA - G3",
"sha1": "1F74B6010A219FD9CA058D07AA053D15EFD9300D",
"sha256": "F2EDAAFF3D9B701FB4D14C9F285F8C0BAD90FC3BCC1FA07D47F4718EDABA34A5",
"source": "multipass #951; cross-checked against incident fixture vulnerable-roots.pem",
"source": "SHA256SUMS-pinned vulnerable-roots.pem",
"kind": "root",
"pem": "vulnerable-roots.pem"
},
{
"name": "AAA Certificate Services",
"sha1": "D1EB23A46D17D68FD92564C2F1F1601764D8E349",
"sha256": "D7A7A0FB5D7E2731D771E9484EBCDEF71D5F0C3E0A2948782BC83EE0EA699EF4",
"source": "incident fixture vulnerable-roots.pem (AAA excluded from multipass #951)",
"source": "SHA256SUMS-pinned vulnerable-roots.pem",
"kind": "root",
"pem": "vulnerable-roots.pem"
},
Expand Down
70 changes: 70 additions & 0 deletions .github/actions/locked-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Locked release tools

Install checksum-verified release tools before configuring release credentials:

```yaml
- uses: ConductorOne/github-workflows/.github/actions/locked-tools@<commit-sha>
id: tools
with:
tools: go,node
```

The action owns the version/URL/checksum reader. It resolves its canonical
`tool-locks.txt` from the action directory, not from a caller checkout, verifies
artifacts before extraction, and checks each expected executable leaf before
publishing PATH. Requesting `gon` also installs Go first.

## Inputs and outputs

- `tools`: required comma-separated tool names. Unknown/duplicate tools and
missing platform locks fail closed.
- `locks`: optional reviewed lock override. Empty selects the action-local
canonical file; an override is not copied over that canonical file.
- `dest`: installation root, default `${{ runner.temp }}/locked-tools`.

Outputs:

| Output | Contract |
| --- | --- |
| `go`, `node`, `syft`, `goreleaser`, `goreleaser-pro`, `gon`, `minisign` | Installed tool version, or source commit for gon; empty when not requested. Go includes its `go` prefix. |
| `versions` | JSON per-tool map of the installed versions/source commits. |
| `lock_sha256` | SHA256 of the exact reviewed lock used throughout preparation, installation and output publication. |
| `lock_path` | Absolute path of that same existing lock file. Action-local by default; the resolved reviewed override when explicitly supplied. Native Windows form on Windows. |

Prepare exports the resolved path, hash and expected Go version to the later
phases. The action checks the lock hash before each later phase and rejects
changes. When Go is requested, **prepare alone** performs the `sort -u`
single-stable-Go-version sanity across all Go platform rows. The Go gate does
not rediscover versions from a checkout's lock.

## Go gate environment contract

After checking out caller/source helpers, wire the installed action's outputs
into the gate; do not derive its expectations from `_workflows/tool-locks.txt`
or any other checkout copy:

```yaml
- name: Check Go module requirements
shell: bash
env:
EXPECTED_GO_VERSION: ${{ steps.tools.outputs.go }}
EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }}
LOCK_PATH: ${{ steps.tools.outputs.lock_path }}
GOTOOLCHAIN: local
run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _caller/go.mod _workflows/go.mod
```

The same environment applies to `verify-locked-go.sh version`. Request `go`
(or `gon`, which adds it) to obtain a nonempty expected Go version.

All three expectation variables are required. The gate validates their shape,
requires an absolute readable `LOCK_PATH`, and hashes its bytes to assert the
provided identity. It never parses lock versions. Missing/partial expectations,
a changed lock, nonlocal toolchain mode or a runtime Go-version mismatch fail
closed.

Module mode first rejects missing, duplicate, malformed or too-new `go`
directives. It then checks runtime version equality and runs
`GOTOOLCHAIN=local go list -m` with an explicit module file, readonly/offline
module settings and workspace discovery disabled. The reported GoVersion must
agree with the directive. Neither gate mode can download a Go toolchain.
9 changes: 9 additions & 0 deletions .github/actions/locked-tools/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ outputs:
lock_sha256:
description: SHA256 of the exact lock file used by every phase, for provenance.
value: ${{ steps.result.outputs.lock_sha256 }}
lock_path:
description: Absolute path of the verified lock used by this action (action-local by default).
value: ${{ steps.result.outputs.lock_path }}
runs:
using: composite
steps:
Expand Down Expand Up @@ -65,6 +68,8 @@ runs:
env:
LOCKED_TOOLS: ${{ steps.prepare.outputs.tools }}
TOOL_LOCK_FILE: ${{ steps.prepare.outputs.lock_file }}
LOCK_PATH: ${{ steps.prepare.outputs.lock_path }}
EXPECTED_GO_VERSION: ${{ steps.prepare.outputs.go_version }}
LOCKED_TOOLS_DEST: ${{ steps.prepare.outputs.dest }}
EXPECTED_LOCK_SHA256: ${{ steps.prepare.outputs.lock_sha256 }}
run: bash "$GITHUB_ACTION_PATH/locked-tools.sh" install
Expand All @@ -75,6 +80,8 @@ runs:
env:
LOCKED_TOOLS: ${{ steps.prepare.outputs.tools }}
TOOL_LOCK_FILE: ${{ steps.prepare.outputs.lock_file }}
LOCK_PATH: ${{ steps.prepare.outputs.lock_path }}
EXPECTED_GO_VERSION: ${{ steps.prepare.outputs.go_version }}
LOCKED_TOOLS_DEST: ${{ steps.prepare.outputs.dest }}
EXPECTED_LOCK_SHA256: ${{ steps.prepare.outputs.lock_sha256 }}
LOCKED_GON_PATH: ${{ steps.prepare.outputs.gon_path }}
Expand All @@ -90,6 +97,8 @@ runs:
env:
LOCKED_TOOLS: ${{ steps.prepare.outputs.tools }}
TOOL_LOCK_FILE: ${{ steps.prepare.outputs.lock_file }}
LOCK_PATH: ${{ steps.prepare.outputs.lock_path }}
EXPECTED_GO_VERSION: ${{ steps.prepare.outputs.go_version }}
LOCKED_TOOLS_DEST: ${{ steps.prepare.outputs.dest }}
EXPECTED_LOCK_SHA256: ${{ steps.prepare.outputs.lock_sha256 }}
run: bash "$GITHUB_ACTION_PATH/locked-tools.sh" outputs
6 changes: 3 additions & 3 deletions .github/actions/locked-tools/gon-source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
lock="${TOOL_LOCK_FILE:-$script_dir/tool-locks.txt}"
commit="$(awk '$1 == "gon" && $3 == "darwin/*" && $4 == "source" && NF == 4 { print $2; count++ } END { if (count != 1) exit 1 }' "$lock")"
if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then
echo '::error title=GF13::Expected a single immutable gon source commit lock' >&2
echo '::error title=locked-tools::Expected a single immutable gon source commit lock' >&2
exit 1
fi
case "${1:-}" in
Expand All @@ -20,12 +20,12 @@ case "${1:-}" in
source_dir="$2"
dest="$3"
if [ "$(git -C "$source_dir" rev-parse HEAD)" != "$commit" ]; then
echo '::error title=GF13::gon checkout does not match locked source commit' >&2
echo '::error title=locked-tools::gon checkout does not match locked source commit' >&2
exit 1
fi
# Require the verified Go binary and checksum-authenticated module downloads.
if [ "${GOFLAGS:-}" != -mod=readonly ] || [ "${GOPROXY:-}" != https://proxy.golang.org ] || [ "${GOSUMDB:-}" != sum.golang.org ]; then
echo '::error title=GF13::gon requires readonly modules, proxy.golang.org, and sum.golang.org' >&2
echo '::error title=locked-tools::gon requires readonly modules, proxy.golang.org, and sum.golang.org' >&2
exit 1
fi
bash "$script_dir/verify-locked-go.sh" version
Expand Down
14 changes: 7 additions & 7 deletions .github/actions/locked-tools/install-tool.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ $ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

if (-not $IsWindows -or [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'X64') {
throw '::error title=GF13::Unsupported platform (expected windows/amd64)'
throw '::error title=locked-tools::Unsupported platform (expected windows/amd64)'
}
$lock = if ($env:TOOL_LOCK_FILE) { $env:TOOL_LOCK_FILE } else { Join-Path $PSScriptRoot 'tool-locks.txt' }
$rows = @(Get-Content $lock | Where-Object { $_ -notmatch '^\s*(#|$)' } | ForEach-Object {
$fields = $_ -split '\s+'
if ($fields[0] -eq $Tool -and $fields[2] -eq 'windows/amd64') { ,$fields }
})
if ($rows.Count -ne 1 -or $rows[0].Count -ne 5) {
throw "::error title=GF13::Expected exactly one artifact lock for $Tool windows/amd64"
throw "::error title=locked-tools::Expected exactly one artifact lock for $Tool windows/amd64"
}
$version = $rows[0][1]
$expected = $rows[0][3]
$url = $rows[0][4]
if ($expected -cnotmatch '^[0-9a-f]{64}$') {
throw "::error title=GF13::Invalid sha256 lock for $Tool"
throw "::error title=locked-tools::Invalid sha256 lock for $Tool"
}
switch ($Tool) {
'go' { $canonical = "https://go.dev/dl/$version.windows-amd64.zip" }
Expand All @@ -31,10 +31,10 @@ switch ($Tool) {
$package = "node-$version-win-x64"
$canonical = "https://nodejs.org/dist/$version/$package.zip"
}
default { throw "::error title=GF13::Unsupported artifact tool: $Tool" }
default { throw "::error title=locked-tools::Unsupported artifact tool: $Tool" }
}
if ($url -cne $canonical) {
throw "::error title=GF13::$Tool lock version/platform does not match its URL"
throw "::error title=locked-tools::$Tool lock version/platform does not match its URL"
}
$workdir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $workdir | Out-Null
Expand All @@ -43,7 +43,7 @@ try {
Invoke-WebRequest -Uri $url -OutFile $archive -MaximumRetryCount 3
$actual = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant()
if ($actual -cne $expected) {
Write-Host "::error title=GF13::$Tool sha256 mismatch"
Write-Host "::error title=locked-tools::$Tool sha256 mismatch"
throw "Expected $expected, got $actual"
}
# Do not extract anything until the committed hash has matched.
Expand All @@ -60,7 +60,7 @@ try {
$binaryName = if ($Tool -eq 'goreleaser-pro') { 'goreleaser.exe' } else { "$Tool.exe" }
$binaryPath = Join-Path $bin $binaryName
if (-not (Test-Path -LiteralPath $binaryPath -PathType Leaf)) {
Write-Host "::error title=GF13::$Tool package layout mismatch"
Write-Host "::error title=locked-tools::$Tool package layout mismatch"
throw "Expected tool binary not found at $binaryPath"
}
if ($Tool -eq 'go') {
Expand Down
Loading
Loading