Skip to content
Merged

Dev #17

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
4 changes: 2 additions & 2 deletions .github/scripts/policy_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import sys
from pathlib import Path

ENFORCED_VERSION_RE = re.compile(r"^v[1-9][0-9]*$")
ENFORCED_VERSION_RE = re.compile(r"^v[1-9][0-9]*(\.[0-9]+)?$")
RULE_ID_RE = re.compile(r"^[A-Z]{3,4}-[0-9]+$")
CHECK_RE = re.compile(r"^check_[a-z0-9_]+$")
SCOPE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
Expand Down Expand Up @@ -92,7 +92,7 @@ def validate_v2(obj, verifier: Path | None) -> list[str]:
errs.append("schema 2 policies must have enforcement_mode 'enforced'")
ver = obj["policy_version"]
if not (isinstance(ver, str) and ENFORCED_VERSION_RE.match(ver)):
errs.append("policy_version must look like v1, v2, ...")
errs.append("policy_version must look like v1, v2, v2.1, ...")
if not _nonempty(obj["summary"]):
errs.append("summary must be a non-empty string")
if not (isinstance(obj["not_guaranteed"], list) and obj["not_guaranteed"]
Expand Down
101 changes: 99 additions & 2 deletions .github/scripts/verify_builder_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
--tag / --tag-sha the release tag and the commit it must point at
"""
import argparse
import gzip
import hashlib
import json
import re
import subprocess
Expand All @@ -35,7 +37,8 @@

class Ctx:
def __init__(self, *, root, tag, tag_sha, inputs, digests, sboms, vex,
spdx=None, assets=None, run=None, git_show=None):
spdx=None, assets=None, run=None, git_show=None,
asserts=None, artifacts_dir=None):
self.root = Path(root)
self.tag = tag
self.tag_sha = tag_sha
Expand All @@ -45,6 +48,8 @@ def __init__(self, *, root, tag, tag_sha, inputs, digests, sboms, vex,
self.spdx = spdx or {} # arch -> parsed SPDX SBOM (must agree with CycloneDX)
self.vex = vex # parsed OpenVEX
self.assets = assets # list[str] | None
self.asserts = asserts or {} # arch -> parsed assert-image.<arch>.json
self.artifacts_dir = Path(artifacts_dir) if artifacts_dir else None
self.run = run or _run
self.git_show = git_show or _git_show(self.root, tag_sha, self.run)

Expand Down Expand Up @@ -101,6 +106,13 @@ def cargo_pairs(sbom):
return {(n, v) for t, n, v in purl_set(sbom) if t == "cargo"}


def artifact_path(ctx, arch, name):
"""Path of a file in the sbom-<arch>/ artifact directory, or None if no directory was given."""
if ctx.artifacts_dir is None:
return None
return ctx.artifacts_dir / f"sbom-{arch}" / name


def _per_arch(ctx, getter, label):
out, errs = {}, []
for a in ARCHES:
Expand Down Expand Up @@ -225,6 +237,88 @@ def check_vex_attested(ctx, params):
return errs


def check_assert_image_passed(ctx, params):
"""assert-image.<arch>.json (written by release.yml from assert-image.sh) names this image
digest, the tagged commit and the released CycloneDX SBOM, lists exactly the required checks,
and every check is PASS."""
want = list(params["required_checks"])
if not want:
return ["policy required_checks is empty"]
errs = []
for a in ARCHES:
d = _digest_ok(ctx, a, errs)
doc = ctx.asserts.get(a)
if not isinstance(doc, dict):
errs.append(f"{a}: assert-image.{a}.json missing")
continue
if d and doc.get("image") != f"{params['image']}@{d}":
errs.append(f"{a}: assert-image image {doc.get('image')!r} != {params['image']}@{d}")
if doc.get("commit") != ctx.tag_sha:
errs.append(f"{a}: assert-image commit {doc.get('commit')!r} != tag commit {ctx.tag_sha}")
p = artifact_path(ctx, a, f"sbom.{a}.cdx.json")
if p is None or not p.exists():
errs.append(f"{a}: released CycloneDX SBOM not found, cannot compare sbom_sha256")
elif doc.get("sbom_sha256") != hashlib.sha256(p.read_bytes()).hexdigest():
errs.append(f"{a}: assert-image sbom_sha256 does not match the released CycloneDX SBOM")
checks = doc.get("checks")
if not (isinstance(checks, list) and all(isinstance(c, dict) for c in checks)):
errs.append(f"{a}: assert-image checks is not a list of objects")
continue
names = [str(c.get("name")) for c in checks]
if sorted(names) != sorted(want):
errs.append(f"{a}: assert-image checks differ from the policy list: "
f"missing {sorted(set(want) - set(names))}, unexpected {sorted(set(names) - set(want))}")
for c in checks:
if c.get("result") != "PASS":
errs.append(f"{a}: assert-image check {c.get('name')!r} result {c.get('result')!r}")
return errs


def check_assert_attested(ctx, params):
"""assert-image.<arch>.json is the predicate of a verified attestation on the image digest,
made by the release workflow from the release tag."""
errs = []
sref = params["source_ref_template"].format(tag=ctx.tag)
for a in ARCHES:
d = _digest_ok(ctx, a, errs)
if d:
_predicate_equals(ctx, a, d, params, params["predicate_type"], sref,
ctx.asserts.get(a), f"assert-image.{a}.json", errs)
return errs


def check_build_log_attested(ctx, params):
"""build-log.<arch>.txt.gz is a non-empty gzip that mentions the image digest, and the file
itself has a verified attestation from the build workflow, from main, at the tagged commit."""
errs = []
for a in ARCHES:
d = _digest_ok(ctx, a, errs)
p = artifact_path(ctx, a, f"build-log.{a}.txt.gz")
if p is None or not p.exists():
errs.append(f"{a}: build-log.{a}.txt.gz missing")
continue
try:
with gzip.open(p, "rt", errors="replace") as fh:
text = fh.read()
except (OSError, EOFError) as e:
errs.append(f"{a}: build log is not a readable gzip: {e}")
continue
if not text.strip():
errs.append(f"{a}: build log is empty")
elif d and d not in text:
errs.append(f"{a}: build log does not mention image digest {d}")
rc, _ = ctx.run([
"gh", "attestation", "verify", str(p),
"--repo", params["repo"],
"--signer-workflow", params["signer_workflow"],
"--source-ref", params["source_ref"],
"--source-digest", ctx.tag_sha,
])
if rc != 0:
errs.append(f"{a}: gh attestation verify failed for build-log.{a}.txt.gz")
return errs


def check_gate_script_hash(ctx, params):
import hashlib
want = hashlib.sha256(ctx.git_show(params["script_path"])).hexdigest()
Expand Down Expand Up @@ -462,10 +556,13 @@ def f(arch, name):
cdx = {x: jload(f(x, f"sbom.{x}.cdx.json")) for x in ARCHES}
spdx = {x: jload(f(x, f"sbom.{x}.spdx.json")) for x in ARCHES}
assets = a.assets_file.read_text().split() if a.assets_file else None
asserts = {x: jload(f(x, f"assert-image.{x}.json")) for x in ARCHES}
return Ctx(root=a.root, tag=a.tag, tag_sha=a.tag_sha, inputs=inputs, digests=digests,
sboms={k: v for k, v in cdx.items() if v is not None},
spdx={k: v for k, v in spdx.items() if v is not None},
vex=jload(a.vex), assets=assets)
vex=jload(a.vex), assets=assets,
asserts={k: v for k, v in asserts.items() if v is not None},
artifacts_dir=a.artifacts_dir)


def main():
Expand Down
40 changes: 35 additions & 5 deletions .github/workflows/build-environment-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ jobs:
- name: Build (local, not pushed yet)
env:
TAG: ${{ steps.tag.outputs.tag }}
ARCH: ${{ matrix.arch }}
run: |
docker buildx build --load \
set -euo pipefail
docker buildx build --load --progress=plain \
-f "$CONTEXT/Containerfile" \
-t "$IMAGE:$TAG" \
"$CONTEXT"
"$CONTEXT" 2>&1 | tee "build.$ARCH.log"

- name: Check apk package list against committed lock (exact match)
env:
Expand All @@ -84,6 +86,7 @@ jobs:
echo "::error::Installed apk packages differ from $LOCK. If the change is intended, commit the new list."
exit 1
fi
echo "apk package list matches $LOCK" | tee -a "build.$TAG.log"

- name: Upload apk list (for first-time lock or review)
if: always()
Expand All @@ -98,10 +101,12 @@ jobs:
env:
REF: ${{ steps.tag.outputs.tag }}
run: |
docker push "$IMAGE:$REF"
set -euo pipefail
docker push "$IMAGE:$REF" 2>&1 | tee -a "build.${{ matrix.arch }}.log"
DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:$REF" | cut -d@ -f2)"
case "$DIGEST" in sha256:????????????????????????????????????????????????????????????????) ;; *) echo "::error::bad digest: $DIGEST"; exit 1;; esac
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "pushed $IMAGE@$DIGEST" | tee -a "build.${{ matrix.arch }}.log"
echo "$DIGEST" > "image.digest.${{ matrix.arch }}"
echo "### ${{ matrix.arch }} builder digest" >> "$GITHUB_STEP_SUMMARY"
echo "\`$IMAGE@$DIGEST\`" >> "$GITHUB_STEP_SUMMARY"
Expand Down Expand Up @@ -200,10 +205,35 @@ jobs:
| jq -e --slurpfile want "builder-inputs.$ARCH.json" \
'any(.[]; .verificationResult.statement.predicate == $want[0])' > /dev/null

- name: Upload digest and inputs
- name: Package build log
env:
ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
[ -s "build.$ARCH.log" ] || { echo "::error::build log is empty"; exit 1; }
gzip -9 -n -c "build.$ARCH.log" > "build-log.$ARCH.txt.gz"
ls -l "build-log.$ARCH.txt.gz"

- name: Attest build log
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: build-log.${{ matrix.arch }}.txt.gz

- name: Verify the build-log attestation we just made
env:
GH_TOKEN: ${{ github.token }}
ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
gh attestation verify "build-log.$ARCH.txt.gz" \
--repo "${{ github.repository }}" \
--signer-workflow "${{ github.repository }}/.github/workflows/build-environment-image.yml"

- name: Upload digest, inputs and build log
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: image-digest-${{ matrix.arch }}
path: |
image.digest.${{ matrix.arch }}
builder-inputs.${{ matrix.arch }}.json
builder-inputs.${{ matrix.arch }}.json
build-log.${{ matrix.arch }}.txt.gz
121 changes: 121 additions & 0 deletions .github/workflows/pages-deployment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: Universal Site Adapter

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read # Secure: No push access required!
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: false

jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.x"

- name: Build site payload (_site/)
env:
REPO_NAME: ${{ github.repository }}
run: |
# Pinned by wheel hash for supply-chain integrity.
echo "markdown==3.7 --hash=sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803" > /tmp/reqs.txt
pip install --require-hashes --only-binary=:all: --no-deps -r /tmp/reqs.txt

python3 - << 'EOF'
import os, shutil, subprocess, markdown

os.makedirs("_site", exist_ok=True)
repo_name = os.environ.get("REPO_NAME", "Site").split("/")[-1]
tracked = subprocess.check_output(["git", "ls-files"], text=True).splitlines()

# Strict web-asset allowlist (Now updated for Python Indexes and APIs)
WEB_FILES = {"index.html", "favicon.ico", "robots.txt", "sitemap.xml", "CNAME", "404.html"}
WEB_DIRS = {"assets", "css", "js", "images", "img", "fonts", "static", "public", "docs", "media", "simple"}
WEB_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".avif", ".css", ".pdf", ".json", ".whl", ".tar.gz", ".tar.bz2"}

def is_safe_asset(filepath):
parts = filepath.split('/')
ext = os.path.splitext(filepath)[1].lower()
return (len(parts) == 1 and parts[0] in WEB_FILES) or (parts[0] in WEB_DIRS) or (ext in WEB_EXTS)

# FIX #1: Sentinel Check Restored to prevent freezing legacy auto-generated repos
has_custom_site = False
if os.path.exists("index.html"):
with open("index.html", "r", encoding="utf-8", errors="ignore") as f:
if "<!-- auto-generated-from-readme -->" not in f.read():
has_custom_site = True

if has_custom_site:
print("::notice::[CUSTOM SITE] Explicit index.html detected. Packaging strict web assets.")
for f in tracked:
# Never publish CI/CD configs or raw repository metadata
if f.startswith(".github/") or f in {".gitignore", "SECURITY.md", "README.md", "LICENSE"}:
continue
# Publish if it explicitly looks like a web asset or ends in .html
if is_safe_asset(f) or f.endswith(".html"):
dst = os.path.join("_site", f)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(f, dst)

else:
print("::notice::[README FALLBACK] No custom index.html found. Rendering README.md.")
content = open("README.md", "r", encoding="utf-8").read() if os.path.exists("README.md") else f"# {repo_name}"
body = markdown.markdown(content, extensions=["tables", "fenced_code", "toc"])

html = f"""<!DOCTYPE html>
<!-- auto-generated-from-readme -->
<html><head><meta charset="utf-8"><title>{repo_name}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown.min.css">
<style>body {{ box-sizing: border-box; min-width: 200px; max-width: 980px; margin: 0 auto; padding: 45px; }}
@media (max-width: 767px) {{ body {{ padding: 15px; }} }}</style>
</head><body class="markdown-body">{body}</body></html>"""

with open("_site/index.html", "w", encoding="utf-8") as out:
out.write(html)

# FIX #2: Unified safe copy logic ensures root-level images (like ./logo.png) are copied
for f in tracked:
if f.startswith(".github/") or f in {".gitignore", "SECURITY.md", "README.md", "LICENSE", "index.html"}:
continue
if is_safe_asset(f):
dst = os.path.join("_site", f)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(f, dst)

# Prevent GitHub Pages from stripping folders starting with underscores
open("_site/.nojekyll", "w").close()
EOF

# FIX #3: Defensive validation restored
- name: Validate build payload
run: |
if [ ! -f "_site/index.html" ]; then
echo "::error::Build failed to generate _site/index.html"
exit 1
fi

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: _site

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
Loading
Loading