diff --git a/.github/workflows/complete-ubuntu-security-backports-v0.1.0.yml b/.github/workflows/complete-ubuntu-security-backports-v0.1.0.yml new file mode 100644 index 0000000000..074f518b50 --- /dev/null +++ b/.github/workflows/complete-ubuntu-security-backports-v0.1.0.yml @@ -0,0 +1,134 @@ +name: Complete Existing Ubuntu Backports v0.1.0 Attestation + +on: + pull_request: + paths: + - '.github/workflows/complete-ubuntu-security-backports-v0.1.0.yml' + - '.github/workflows/publish-ubuntu-security-backports.yml' + - 'server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py' + - 'server/security/ubuntu-backports/bundle/published-v0.1.0-candidate.lock.json' + - 'server/security/ubuntu-backports/bundle/test_complete_published_v010.py' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ubuntu-security-backports-v0.1.0-completion + cancel-in-progress: false + +jobs: + checks: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check the single-candidate completion boundaries + run: python3 -B server/security/ubuntu-backports/bundle/test_complete_published_v010.py + + complete: + needs: checks + if: github.event_name == 'workflow_dispatch' && github.repository == 'PastureStack/server' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + actions: read + packages: write + id-token: write + attestations: write + artifact-metadata: write + env: + IMAGE: ghcr.io/pasturestack/ubuntu-security-backports + DIGEST: sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a + PRODUCER: 59f4ec1f31e510361c4635023c08fcf2a2b2133c + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Bind original failed run and exact immutable producer artifact + id: original + run: | + set -Eeuo pipefail + helper=server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py + work="$(python3 -B "$helper" --print-work-root)" + printf 'work=%s\n' "$work" >>"$GITHUB_OUTPUT" + python3 -B "$helper" --prepare + + - name: Authenticate only for attestation and original provenance verification + run: printf '%s' "$GH_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + + - name: Verify existing OCI provenance from the original protected producer + env: + WORK: ${{ steps.original.outputs.work }} + run: | + set -Eeuo pipefail + gh attestation verify "oci://$IMAGE@$DIGEST" --repo PastureStack/server \ + --signer-workflow PastureStack/server/.github/workflows/publish-ubuntu-security-backports.yml \ + --source-ref refs/heads/main --source-digest "$PRODUCER" --signer-digest "$PRODUCER" \ + --predicate-type https://slsa.dev/provenance/v1 --format json >"$WORK/publisher/original-provenance-verified.json" + python3 -B server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py --check-provenance + + - name: Pull exact digest anonymously and retain original output bytes + env: + WORK: ${{ steps.original.outputs.work }} + run: | + set -Eeuo pipefail + mkdir "$WORK/anonymous-docker" "$WORK/readback" + printf '{"auths":{}}\n' >"$WORK/anonymous-docker/config.json" + docker --config "$WORK/anonymous-docker" pull "$IMAGE@$DIGEST" + container="$(docker create --entrypoint /unused "$IMAGE@$DIGEST")" + trap 'docker rm "$container" >/dev/null' EXIT + docker cp "$container:/out" "$WORK/readback/out" + docker cp "$container:/bundle/install-existing-backports" "$WORK/readback/bundle-installer" + + - name: Validate unchanged original SBOM using pinned official CycloneDX schemas + env: + WORK: ${{ steps.original.outputs.work }} + run: | + set -Eeuo pipefail + python3 -m venv "$WORK/schema-venv" + "$WORK/schema-venv/bin/python" -m pip install --disable-pip-version-check --only-binary=:all: \ + jsonschema==4.25.1 attrs==25.3.0 jsonschema-specifications==2025.4.1 \ + referencing==0.36.2 rpds-py==0.27.1 typing-extensions==4.15.0 + "$WORK/schema-venv/bin/python" -B server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py --check-sbom + + - name: Attest the original SBOM with explicit supported predicate mode + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ghcr.io/pasturestack/ubuntu-security-backports + subject-digest: sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a + predicate-type: https://cyclonedx.org/bom + predicate-path: ${{ steps.original.outputs.work }}/readback/out/sbom.cdx.json + push-to-registry: true + + - name: Verify exact new predicate and complete all original anonymous output checks + env: + WORK: ${{ steps.original.outputs.work }} + run: | + set -Eeuo pipefail + gh attestation verify "oci://$IMAGE@$DIGEST" --repo PastureStack/server \ + --signer-workflow PastureStack/server/.github/workflows/complete-ubuntu-security-backports-v0.1.0.yml \ + --source-ref refs/heads/main --source-digest "$GITHUB_SHA" --signer-digest "$GITHUB_SHA" \ + --predicate-type https://cyclonedx.org/bom --format json >"$WORK/publisher/sbom-verified.json" + python3 -B server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py --finish + + - name: Clear registry credentials + if: always() + run: docker logout ghcr.io + + - name: Retain completion evidence and original failure identity + if: always() && steps.original.outputs.work != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ubuntu-security-backports-v0.1.0-completion-${{ github.sha }}-${{ github.run_id }} + path: | + ${{ steps.original.outputs.work }}/publisher/*.json + ${{ steps.original.outputs.work }}/readback/out/sbom.cdx.json + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/publish-ubuntu-security-backports.yml b/.github/workflows/publish-ubuntu-security-backports.yml index 32e87fdf9a..51b1a2048c 100644 --- a/.github/workflows/publish-ubuntu-security-backports.yml +++ b/.github/workflows/publish-ubuntu-security-backports.yml @@ -226,7 +226,8 @@ jobs: with: subject-name: ghcr.io/pasturestack/ubuntu-security-backports subject-digest: ${{ steps.image.outputs.digest }} - sbom-path: ${{ steps.inputs.outputs.work }}/context/out/sbom.cdx.json + predicate-type: https://cyclonedx.org/bom + predicate-path: ${{ steps.inputs.outputs.work }}/context/out/sbom.cdx.json push-to-registry: true - name: Require public anonymous pull and exact OCI data readback diff --git a/server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py b/server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py new file mode 100644 index 0000000000..878d21ace8 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/complete-published-v0.1.0.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Complete one already-pushed candidate; never build, push or change payloads.""" +import argparse +import importlib.metadata +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import urllib.request + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('original_qualifier', HERE / 'qualify-native-packages.py') +Q = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(Q) +LOCK = HERE / 'published-v0.1.0-candidate.lock.json' +IDENTITY = { + 'image': 'ghcr.io/pasturestack/ubuntu-security-backports', + 'digest': 'sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a', + 'producerCommit': '59f4ec1f31e510361c4635023c08fcf2a2b2133c', + 'runId': 40138193717, 'artifactId': 12577573510, + 'artifactZipSha256': '635c5cddec48776278061c47084062518cd6f1c173f862f9b9b49dbe29441f72', + 'sbomSha256': '6ae4fb736a8df63dc3db6dd74b753cf9008379f8414f05c7bd30ae7b2cafd470', +} +PREDICATE = 'https://cyclonedx.org/bom' +ORIGINAL_INVOCATION = 'https://github.com/PastureStack/server/actions/runs/40138193717/attempts/1' +require = Q.require + + +def load_lock(): + lock = json.loads(LOCK.read_bytes()) + require(set(lock) == {'schemaVersion', 'scope', 'repository', 'publicationState', 'identity', 'producer', + 'artifact', 'outMetadataSha256', 'sbomDocument', 'officialSchemas'} + and lock['schemaVersion'] == 1 and lock['scope'] == 'completion-only-existing-v0.1.0' + and lock['repository'] == Q.REPO and lock['identity'] == IDENTITY, + 'This completion is restricted to the single reviewed published candidate') + return lock + + +def verify_api(lock, run, jobs, artifact): + identity, producer = lock['identity'], lock['producer'] + require(run['id'] == identity['runId'] and run['head_sha'] == identity['producerCommit'] + and run['head_branch'] == 'main' and run['event'] == 'workflow_dispatch' + and run['path'] == producer['workflow'] and run['run_attempt'] == 1 + and run['status'] == 'completed' and run['conclusion'] == 'failure' + and run['repository']['full_name'] == Q.REPO, 'Original run identity/status changed') + by_name = {j['name']: j for j in jobs['jobs']} + require(jobs['total_count'] == len(jobs['jobs']) == 2 and set(by_name) == {'checks', 'publish'}, + 'Original checks/publish job set changed') + for name, conclusion, job_id in [('checks', 'success', producer['checksJobId']), + ('publish', 'failure', producer['publishJobId'])]: + job = by_name[name] + require(job['id'] == job_id and job['run_id'] == identity['runId'] + and job['head_sha'] == identity['producerCommit'] + and job['status'] == 'completed' and job['conclusion'] == conclusion, + 'Original job identity/outcome changed: ' + name) + require([{k: s[k] for k in ('number', 'name', 'status', 'conclusion')} + for s in by_name['publish']['steps']] == producer['steps'], + 'Original native/qualification/providers/push/provenance or sole SBOM failure changed') + require(artifact['id'] == identity['artifactId'] and artifact['name'] == lock['artifact']['name'] + and artifact['size_in_bytes'] == lock['artifact']['bytes'] and artifact['expired'] is False + and artifact['digest'] == 'sha256:' + identity['artifactZipSha256'] + and artifact['workflow_run']['id'] == identity['runId'] + and artifact['workflow_run']['head_sha'] == identity['producerCommit'], + 'Original artifact is not bound to the reviewed producer') + + +def verify_metadata(lock, files): + for name, digest in lock['outMetadataSha256'].items(): + require(Q.sha(files[name]) == digest, 'Original metadata bytes changed: ' + name) + sbom = json.loads(files['sbom.cdx.json']) + require(Q.sha(files['sbom.cdx.json']) == lock['identity']['sbomSha256'] + and sbom == lock['sbomDocument'] and len(sbom['components']) == 13, + 'SBOM bytes/format/13-component identity differs from original') + manifest, qualification = json.loads(files['manifest.json']), json.loads(files['qualification.json']) + require(set(manifest) == {'schemaVersion', 'bundleVersion', 'platform', 'qualification', 'families', 'packages'} + and manifest['schemaVersion'] == qualification['schemaVersion'] == 1 + and manifest['bundleVersion'] == qualification['bundleVersion'] == '0.1.0' + and manifest['platform'] == qualification['platform'] == Q.PLATFORM + and qualification['status'] == 'qualified' and len(manifest['packages']) == 12 + and manifest['qualification'] == {'path': 'qualification.json', 'sha256': Q.sha(files['qualification.json'])}, + 'Original qualified package identity is incomplete') + components = {c['purl']: c for c in sbom['components']} + require(len(components) == 13 and len({p['name'] for p in manifest['packages']}) == 12, + 'Duplicate SBOM component/package') + for package in manifest['packages']: + path = str(Q.safe_name(package['archive']['path'])); c = components[Q.purl(package)] + require(c['name'] == package['name'] and c['version'] == package['version'] and c['type'] == 'library' + and c['bom-ref'] == c['purl'] + and c['hashes'] == [{'alg': 'SHA-256', 'content': package['archive']['sha256']}] + and Q.sha(files[path]) == package['archive']['sha256'], 'SBOM component/deb archive mismatch') + installer = components['pkg:generic/pasturestack/backport-installer@0.1.0'] + require(installer['name'] == 'install-existing-backports' and installer['type'] == 'application' + and installer['hashes'] == [{'alg': 'SHA-256', 'content': Q.sha(files['install-existing-backports'])}] + and qualification['installer']['sha256'] == Q.sha(files['install-existing-backports']), + 'SBOM installer hash/identity mismatch') + return sbom + + +def out_files(directory): + require(directory.is_dir() and not any(p.is_symlink() for p in directory.rglob('*')), 'Unsafe/missing out directory') + return {p.relative_to(directory).as_posix(): p.read_bytes() for p in directory.rglob('*') if p.is_file()} + + +def prepare(lock): + require(not Q.WORK.exists(), 'Completion work directory must be new') + Q.WORK.mkdir(); publisher = Q.WORK / 'publisher'; publisher.mkdir() + run = Q.gh_json('actions/runs/' + str(IDENTITY['runId'])) + jobs = Q.gh_json('actions/runs/' + str(IDENTITY['runId']) + '/jobs?per_page=100') + artifact = Q.gh_json('actions/artifacts/' + str(IDENTITY['artifactId'])) + verify_api(lock, run, jobs, artifact) + Q.write_json(publisher / 'original-api.json', {'run': run, 'jobs': jobs, 'artifact': artifact}) + archive = publisher / 'original.zip' + archive.write_bytes(subprocess.check_output(['gh', 'api', 'repos/' + Q.REPO + '/actions/artifacts/' + + str(IDENTITY['artifactId']) + '/zip'])) + entries = Q.read_zip(archive, IDENTITY['artifactZipSha256']) + files = {n.removeprefix('context/out/'): b for n, b in entries.items() if n.startswith('context/out/')} + verify_metadata(lock, files) + require(Q.sha(entries['publisher/published-manifest.json']) == IDENTITY['digest'].removeprefix('sha256:') + and json.loads(entries['publisher/image-metadata.json'])['containerimage.digest'] == IDENTITY['digest'], + 'Original push metadata is not the immutable subject') + for name, data in files.items(): + path = Q.WORK / 'context/out' / str(Q.safe_name(name)) + path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(data) + + +def verify_schema(lock, document): + from jsonschema import Draft7Validator + from referencing import Registry, Resource + require(importlib.metadata.version('jsonschema') == '4.25.1', 'Exact reviewed schema validator is required') + schemas = [] + for pin in lock['officialSchemas']: + with urllib.request.urlopen(pin['url'], timeout=30) as response: + data = response.read(2 * 1024 * 1024 + 1) + require(len(data) <= 2 * 1024 * 1024 and Q.sha(data) == pin['sha256'], 'Official schema bytes changed') + schemas.append(json.loads(data)) + registry = Registry().with_resources((s['$id'], Resource.from_contents(s)) for s in schemas) + Draft7Validator.check_schema(schemas[0]); validator = Draft7Validator(schemas[0], registry=registry) + validator.validate(document) + mutations = [] + for kind in ('format', 'type', 'name'): + changed = json.loads(json.dumps(document)) + if kind == 'format': changed['bomFormat'] = 'NOT-CYCLONEDX' + if kind == 'type': changed['metadata']['component']['type'] = 'invalid-component-type' + if kind == 'name': changed['components'][0].pop('name') + require(not validator.is_valid(changed), 'Official schema negative control was accepted: ' + kind) + mutations.append(kind) + Q.write_json(Q.WORK / 'publisher/schema-validation.json', {'status': 'PASS', 'validator': 'jsonschema 4.25.1', + 'sbomSha256': IDENTITY['sbomSha256'], 'officialSchemas': lock['officialSchemas'], 'rejectedControls': mutations}) + + +def verify_attestation(results, predicate_type, predicate=None): + require(isinstance(results, list) and results, 'Native gh verification did not return verified statements') + for result in results: + statement = result['verificationResult']['statement'] + require(statement['predicateType'] == predicate_type + and statement['subject'] == [{'name': IDENTITY['image'], + 'digest': {'sha256': IDENTITY['digest'].removeprefix('sha256:')}}] + and (predicate is None or statement['predicate'] == predicate), + 'Native verified predicate/subject is not the exact original artifact') + if predicate_type == 'https://slsa.dev/provenance/v1': + require(statement['predicate']['runDetails']['metadata']['invocationId'] == ORIGINAL_INVOCATION, + 'Original provenance belongs to a different producer run/attempt') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + modes = parser.add_mutually_exclusive_group(required=True) + for mode in ('prepare', 'check-sbom', 'check-provenance', 'finish', 'print-work-root'): + modes.add_argument('--' + mode, action='store_true') + args = parser.parse_args(); lock = load_lock() + if args.print_work_root: print(Q.WORK); return + if args.prepare: prepare(lock); return + publisher = Q.WORK / 'publisher' + if args.check_provenance: + verify_attestation(json.loads((publisher / 'original-provenance-verified.json').read_bytes()), + 'https://slsa.dev/provenance/v1'); return + document = verify_metadata(lock, out_files(Q.WORK / 'readback/out')) + if args.check_sbom: verify_schema(lock, document); return + require(json.loads((publisher / 'schema-validation.json').read_bytes())['status'] == 'PASS', 'Schema gate missing') + verify_attestation(json.loads((publisher / 'sbom-verified.json').read_bytes()), PREDICATE, document) + Q.verify_readback() + Q.write_json(publisher / 'completion-receipt.json', {'schemaVersion': 1, 'status': 'PASS_COMPLETION_ONLY', + 'identity': IDENTITY, 'originalRunConclusion': 'failure', 'completionSourceCommit': os.environ['GITHUB_SHA'], + 'sbomBytesUnchanged': True, 'anonymousFullOutReadback': 'PASS', 'rebuiltOrRepushedImage': False, + 'verificationSha256': {n: Q.file_sha(publisher / n) for n in + ('original-api.json', 'original-provenance-verified.json', 'schema-validation.json', 'sbom-verified.json')}}) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as error: + print('PUBLISHED_V010_COMPLETION_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/server/security/ubuntu-backports/bundle/published-v0.1.0-candidate.lock.json b/server/security/ubuntu-backports/bundle/published-v0.1.0-candidate.lock.json new file mode 100644 index 0000000000..efc12feaa6 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/published-v0.1.0-candidate.lock.json @@ -0,0 +1,359 @@ +{ + "artifact": { + "bytes": 12539172, + "name": "ubuntu-security-backports-v0.1.0-59f4ec1f31e510361c4635023c08fcf2a2b2133c-40138193717" + }, + "identity": { + "artifactId": 12577573510, + "artifactZipSha256": "635c5cddec48776278061c47084062518cd6f1c173f862f9b9b49dbe29441f72", + "digest": "sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a", + "image": "ghcr.io/pasturestack/ubuntu-security-backports", + "producerCommit": "59f4ec1f31e510361c4635023c08fcf2a2b2133c", + "runId": 40138193717, + "sbomSha256": "6ae4fb736a8df63dc3db6dd74b753cf9008379f8414f05c7bd30ae7b2cafd470" + }, + "officialSchemas": [ + { + "name": "bom-1.6.schema.json", + "sha256": "3e92dddbc30cf7f6a02b80f0942b1a4cfd4fb1c26f1dfc4310afa9d613cafb93", + "url": "https://raw.githubusercontent.com/CycloneDX/specification/1.6/schema/bom-1.6.schema.json" + }, + { + "name": "jsf-0.82.schema.json", + "sha256": "8bae002c25e723db7ee1f26afde680ae1a2b1a8f6b4b4b0fd65dc3becb090aae", + "url": "https://raw.githubusercontent.com/CycloneDX/specification/1.6/schema/jsf-0.82.schema.json" + }, + { + "name": "spdx.schema.json", + "sha256": "baa9d3bd1ed57b6751b0887edead6b5063ff53ff7429cf85d476c6c94af0166e", + "url": "https://raw.githubusercontent.com/CycloneDX/specification/1.6/schema/spdx.schema.json" + } + ], + "outMetadataSha256": { + "SHA256SUMS": "cc79a4b326ce57d2885cb2df99a17374359b3c6f1f2fd8830c7a9a26c9215b97", + "manifest.json": "f48ab8ad0ad2a79cae5049a7225a221b4a350f3aaea9f3602184babf5ed74c2e", + "openvex.json": "111f1e0f3c42649144c121b4544d49d2ee1399666eb7c31e0393440d6e387799", + "qualification.json": "07747eca9f3b7c42fed92d854e427ace81305c90b5ceb37318f07e1e577b6cf9", + "source-inputs.lock.json": "9ce2862b2dbe9ea9bd806f561a66df276816e9a4d26233aa46de4f03e1509cd2" + }, + "producer": { + "checksJobId": 121004122243, + "publishJobId": 121004225140, + "steps": [ + { + "conclusion": "success", + "name": "Set up job", + "number": 1, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Check out the exact protected publisher source", + "number": 2, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Require all native package inputs before building", + "number": 3, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Authenticate the existing Server owner package namespace", + "number": 4, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Refuse an existing tag or an ambiguous registry response", + "number": 5, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Download and bind the reviewed native artifacts", + "number": 6, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Check out the released Server 551 PCRE2 producer", + "number": 7, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Verify released PCRE2 source and build the current static installer", + "number": 8, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Qualify package bytes, native receipts, payload metadata, SBOM and exact-package VEX", + "number": 9, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Require native exact-package glibc OpenVEX semantics", + "number": 10, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Require real installation in both isolated Ubuntu provider fixtures", + "number": 11, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Publish the new build-only OCI data artifact", + "number": 12, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Attest image provenance with the Server workflow identity", + "number": 13, + "status": "completed" + }, + { + "conclusion": "failure", + "name": "Attest payload SBOM", + "number": 14, + "status": "completed" + }, + { + "conclusion": "skipped", + "name": "Require public anonymous pull and exact OCI data readback", + "number": 15, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Clear registry credentials", + "number": 16, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Retain exact qualified package inputs and publication evidence", + "number": 17, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Post Check out the released Server 551 PCRE2 producer", + "number": 33, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Post Check out the exact protected publisher source", + "number": 34, + "status": "completed" + }, + { + "conclusion": "success", + "name": "Complete job", + "number": 35, + "status": "completed" + } + ], + "workflow": ".github/workflows/publish-ubuntu-security-backports.yml" + }, + "publicationState": "OCI pushed and provenance attested; original run failed only at SBOM attestation; completion/readback not yet accepted.", + "repository": "PastureStack/server", + "sbomDocument": { + "bomFormat": "CycloneDX", + "components": [ + { + "bom-ref": "pkg:deb/ubuntu/libpcre2-8-0@10.46-1build1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "b15853575ae72e0cfe19358a5642d1fa54a52bad7c725253c229ed85211b6a1f" + } + ], + "name": "libpcre2-8-0", + "purl": "pkg:deb/ubuntu/libpcre2-8-0@10.46-1build1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "10.46-1build1+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/libpcre2-posix3@10.46-1build1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "f90ae28d8b8fb3defc8636cb9de7770de75735c0d73241dc81e705fc8f73a7cb" + } + ], + "name": "libpcre2-posix3", + "purl": "pkg:deb/ubuntu/libpcre2-posix3@10.46-1build1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "10.46-1build1+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/libc6@2.43-2ubuntu2.4%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "4119ebc8610d17694db4100e6838a693f8927189523f4b4be32be5aa4a82f497" + } + ], + "name": "libc6", + "purl": "pkg:deb/ubuntu/libc6@2.43-2ubuntu2.4%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "2.43-2ubuntu2.4+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/libc-bin@2.43-2ubuntu2.4%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "6e8ea949edd167a48e4f2ded82f4117e4dca89fc7c8060062b614bbb682b9417" + } + ], + "name": "libc-bin", + "purl": "pkg:deb/ubuntu/libc-bin@2.43-2ubuntu2.4%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "2.43-2ubuntu2.4+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/libc-gconv-modules-extra@2.43-2ubuntu2.4%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "22cb6a83267d6e873538983d1eafda6cac9240c5c18ff61e2652fd6ae982e374" + } + ], + "name": "libc-gconv-modules-extra", + "purl": "pkg:deb/ubuntu/libc-gconv-modules-extra@2.43-2ubuntu2.4%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "2.43-2ubuntu2.4+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/rust-coreutils@0.10.0-1ubuntu2~26.04.1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "f90cdece3ffa01acc9153e3741725febb696e02db8ccf6c0d0c4f98ff93ec609" + } + ], + "name": "rust-coreutils", + "purl": "pkg:deb/ubuntu/rust-coreutils@0.10.0-1ubuntu2~26.04.1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "0.10.0-1ubuntu2~26.04.1+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/jq@1.8.2-0pasturestack1~26.04.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "88dce8936c13daa0195783836e5b8658d25545068b59a8e8232ae8db442dcea9" + } + ], + "name": "jq", + "purl": "pkg:deb/ubuntu/jq@1.8.2-0pasturestack1~26.04.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "1.8.2-0pasturestack1~26.04.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/libjq1@1.8.2-0pasturestack1~26.04.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "e1b61456c00ae0508406163000d1a816de0c64e4db9f268a9667f31c4315f1df" + } + ], + "name": "libjq1", + "purl": "pkg:deb/ubuntu/libjq1@1.8.2-0pasturestack1~26.04.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "1.8.2-0pasturestack1~26.04.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/libp11-kit0@0.26.2-2%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "b4d244aed0607da60c6c2da716fae1d66edc4a59449d5b598df613b84069702d" + } + ], + "name": "libp11-kit0", + "purl": "pkg:deb/ubuntu/libp11-kit0@0.26.2-2%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "0.26.2-2+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/p11-kit@0.26.2-2%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "d816fde1ff781f00facca43d37684085c7286d7056ad990e40d3d162e6f511d6" + } + ], + "name": "p11-kit", + "purl": "pkg:deb/ubuntu/p11-kit@0.26.2-2%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "0.26.2-2+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/p11-kit-modules@0.26.2-2%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "hashes": [ + { + "alg": "SHA-256", + "content": "1429e81a3a7ba4b148e0966af4abc441dcfd2179e41efd8fb1c8ad7016e94f4f" + } + ], + "name": "p11-kit-modules", + "purl": "pkg:deb/ubuntu/p11-kit-modules@0.26.2-2%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04", + "type": "library", + "version": "0.26.2-2+pasturestack.1" + }, + { + "bom-ref": "pkg:deb/ubuntu/libxrender1@0.9.12-1build1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04&epoch=1", + "hashes": [ + { + "alg": "SHA-256", + "content": "49fa0140dcc46481271fb203c2b4bf7a4c9b588bd849f498856c75a4926c212c" + } + ], + "name": "libxrender1", + "purl": "pkg:deb/ubuntu/libxrender1@0.9.12-1build1%2Bpasturestack.1?arch=amd64&distro=ubuntu-26.04&epoch=1", + "type": "library", + "version": "1:0.9.12-1build1+pasturestack.1" + }, + { + "bom-ref": "pkg:generic/pasturestack/backport-installer@0.1.0", + "hashes": [ + { + "alg": "SHA-256", + "content": "ca5202850ec65c6bd58b9fd307405c611bcccb7808f931324e70c9adbe350fe9" + } + ], + "name": "install-existing-backports", + "properties": [ + { + "name": "pasturestack:go-toolchain", + "value": "go1.27.3" + } + ], + "purl": "pkg:generic/pasturestack/backport-installer@0.1.0", + "type": "application", + "version": "0.1.0" + } + ], + "metadata": { + "component": { + "name": "ubuntu-security-backports", + "type": "data", + "version": "0.1.0" + } + }, + "specVersion": "1.6", + "version": 1 + }, + "schemaVersion": 1, + "scope": "completion-only-existing-v0.1.0" +} diff --git a/server/security/ubuntu-backports/bundle/test_complete_published_v010.py b/server/security/ubuntu-backports/bundle/test_complete_published_v010.py new file mode 100644 index 0000000000..6b951dbe53 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/test_complete_published_v010.py @@ -0,0 +1,152 @@ +"""Completion metadata/CLI mutations only; no CI, build, scanner or registry calls.""" +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('completion', HERE / 'complete-published-v0.1.0.py') +C = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(C) + + +def encoded(value): + return (json.dumps(value, indent=2, sort_keys=True) + '\n').encode() + + +def api_fixture(lock): + i, p = lock['identity'], lock['producer'] + run = {'id': i['runId'], 'head_sha': i['producerCommit'], 'head_branch': 'main', 'event': 'workflow_dispatch', + 'path': p['workflow'], 'run_attempt': 1, 'status': 'completed', 'conclusion': 'failure', + 'repository': {'full_name': C.Q.REPO}} + jobs = {'total_count': 2, 'jobs': [{'name': name, 'id': p[name + 'JobId'], 'run_id': i['runId'], + 'head_sha': i['producerCommit'], 'status': 'completed', 'conclusion': conclusion, + 'steps': copy.deepcopy(p['steps']) if name == 'publish' else []} + for name, conclusion in [('checks', 'success'), ('publish', 'failure')]]} + artifact = {'id': i['artifactId'], 'name': lock['artifact']['name'], 'expired': False, + 'size_in_bytes': lock['artifact']['bytes'], 'digest': 'sha256:' + i['artifactZipSha256'], + 'workflow_run': {'id': i['runId'], 'head_sha': i['producerCommit']}} + return run, jobs, artifact + + +def metadata_fixture(): + # Synthetic byte bodies exercise the metadata validator independently of + # the production loader's immutable real-candidate pins. + lock = copy.deepcopy(C.load_lock()); document = copy.deepcopy(lock['sbomDocument']); files = {} + qualification = {'schemaVersion': 1, 'bundleVersion': '0.1.0', 'platform': C.Q.PLATFORM, + 'status': 'qualified', 'installer': {}} + manifest = {'schemaVersion': 1, 'bundleVersion': '0.1.0', 'platform': C.Q.PLATFORM, + 'families': [], 'packages': []} + for component in document['components']: + name = component['name']; body = ('unit-fixture-' + name).encode(); digest = C.Q.sha(body) + component['hashes'] = [{'alg': 'SHA-256', 'content': digest}] + if name == 'install-existing-backports': + files[name] = body; qualification['installer']['sha256'] = digest; continue + archive = 'debs/' + name + '.deb'; files[archive] = body + manifest['packages'].append({'name': name, 'version': component['version'], 'archive': {'path': archive, 'sha256': digest}}) + files['qualification.json'] = encoded(qualification) + manifest['qualification'] = {'path': 'qualification.json', 'sha256': C.Q.sha(files['qualification.json'])} + files['manifest.json'] = encoded(manifest); files['sbom.cdx.json'] = encoded(document) + lock['sbomDocument'] = document; lock['identity']['sbomSha256'] = C.Q.sha(files['sbom.cdx.json']) + lock['outMetadataSha256'] = {n: C.Q.sha(files[n]) for n in ['manifest.json', 'qualification.json']} + return lock, files + + +class CompletionBoundaries(unittest.TestCase): + def test_single_published_candidate_has_no_generic_subject_or_version_input(self): + lock = C.load_lock(); self.assertEqual(lock['identity'], C.IDENTITY) + self.assertEqual(len(lock['sbomDocument']['components']), 13) + self.assertNotIn('serialNumber', lock['sbomDocument']) + for kind in ('digest', 'producerCommit', 'runId', 'artifactId', 'sbomSha256', 'unknown-metadata'): + changed = copy.deepcopy(lock) + if kind == 'unknown-metadata': changed['unknown'] = 'unreviewed' + else: changed['identity'][kind] = 'wrong' + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / C.LOCK.name; path.write_bytes(encoded(changed)) + with self.subTest(kind=kind), patch.object(C, 'LOCK', path), self.assertRaises(ValueError): C.load_lock() + + def test_original_failure_is_preserved_with_all_prior_required_steps_passed(self): + lock = C.load_lock(); run, jobs, artifact = api_fixture(lock); original = copy.deepcopy((run, jobs, artifact)) + C.verify_api(lock, run, jobs, artifact) + self.assertEqual((run, jobs, artifact), original) + failed = [s['name'] for s in jobs['jobs'][1]['steps'] if s['conclusion'] == 'failure'] + self.assertEqual(failed, ['Attest payload SBOM']) + + def test_wrong_source_job_or_original_required_failure_is_rejected(self): + for kind in ('source', 'run-green', 'checks', 'native', 'provider', 'push', 'provenance', 'extra-step', 'artifact'): + lock = C.load_lock(); run, jobs, artifact = api_fixture(lock) + if kind == 'source': run['head_sha'] = '0' * 40 + if kind == 'run-green': run['conclusion'] = 'success' + if kind == 'checks': jobs['jobs'][0]['conclusion'] = 'failure' + number = {'native': 6, 'provider': 11, 'push': 12, 'provenance': 13}.get(kind) + if number: + next(s for s in jobs['jobs'][1]['steps'] if s['number'] == number)['conclusion'] = 'failure' + if kind == 'extra-step': jobs['jobs'][1]['steps'].append({'number': 99, 'name': 'unknown', 'status': 'completed', 'conclusion': 'success'}) + if kind == 'artifact': artifact['digest'] = 'sha256:' + '0' * 64 + with self.subTest(kind=kind), self.assertRaises(ValueError): C.verify_api(lock, run, jobs, artifact) + + def test_exact_thirteen_components_bind_every_archive_and_installer(self): + lock, files = metadata_fixture(); original = copy.deepcopy(files) + self.assertEqual(len(C.verify_metadata(lock, files)['components']), 13) + self.assertEqual(files, original) + + def test_truncated_unknown_metadata_and_changed_package_bytes_are_rejected(self): + for kind in ('truncated', 'unknown', 'component', 'archive', 'installer', 'manifest-metadata'): + lock, files = metadata_fixture() + if kind == 'truncated': files['sbom.cdx.json'] = files['sbom.cdx.json'][:20] + if kind in ('unknown', 'component'): + doc = json.loads(files['sbom.cdx.json']) + if kind == 'unknown': doc['metadata']['unreviewed'] = True + else: doc['components'].pop() + files['sbom.cdx.json'] = encoded(doc) + lock['identity']['sbomSha256'] = C.Q.sha(files['sbom.cdx.json']) + if kind == 'archive': files[next(n for n in files if n.endswith('.deb'))] += b'tamper' + if kind == 'installer': files['install-existing-backports'] += b'tamper' + if kind == 'manifest-metadata': + doc = json.loads(files['manifest.json']); doc['unknown'] = True; files['manifest.json'] = encoded(doc) + lock['outMetadataSha256']['manifest.json'] = C.Q.sha(files['manifest.json']) + with self.subTest(kind=kind), self.assertRaises(ValueError): C.verify_metadata(lock, files) + + def test_native_verified_predicate_must_be_original_ast_and_subject(self): + document = C.load_lock()['sbomDocument'] + statement = {'predicateType': C.PREDICATE, 'predicate': document, + 'subject': [{'name': C.IDENTITY['image'], 'digest': {'sha256': C.IDENTITY['digest'][7:]}}]} + C.verify_attestation([{'verificationResult': {'statement': statement}}], C.PREDICATE, document) + for kind in ('digest', 'predicate', 'type'): + wrong = copy.deepcopy(statement) + if kind == 'digest': wrong['subject'][0]['digest']['sha256'] = '0' * 64 + if kind == 'predicate': wrong['predicate']['metadata']['unreviewed'] = True + if kind == 'type': wrong['predicateType'] = 'https://spdx.dev/Document' + with self.subTest(kind=kind), self.assertRaises(ValueError): + C.verify_attestation([{'verificationResult': {'statement': wrong}}], C.PREDICATE, document) + provenance = copy.deepcopy(statement) + provenance.update(predicateType='https://slsa.dev/provenance/v1', + predicate={'runDetails': {'metadata': {'invocationId': C.ORIGINAL_INVOCATION}}}) + C.verify_attestation([{'verificationResult': {'statement': provenance}}], 'https://slsa.dev/provenance/v1') + for invocation in ('https://github.com/PastureStack/server/actions/runs/40138193718/attempts/1', + 'https://github.com/PastureStack/server/actions/runs/40138193717/attempts/2'): + provenance['predicate']['runDetails']['metadata']['invocationId'] = invocation + with self.subTest(invocation=invocation), self.assertRaises(ValueError): + C.verify_attestation([{'verificationResult': {'statement': provenance}}], 'https://slsa.dev/provenance/v1') + + def test_workflow_cannot_rebuild_or_repush_and_explicit_predicate_is_used(self): + root = HERE.parents[3] + source = (root / '.github/workflows/complete-ubuntu-security-backports-v0.1.0.yml').read_text() + for forbidden in ('docker build', 'docker push', 'docker tag', 'buildx', 'inputs:', 'sbom-path:'): + self.assertNotIn(forbidden, source) + self.assertIn("github.ref == 'refs/heads/main'", source) + self.assertIn('needs: checks', source) + self.assertIn('--source-digest "$PRODUCER" --signer-digest "$PRODUCER"', source) + self.assertIn('--source-digest "$GITHUB_SHA" --signer-digest "$GITHUB_SHA"', source) + normal = (root / '.github/workflows/publish-ubuntu-security-backports.yml').read_text() + for text in (source, normal): + self.assertIn('predicate-type: https://cyclonedx.org/bom', text) + self.assertIn('predicate-path:', text) + self.assertNotIn('sbom-path:', text) + + +if __name__ == '__main__': + unittest.main(verbosity=2)