Skip to content
Draft
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: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ test-scripts:
python3 scripts/test-account-status-lifecycle.py
bash scripts/test-s3-release-uploads.sh
bash scripts/test-validate-release-artifacts.sh
python3 scripts/test-certificate-identities.py
bash scripts/test-cosign-retry.sh
bash scripts/test-publish-homebrew-formula.sh
bash scripts/test-deny-os.sh
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,7 @@ To test changes, point a connector at your branch:
```yaml
uses: ConductorOne/github-workflows/.github/workflows/release.yaml@my-branch
```

After the exact-ref cutover, a branch-ref run fails `verify-release` and is not
recorded; branch canaries need a reviewed test-only path (not provided by this
change).
30 changes: 27 additions & 3 deletions docs/release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,14 +435,14 @@ cosign verify-blob-attestation \
--bundle artifact.provenance.sigstore.json \
--type https://slsa.dev/provenance/v1 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@.*' \
--certificate-identity-regexp '^https://github\.com/ConductorOne/github-workflows/\.github/workflows/release\.yaml@refs/tags/v4$' \
artifact.zip

# Verify image provenance
cosign verify-attestation \
--type https://slsa.dev/provenance/v1 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@.*' \
--certificate-identity-regexp '^https://github\.com/ConductorOne/github-workflows/\.github/workflows/release\.yaml@refs/tags/v4$' \
public.ecr.aws/conductorone/baton-foo@sha256:abc123
```

Expand Down Expand Up @@ -532,6 +532,9 @@ Use test connectors for validation:
```yaml
uses: ConductorOne/github-workflows/.github/workflows/release.yaml@your-branch
```
After the exact-ref cutover, a branch-ref run fails `verify-release` and is
not recorded; branch canaries need a reviewed test-only path (not provided
by this change).
3. Create a test release on test connector repo via UI
4. Wait for workflow to complete
5. Validate outputs:
Expand Down Expand Up @@ -585,6 +588,27 @@ The `scripts/validate-release-artifacts.sh` script validates:
- ECR Public image attestations (if present)
- Manifest signature (if present)

All cosign checks use the exact certificate identity
`https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v4`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (confidence: high): This section now says the only accepted identity is @refs/tags/v4. But the consumer-facing verification examples in the same doc (lines 438, 445, 632) still use release.yaml@.*, and the Certificate Identity section (line 454) still says @<ref>. Anyone following those examples gets the any-ref trust this PR is meant to remove. Please update them to the anchored regexp.

and the GitHub Actions issuer. The anchored regexp escapes literal dots and
rejects branch refs, other tags, commit refs, lookalike paths and suffixes.
It is configured locally, never derived from the presented certificate and not
widenable by an environment override. `v4` is the selected moving release tag,
not a frozen commit SHA; tag protection and reviewed, logged promotions are
prerequisites for the coordinated cutover.

**Coordinated rollout hold:** do not merge or promote this validation-policy
change independently of the fleet registry exact-ref enforcement flip. Complete
the observed-ref inventory and protect the workflow tag first, then ship the
producer pin and registry policy as one coordinated cutover. This change does
not alter registry configuration, tag protections or any release ref.

`python3 scripts/test-certificate-identities.py` runs the actual validation
script with offline curl/cosign fixtures for every verification path, including
the legacy detached-manifest fallback. Positive and negative identity fixtures
and an intentionally widened-regexp control prove the expected policy wiring;
they are not live certificate or registry-cutover evidence.

```bash
./scripts/validate-release-artifacts.sh ORG/REPO VERSION
```
Expand All @@ -608,7 +632,7 @@ cosign verify-blob-attestation \
--bundle baton-github-test-v0.1.102-darwin-arm64.zip.provenance.sigstore.json \
--type https://slsa.dev/provenance/v1 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@.*' \
--certificate-identity-regexp '^https://github\.com/ConductorOne/github-workflows/\.github/workflows/release\.yaml@refs/tags/v4$' \
baton-github-test-v0.1.102-darwin-arm64.zip
```

Expand Down
121 changes: 121 additions & 0 deletions scripts/test-certificate-identities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Offline policy-wiring controls through the actual release validation script."""
from collections import Counter
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest

ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts/validate-release-artifacts.sh"
IDENTITY = "https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v4"
ISSUER = "https://token.actions.githubusercontent.com"


class CertificateIdentityTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.directory = Path(self.temporary.name)
self.bin = self.directory / "bin"
self.bin.mkdir()
manifest = {"semver": "v1.2.3", "assets": {"linux-amd64": {"href": "https://dist.conductorone.com/releases/ConductorOne/example/v1.2.3/example.tar.gz"}},
"images": {"ecrPublic": {"uri": "public.ecr.aws/example/image@sha256:" + "a" * 64}}}
self.manifest = self.directory / "manifest.json"
self.manifest.write_text(json.dumps(manifest))
curl = self.bin / "curl"
curl.write_text('''#!/usr/bin/env python3
import os, pathlib, sys
args = sys.argv[1:]
url = next((arg for arg in args if arg.startswith("https://")), "")
if os.environ.get("FIXTURE_DETACHED_MANIFEST") == "true" and url.endswith("manifest.json.sigstore.json"):
sys.exit(22)
if "-o" in args:
output = pathlib.Path(args[args.index("-o") + 1])
output.write_bytes(pathlib.Path(os.environ["FIXTURE_MANIFEST"]).read_bytes() if url.endswith("/manifest.json") else b"fixture")
''')
curl.chmod(0o755)
cosign = self.bin / "cosign"
cosign.write_text('''#!/usr/bin/env python3
import json, os, re, sys
args = sys.argv[1:]
with open(os.environ["FIXTURE_CALLS"], "a") as output:
output.write(json.dumps(args) + "\\n")
try:
pattern = args[args.index("--certificate-identity-regexp") + 1]
issuer = args[args.index("--certificate-oidc-issuer") + 1]
except (ValueError, IndexError):
sys.exit("missing certificate policy")
# For this anchored literal/ref expression fullmatch models Go's end-of-text '$'.
if not re.fullmatch(pattern, os.environ["FIXTURE_IDENTITY"]) or issuer != os.environ["FIXTURE_ISSUER"]:
sys.exit("certificate identity or issuer mismatch")
''')
cosign.chmod(0o755)

def validate(self, identity=IDENTITY, issuer=ISSUER, detached=False, script=SCRIPT):
calls = self.directory / "calls.jsonl"
calls.unlink(missing_ok=True)
environment = dict(os.environ, PATH=f"{self.bin}:{os.environ['PATH']}",
FIXTURE_MANIFEST=str(self.manifest), FIXTURE_CALLS=str(calls),
FIXTURE_IDENTITY=identity, FIXTURE_ISSUER=issuer,
FIXTURE_DETACHED_MANIFEST=str(detached).lower(),
COSIGN_VERIFY_ATTEMPTS="1", COSIGN_VERIFY_DELAY="0",
CERT_IDENTITY_REGEXP=".*") # Must not override configured policy.
completed = subprocess.run(["bash", str(script), "ConductorOne/example", "v1.2.3"],
env=environment, capture_output=True, text=True)
return completed, [json.loads(line) for line in calls.read_text().splitlines()]

def test_exact_protected_ref_on_every_verification_path(self):
for detached in (False, True):
with self.subTest(detached_manifest=detached):
result, calls = self.validate(detached=detached)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(Counter(call[0] for call in calls),
{"verify-blob": 2, "verify-blob-attestation": 2, "verify-attestation": 1})
self.assertEqual(len({call[call.index("--certificate-identity-regexp") + 1] for call in calls}), 1)
for call in calls:
self.assertEqual(call[call.index("--certificate-oidc-issuer") + 1], ISSUER)
manifest_call = calls[-1]
self.assertEqual("--bundle" in manifest_call, not detached)
self.assertEqual("--signature" in manifest_call, detached)

def test_other_refs_and_lookalike_identities_are_rejected(self):
invalid = [
IDENTITY.replace("refs/tags/v4", ref) for ref in
("refs/heads/main", "refs/heads/topic", "refs/tags/v3", "refs/tags/v40",
"refs/tags/v4.0.0", "refs/tags/v4-foo", "refs/tags/v4/x", "v4", "a" * 40)]
invalid += ["prefix/" + IDENTITY, IDENTITY + "/suffix", IDENTITY + "\n",
IDENTITY.replace("github.com", "githubXcom"),
IDENTITY.replace("/.github/", "/Xgithub/"),
IDENTITY.replace("release.yaml", "releaseXyaml"),
IDENTITY.replace("github-workflows/", "github-axiomatic-workflows/")]
for identity in invalid:
with self.subTest(identity=identity):
result, calls = self.validate(identity)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(len(calls), 5)
self.assertIn("Failed: 5", result.stdout)

def test_wrong_issuer_is_rejected(self):
result, _ = self.validate(issuer=ISSUER + ".untrusted")
self.assertNotEqual(result.returncode, 0)

def test_widened_regexp_negative_control_would_accept_branch(self):
source = SCRIPT.read_text()
pattern = re.search(r"^CERT_IDENTITY_REGEXP='([^']+)'$", source, re.MULTILINE)[1]
widened = pattern.replace("@refs/tags/v4$", "@.*$")
self.assertNotEqual(pattern, widened)
mutant = self.directory / "wide-validation.sh"
mutant.write_text(source.replace(pattern, widened))
branch = IDENTITY.replace("refs/tags/v4", "refs/heads/main")
rejected, _ = self.validate(branch)
accepted, _ = self.validate(branch, script=mutant)
self.assertNotEqual(rejected.returncode, 0)
self.assertEqual(accepted.returncode, 0, accepted.stdout + accepted.stderr)


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions scripts/validate-release-artifacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ fail_verify() {
sed 's/^/ /' "$COSIGN_STDERR"
}

# Certificate identity pattern for cosign verification
CERT_IDENTITY_REGEXP='https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@.*'
# Exact protected workflow ref; coordinate changes with registry identity policy.
CERT_IDENTITY_REGEXP='^https://github\.com/ConductorOne/github-workflows/\.github/workflows/release\.yaml@refs/tags/v4$'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (confidence: high): release.yaml's verify-release job (line ~1773) runs this script from job.workflow_sha, and record-registry-api only runs when verify-release succeeds. With this pin, any release that calls the workflow at a ref other than v4 (a branch, @v4.0.1, or a SHA pin) now fails in-run verification and never gets recorded. That breaks the branch-testing process still documented in docs/release-workflow.md:527-540 and README.md:478-482. Please document this limitation in the testing instructions, or add a deliberate test-only path, so maintainers don't hit an unexpected release failure.

CERT_OIDC_ISSUER='https://token.actions.githubusercontent.com'

echo ""
Expand Down
Loading