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
62 changes: 62 additions & 0 deletions .github/scripts/check-vex-coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# usage: check-vex-coverage.py SCAN.json SBOM.json VEX.json OUT.json
import json, sys, datetime as dt

MAX_DB_AGE_DAYS, MIN_COMPONENTS = 3, 100

def find_key(o, key):
if isinstance(o, dict):
if key in o: return o[key]
o = list(o.values())
if isinstance(o, list):
for v in o:
r = find_key(v, key)
if r is not None: return r

def lists_pkg(stmt, name):
subs = [c.get("@id", "") for p in stmt.get("products", []) for c in p.get("subcomponents", [])]
return not subs or any(f"/{name}@" in s for s in subs)

def ok_status(s, severity):
st = s.get("status")
if st == "fixed": return True
if st == "not_affected": return bool(s.get("justification") or s.get("impact_statement"))
# accepted risk: only below the HIGH gate, and only with a written action statement
if st == "affected": return severity in ("Medium", "Low", "Negligible") and bool(s.get("action_statement"))
return False

def main(scan_p, sbom_p, vex_p, out_p):
scan, sbom, vex = (json.load(open(p)) for p in (scan_p, sbom_p, vex_p))
problems, covered, uncovered, seen = [], [], [], set()
n = len(sbom.get("components", []))
if n < MIN_COMPONENTS:
problems.append(f"control: SBOM has only {n} components")
desc = scan.get("descriptor", {})
built = find_key(desc.get("db", {}), "built")
try:
age = (dt.datetime.now(dt.timezone.utc) - dt.datetime.fromisoformat(str(built).replace("Z", "+00:00"))).days
if age > MAX_DB_AGE_DAYS: problems.append(f"vulnerability DB is {age} days old")
except Exception:
problems.append("cannot read the vulnerability DB build date from the scan")
by_id = {}
for s in vex.get("statements", []):
v = s.get("vulnerability", {})
for i in {v.get("name"), *v.get("aliases", [])} - {None}:
by_id.setdefault(i, []).append(s)
for m in scan.get("matches", []):
ids = {m["vulnerability"]["id"], *(r["id"] for r in m.get("relatedVulnerabilities", []))}
seen |= ids
pkg = m["artifact"]
hit = [s for i in ids for s in by_id.get(i, []) if ok_status(s, m["vulnerability"].get("severity")) and lists_pkg(s, pkg["name"])]
row = {"ids": sorted(ids), "package": pkg["name"], "version": pkg.get("version")}
(covered if hit else uncovered).append({**row, "status": hit[0]["status"]} if hit else row)
unused = sorted(i for i in by_id if i not in seen)
verdict = "pass" if not problems and not uncovered else "fail"
json.dump({"verdict": verdict, "scanner": desc.get("name"), "scanner_version": desc.get("version"),
"db_built": built, "sbom_components": n, "findings": len(covered) + len(uncovered),
"covered": covered, "uncovered": uncovered, "problems": problems,
"vex_statements_without_finding": unused}, open(out_p, "w"), indent=2, sort_keys=True)
print(f"{verdict}: {len(covered)} covered, {len(uncovered)} uncovered, problems={problems}")
return 0 if verdict == "pass" else 1

sys.exit(main(*sys.argv[1:5]))
34 changes: 34 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ permissions: {}
env:
IMAGE: ghcr.io/patrickryankenneth/python-wheels-builder-alpine
SYFT_VERSION: "1.54.0"
GRYPE_VERSION: "0.120.0"
VEX_PATH: environments/alpine-3.24-musl/vex/builder.openvex.json
VEX_PREDICATE_TYPE: https://openvex.dev/ns/v0.2.0
ASSERT_PREDICATE_TYPE: https://github.com/patrickryankenneth/python-wheels-builder/predicate/assert-image/v1
Expand Down Expand Up @@ -76,10 +77,12 @@ jobs:
runner: ubuntu-24.04
syft_arch: amd64
syft_sha256: 54a87372498168b2d033e876fd41fa4e8035b872699e525a57046e1f2f09c860
grype_sha256: a5a1218dce63acdac152a6b3b5bb366e7267e36f4069848cf455543b3fa5700e
- arch: aarch64
runner: ubuntu-24.04-arm
syft_arch: arm64
syft_sha256: ee6d4566373a05b344bc6b5f1706f14419bf9338ba39ff686e247deefe9b8818
grype_sha256: bc0e52b1a0de37e2ff021c4924d689dce7dcff2e7d74b39aea16c0453e69be18
runs-on: ${{ matrix.runner }}
permissions:
contents: read
Expand Down Expand Up @@ -172,6 +175,37 @@ jobs:
PY
cat "out/assert-image.$ARCH.json"

- name: Install pinned Grype
env:
GRYPE_ARCH: ${{ matrix.syft_arch }}
GRYPE_SHA256: ${{ matrix.grype_sha256 }}
run: |
set -euo pipefail
f="grype_${GRYPE_VERSION}_linux_${GRYPE_ARCH}.tar.gz"
curl -fsSL --proto '=https' -o "$f" \
"https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/$f"
echo "$GRYPE_SHA256 $f" | sha256sum --check --strict -
mkdir -p "$RUNNER_TEMP/grype"
tar -xzf "$f" -C "$RUNNER_TEMP/grype" grype

- name: Scan the image and check VEX coverage
env:
GRYPE_CHECK_FOR_APP_UPDATE: "false"
run: |
set -euo pipefail
"$RUNNER_TEMP/grype/grype" db update
"$RUNNER_TEMP/grype/grype" "registry:$IMAGE@$D" -o json > "out/vuln-scan.$ARCH.json"
python3 .github/scripts/check-vex-coverage.py \
"out/vuln-scan.$ARCH.json" "out/sbom.$ARCH.cdx.json" \
"$VEX_PATH" "out/vex-coverage.$ARCH.json" | tee -a "$GITHUB_STEP_SUMMARY"

- name: Upload VEX coverage report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vex-coverage-${{ matrix.arch }}
path: out/vex-coverage.${{ matrix.arch }}.json

- name: Attest CycloneDX SBOM
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
Expand Down
20 changes: 15 additions & 5 deletions environments/alpine-3.24-musl/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,21 @@
# Get the digest with: skopeo inspect --format '{{.Digest}}' docker://docker.io/library/alpine:3.24
FROM docker.io/library/alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6

# build-base: gcc/make/etc - the C toolchain Rust's linker needs.
# Toolchain spelled out instead of build-base, which also pulls in `patch` (never used).
# perl is deliberately absent: the dbt-oss build does not need it, and git-perl would come with it.
# readelf/objdump are deleted: the build never calls them.
# python3-dev: maturin's build backend links against libpython.
# openssl-dev/libffi-dev: common transitive needs for Rust crates with
# native deps.
# NOTE: Alpine has no package literally named "protoc" - the binary ships
# in "protobuf". Do not reintroduce "protoc" here.
RUN apk add --no-cache \
build-base \
binutils \
file \
fortify-headers \
g++ \
gcc \
libc-dev \
curl \
git \
python3 \
Expand All @@ -37,11 +44,12 @@ RUN apk add --no-cache \
openssl-libs-static \
libffi-dev \
pkgconfig \
perl \
make \
patchelf \
protobuf \
protobuf-dev
protobuf-dev \
&& find / -xdev ! -type d \( -name readelf -o -name objdump \) -delete \
&& ! find / -xdev ! -type d \( -name '*readelf*' -o -name '*objdump*' \) | grep .

# Fail the image build in seconds if these didn't land.
RUN patchelf --version && protoc --version
Expand Down Expand Up @@ -104,7 +112,9 @@ COPY requirements-builder.txt /tmp/requirements-builder.txt
RUN pip install --break-system-packages --no-cache-dir --require-hashes \
-r /tmp/requirements-builder.txt \
&& rm /tmp/requirements-builder.txt \
&& maturin --version
&& maturin --version \
&& apk del py3-pip \
&& ! python3 -m pip --version >/dev/null 2>&1

LABEL org.opencontainers.image.source="https://github.com/patrickryankenneth/python-wheels-builder"
WORKDIR /build
Expand Down
4 changes: 2 additions & 2 deletions environments/alpine-3.24-musl/apk.lock.aarch64
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,6 @@ scanelf-1.3.9-r1
sqlite-libs-3.53.4-r0
ssl_client-1.37.0-r31
xz-libs-5.8.4-r0
zlib-1.3.2-r0
zlib-dev-1.3.2-r0
zlib-1.3.2-r1
zlib-dev-1.3.2-r1
zstd-libs-1.5.7-r2
4 changes: 2 additions & 2 deletions environments/alpine-3.24-musl/apk.lock.x86_64
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,6 @@ scanelf-1.3.9-r1
sqlite-libs-3.53.4-r0
ssl_client-1.37.0-r31
xz-libs-5.8.4-r0
zlib-1.3.2-r0
zlib-dev-1.3.2-r0
zlib-1.3.2-r1
zlib-dev-1.3.2-r1
zstd-libs-1.5.7-r2
Loading
Loading