github-actions[bot] commented on code in PR #41175: URL: https://github.com/apache/beam/pull/41175#discussion_r4529346991
########## .github/workflows/beam_VEX_Triage_and_Publish.yml: ########## @@ -0,0 +1,164 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Beam Container VEX Triage and Publish + +on: + workflow_dispatch: + inputs: + release: + description: "Beam release tag to scan and attach VEX to (e.g., 2.77.0)" + required: false + default: "2.77.0" + attach_vex: + description: "Attach OpenVEX (cosign) and CSAF 2.0 VEX (Artifact Registry)" + type: boolean + default: true + open_pr: + description: "Open a draft PR if new non-exploitable CVEs are discovered" + type: boolean + default: true + schedule: + - cron: "0 12 * * 1" + +permissions: + contents: write + pull-requests: write + id-token: write + +env: + RELEASE: "${{ github.event.inputs.release || '2.77.0' }}" + +jobs: + triage-and-publish-vex: + runs-on: [self-hosted, ubuntu-24.04, main] + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: true + + - name: Set up Python 3.11 + uses: actions/setup-python@v7 + with: Review Comment: **🔴 Critical**: Piping an installation script directly from a mutable `main` branch (`https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh`) into `sudo sh`, and downloading the `cosign` binary without verifying its SHA-256 checksum, creates a severe supply-chain vulnerability—especially on `[self-hosted, ubuntu-24.04, main]` runners holding `GCP_SA_KEY`, `GEMINI_API_KEY`, `contents: write`, and `id-token: write` permissions. Please pin Trivy to a specific release version and verify the SHA-256 checksums of both downloaded binaries before making them executable. ########## .github/workflows/republish_released_docker_containers.yml: ########## @@ -104,3 +106,63 @@ jobs: --no-daemon \ --no-parallel + attach-vex: + needs: build + runs-on: [self-hosted, ubuntu-24.04, main] + permissions: + contents: read + id-token: write + steps: + - name: Checkout master for VEX definitions + uses: actions/checkout@v7 + with: + ref: master + repository: apache/beam + persist-credentials: false + - name: Install Python 3.11 + uses: actions/setup-python@v7 + with: + python-version: '3.11' + - name: Install Cosign + run: | + if ! command -v cosign >/dev/null 2>&1; then + sudo curl -sSfL https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64 -o /usr/local/bin/cosign Review Comment: **🟠 High**: The `cosign` binary is downloaded over `curl` and executed with `sudo` without verifying its SHA-256 digest, and `gcloud auth configure-docker` is never run in the `attach-vex` job before `vex_manager.py attach` invokes `cosign attest` against `gcr.io/apache-beam-testing/...`. Verify the `cosign` checksum on download and configure Docker registry authentication (`gcloud auth configure-docker gcr.io`) so `cosign attest` can push attestations. ########## .github/workflows/beam_VEX_Triage_and_Publish.yml: ########## @@ -0,0 +1,164 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Beam Container VEX Triage and Publish + +on: + workflow_dispatch: + inputs: + release: + description: "Beam release tag to scan and attach VEX to (e.g., 2.77.0)" + required: false + default: "2.77.0" + attach_vex: + description: "Attach OpenVEX (cosign) and CSAF 2.0 VEX (Artifact Registry)" + type: boolean + default: true + open_pr: + description: "Open a draft PR if new non-exploitable CVEs are discovered" + type: boolean + default: true + schedule: + - cron: "0 12 * * 1" + +permissions: + contents: write + pull-requests: write + id-token: write + +env: + RELEASE: "${{ github.event.inputs.release || '2.77.0' }}" + +jobs: + triage-and-publish-vex: + runs-on: [self-hosted, ubuntu-24.04, main] + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: true + + - name: Set up Python 3.11 + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.24.x" + + - name: Install scanner and signing dependencies + run: | + python3 -m pip install pyyaml + go install golang.org/x/vuln/cmd/govulncheck@latest Review Comment: **🟠 High**: `gcloud auth configure-docker gcr.io` is missing after setting up Cloud SDK. Both `trivy image` (in the scan step) and `cosign attest` (in the attach step) interact directly with the container registry (`gcr.io/apache-beam-testing/...`) and require Docker/OCI registry credentials to pull images or push OCI attestations. ########## sdks/containers/vex/vex_manager.py: ########## @@ -0,0 +1,1008 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Generates, scans/triages, and attaches VEX statements for Beam containers. + +Supported capabilities: + 1. `generate`: Produces OpenVEX v0.2.0 JSON, CSAF 2.0 VEX JSON (compatible + with `gcloud artifacts vulnerabilities load-vex`), and AutoVM / Lichene + triage rule snippets from `beam_containers_vex.yaml`. + 2. `scan`: Queries vulnerabilities from Google Artifact Registry + (`gcloud artifacts vulnerabilities list`), Trivy (`trivy image`), and + `govulncheck`, filters out CVEs already covered by VEX, performs automated + and optional LLM-assisted reachability triage, and optionally appends + newly verified `not_affected` entries to the VEX config. + 3. `attach`: Attaches OpenVEX attestations to container digests via + `cosign attest --type https://openvex.dev/ns` and loads CSAF 2.0 VEX + assessments into Artifact Registry via + `gcloud artifacts vulnerabilities load-vex`. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime +from datetime import timezone +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import urllib.request +from typing import Any +import yaml + +CSAF_JUSTIFICATION_MAP = { + "component_not_present": "component_not_present", + "vulnerable_code_not_present": "vulnerable_code_not_present", + "vulnerable_code_not_in_execute_path": "vulnerable_code_not_in_execute_path", + "vulnerable_code_cannot_be_controlled_by_adversary": ( + "vulnerable_code_cannot_be_controlled_by_adversary" + ), + "inline_mitigations_already_exist": "inline_mitigations_already_exist", +} + +LICHENE_STATUS_MAP = { + "component_not_present": "COMPONENT_NOT_PRESENT", + "vulnerable_code_not_present": "CODE_NOT_PRESENT", + "vulnerable_code_not_in_execute_path": "CODE_UNREACHABLE", + "vulnerable_code_cannot_be_controlled_by_adversary": "CODE_NOT_CONTROLLABLE", + "inline_mitigations_already_exist": "MITIGATED", +} + +BUILD_ONLY_OS_PACKAGES = { + "binutils": ( + "GNU binutils CLI utilities (objdump, readelf, nm, ld, strip) and " + "libbfd are build-time tools inherited from the base OS image and are " + "never invoked on untrusted ELF inputs at runtime." + ), + "elfutils": ( + "elfutils DWARF/ELF inspection utilities are build/debug tools not " + "executed at runtime by the container entrypoint." + ), + "gcc-12": ( + "gcc-12 compiler binaries and C++ demangler CLI utilities are " + "build-time tools not invoked at runtime." + ), + "linux-libc-dev": ( + "linux-libc-dev contains C header files for compiling userspace " + "programs and ships no runtime executable code or shared libraries." + ), + "m4": ( + "GNU m4 is a build-time macro processor pulled in by autotools and is " + "never invoked at runtime." + ), + "toolchain": ( + "Go cmd/go build-time toolchain vulnerability reported from embedded " + "Go binary build metadata; the runtime container image does not ship " + "or execute cmd/go." + ), + "bluez": ( + "Bluetooth protocol stack libraries present in the upstream Debian " + "base image are unused by container worker processes." + ), + "cairo": ( + "2D desktop graphics library present in the upstream Debian base " + "image is not loaded by headless worker processes." + ), + "gdk-pixbuf": ( + "Desktop GUI pixel-buffer library in the upstream Debian base image " + "is unused by headless worker processes." + ), + "imagemagick": ( + "ImageMagick CLI/library in the upstream Debian base image is not " + "invoked by the container entrypoint." + ), + "libheif": ( + "HEIF image decoder library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "libwmf": ( + "Windows Metafile library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openexr": ( + "OpenEXR HDR image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openjpeg2": ( + "JPEG 2000 image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), +} + +GCR_HOST_RE = re.compile( + r"^(?P<host>(?:(?P<prefix>us|eu|asia)\.)?gcr\.io)/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/(?P<image>.+)$" +) +AR_REDIRECTED_GCR_RE = re.compile( + r"^(?P<region>us|europe|asia)-docker\.pkg\.dev/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/" + r"(?P<repo>(?:(?:us|eu|asia)\.)?gcr\.io)/(?P<image>.+)$" +) + + +def expand_uri_variants(uri: str, extra_digests: list[str] | None = None) -> list[str]: + """Expands a container repository URI into all GCR / AR forms needed by gcloud load-vex.""" + cleaned = re.sub(r"^https?://", "", uri.strip()) + base_uri = cleaned.split("@")[0].split(":")[0] + explicit_suffix = "" + if "@" in cleaned: + explicit_suffix = "@" + cleaned.split("@", 1)[1] + elif ":" in cleaned.split("/")[-1]: + explicit_suffix = ":" + cleaned.split("/")[-1].split(":", 1)[1] + + base_variants: list[str] = [base_uri] + + m_gcr = GCR_HOST_RE.match(base_uri) + if m_gcr: + host = m_gcr.group("host") + prefix = m_gcr.group("prefix") or "" + project = m_gcr.group("project") + image = m_gcr.group("image") + ar_region = {"": "us", "us": "us", "eu": "europe", "asia": "asia"}[prefix] + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{host}/{pv}/{image}") + base_variants.append(f"{ar_region}-docker.pkg.dev/{pv}/{host}/{image}") + if host == "gcr.io": + base_variants.append(f"us.gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/us.gcr.io/{image}") + elif host == "us.gcr.io": + base_variants.append(f"gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/gcr.io/{image}") + + m_ar = AR_REDIRECTED_GCR_RE.match(base_uri) + if m_ar: + region = m_ar.group("region") + project = m_ar.group("project") + repo = m_ar.group("repo") + image = m_ar.group("image") + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{region}-docker.pkg.dev/{pv}/{repo}/{image}") + base_variants.append(f"{repo}/{pv}/{image}") + + deduped_bases: list[str] = [] + for candidate in base_variants: + if candidate not in deduped_bases: + deduped_bases.append(candidate) + + suffixes = [""] + if explicit_suffix and explicit_suffix not in suffixes: + suffixes.append(explicit_suffix) + for digest in extra_digests or []: + norm_digest = digest if digest.startswith("@") else f"@{digest}" + if norm_digest not in suffixes: + suffixes.append(norm_digest) + + expanded: list[str] = [] + for b in deduped_bases: + for s in suffixes: + cand = f"{b}{s}" + if cand not in expanded: + expanded.append(cand) + return expanded + + +def load_vex_config(config_path: Path) -> dict[str, Any]: + with config_path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def normalize_statements( + family_cfg: dict[str, Any], shared_rule_sets: dict[str, Any] +) -> list[dict[str, Any]]: + """Flattens inherited_rule_sets and per-family statements into individual CVE entries.""" + raw_entries: list[dict[str, Any]] = [] + for rule_set_name in family_cfg.get("inherited_rule_sets", []): + rule_set = shared_rule_sets.get(rule_set_name, {}) + raw_entries.extend(rule_set.get("statements", [])) + raw_entries.extend(family_cfg.get("statements", [])) + + by_cve: dict[str, dict[str, Any]] = {} + for entry in raw_entries: + cve_ids = entry.get("ids") or ([entry["id"]] if "id" in entry else []) + for cve_id in cve_ids: + by_cve[cve_id] = { + "id": cve_id, + "status": entry.get("status", "not_affected"), + "justification": entry.get( + "justification", "vulnerable_code_not_in_execute_path" + ), + "packages": list(entry.get("packages", [])), + "impact_statement": entry.get("impact_statement", "").strip(), + "action_statement": entry.get("action_statement", "").strip(), + } + return [by_cve[k] for k in sorted(by_cve.keys())] + + +def build_openvex_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + product_ids: list[str] = [] + for repo in repos: + for expanded in expand_uri_variants(repo, extra_digests=extra_digests): + if expanded not in product_ids: + product_ids.append(expanded) + + openvex_statements: list[dict[str, Any]] = [] + for stmt in statements: + item: dict[str, Any] = { + "vulnerability": {"name": stmt["id"]}, + "timestamp": timestamp, + "products": [{"@id": pid} for pid in product_ids], + "status": stmt["status"], + } + if stmt["status"] == "not_affected": + item["justification"] = stmt["justification"] + if stmt.get("impact_statement"): + item["impact_statement"] = stmt["impact_statement"] + elif stmt["status"] == "affected" and stmt.get("action_statement"): + item["action_statement"] = stmt["action_statement"] + openvex_statements.append(item) + + namespace = publisher.get("namespace", "https://beam.apache.org").rstrip("/") + return { + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": f"{namespace}/vex/{family_name}", + "author": publisher.get("name", "Apache Beam Project"), + "role": publisher.get("role", "vendor"), + "timestamp": timestamp, + "version": 1, + "statements": openvex_statements, + } + + +def build_csaf_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + """Builds a CSAF 2.0 VEX document compatible with gcloud artifacts vulnerabilities load-vex.""" + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + expanded_uris: list[str] = [] + for repo in repos: + for variant in expand_uri_variants(repo, extra_digests=extra_digests): + if variant not in expanded_uris: + expanded_uris.append(variant) + + branches: list[dict[str, Any]] = [] + product_ids: list[str] = [] + for idx, uri in enumerate(expanded_uris, start=1): + pid = f"product_{idx}" + product_ids.append(pid) + branches.append({ + "category": "product_version", + "name": uri, + "product": { + "name": uri, + "product_id": pid, + }, + }) + + vulnerabilities: list[dict[str, Any]] = [] + for stmt in statements: + cve_id = stmt["id"] + status = stmt["status"] + csaf_status_key = { + "not_affected": "known_not_affected", + "affected": "known_affected", + "fixed": "fixed", + "under_investigation": "under_investigation", + }.get(status, "known_not_affected") + + vuln_entry: dict[str, Any] = { + "cve": cve_id, + "title": f"VEX assessment for {cve_id} in {family_name}", + "notes": [ + { + "category": "description", + "title": f"{cve_id} exploitability assessment", + "text": ( + stmt.get("impact_statement") + or f"{cve_id} is {status} in {family_name}." + ), + } + ], + "product_status": { + csaf_status_key: product_ids, + }, + } + if status == "not_affected": + just = CSAF_JUSTIFICATION_MAP.get( + stmt.get("justification", ""), "vulnerable_code_not_in_execute_path" + ) + vuln_entry["threats"] = [ + { + "category": "impact", + "details": ( + stmt.get("impact_statement") + or f"{cve_id} is not exploitable in {family_name}." + ), + "product_ids": product_ids, + } + ] + vuln_entry["flags"] = [ + { + "label": just, + "product_ids": product_ids, + } + ] + elif stmt.get("action_statement"): + vuln_entry["remediations"] = [ + { + "category": "mitigation", + "details": stmt["action_statement"], + "product_ids": product_ids, + } + ] + vulnerabilities.append(vuln_entry) + + return { + "document": { + "Category": "csaf_vex", + "csaf_version": "2.0", + "title": f"{publisher.get('name', 'Apache Beam')} VEX for {family_name}", + "publisher": { + "category": publisher.get("role", "vendor"), + "name": publisher.get("name", "Apache Beam Project"), + "namespace": publisher.get("namespace", "https://beam.apache.org"), + "contact_details": publisher.get( + "contact_details", + "https://beam.apache.org/community/contact-us/", + ), + }, + "tracking": { + "id": f"apache-beam-vex-{family_name}", + "status": "final", + "version": "1", + "initial_release_date": timestamp, + "current_release_date": timestamp, + }, + }, + "product_tree": { + "branches": branches, + }, + "vulnerabilities": vulnerabilities, + } + + +def build_lichene_rules_snippet( + config: dict[str, Any], +) -> str: + """Generates a Lichene Piccolo (`rules.pi`) snippet from the VEX config.""" + shared_sets = config.get("shared_rule_sets", {}) + grouped: dict[tuple[str, str], set[str]] = {} + for family_name, family_cfg in sorted( + config.get("image_families", {}).items() + ): + for stmt in normalize_statements(family_cfg, shared_sets): + if stmt["status"] != "not_affected": + continue + lichene_status = LICHENE_STATUS_MAP.get( + stmt["justification"], "CODE_UNREACHABLE" + ) + key = (lichene_status, stmt["impact_statement"]) + grouped.setdefault(key, set()).add(stmt["id"]) + + lines = [ + "# Licensed to the Apache Software Foundation (ASF) under one", + "# or more contributor license agreements. See the NOTICE file", + "# distributed with this work for additional information", + "# regarding copyright ownership. The ASF licenses this file", + "# to you under the Apache License, Version 2.0 (the", + '# "License"); you may not use this file except in compliance', + "# with the License. You may obtain a copy of the License at", + "#", + "# http://www.apache.org/licenses/LICENSE-2.0", + "#", + "# Unless required by applicable law or agreed to in writing,", + "# software distributed under the License is distributed on an", + '# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY', + "# KIND, either express or implied. See the License for the", + "# specific language governing permissions and limitations", + "# under the License.", + "#", + "# Auto-generated Lichene triage rules from beam_containers_vex.yaml", + "# Target: //testing/security/lichene/config/env/prod/tombolo_self_service/cloud_dataflow/rules.pi", + "", + ] + for idx, ((lichene_status, justification), cves) in enumerate( + sorted(grouped.items()), start=1 + ): + lines.append("containers.triage_vulns(") + lines.append(f' rule_id="vex_auto_rule_{idx}_{lichene_status.lower()}",') + lines.append(f" triage_as=lichene.TriageStatus.{lichene_status},") + lines.append(f" justification={json.dumps(justification)},") + lines.append(' structured_justifications=["b/502535467", "b/553250543"],') + lines.append(' approval_process="b/492390417",') + lines.append(" advisory_references=[") + for cve in sorted(cves): + lines.append(f' "{cve}",') + lines.append(" ],") + lines.append(" for_artifacts=[") + lines.append( + ' containers.create_ar_filter("google.com:dataflow-containers"),' + ) + lines.append(' containers.create_gcr_filter("cloud-dataflow"),') + lines.append( + ' containers.create_gcr_filter("dataflow-templates-base"),' + ) + lines.append(' containers.create_gcr_filter("dataflow-templates"),') + lines.append(" ],") + lines.append(" applies_also_to_deployments=True,") + lines.append("),") + return "\n".join(lines) + "\n" + + +def parse_artifact_registry_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `gcloud artifacts vulnerabilities list --format=json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + if isinstance(data, dict): + occurrences = ( + data.get("Occurrences") + or data.get("occurrences") + or [] + ) + elif isinstance(data, list): + occurrences = data + else: + occurrences = [] + + findings: list[dict[str, Any]] = [] + for item in occurrences: + if not isinstance(item, dict): + continue + occ = item.get("occurrence", item) + if not isinstance(occ, dict): + continue + vuln = occ.get("vulnerability", {}) + cve_id = vuln.get("shortDescription", "") + if not cve_id.startswith("CVE-"): + continue + vex_state = ( + vuln.get("vexAssessment", {}).get("state", "") + if isinstance(vuln.get("vexAssessment"), dict) + else "" + ) + raw_pi = vuln.get("packageIssue", []) + pi_list = [raw_pi] if isinstance(raw_pi, dict) else (raw_pi if isinstance(raw_pi, list) else []) + for pi in pi_list: + if not isinstance(pi, dict): + continue + findings.append({ + "id": cve_id, + "package": pi.get("affectedPackage", ""), + "package_type": pi.get("packageType", "OS"), + "severity": pi.get( + "effectiveSeverity", vuln.get("effectiveSeverity", "UNKNOWN") + ), + "fix_available": bool(pi.get("fixAvailable", False)), + "vex_state": vex_state, + "source": "artifact_registry", + }) + return findings + + +def parse_trivy_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `trivy image --format json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + findings: list[dict[str, Any]] = [] Review Comment: **🔴 Critical**: Trivy sets `Result.Class` to `"lang-pkgs"` for **all** language-specific dependency ecosystems (Python `pip`/`poetry`, Java `jar`/`pom`, Node.js `npm`, Go, etc.), whereas `Result.Type` identifies the specific ecosystem (e.g., `"gobinary"`, `"gomod"`, `"pip"`, `"jar"`). Because `parse_trivy_vulns` stores `result.get("Class", "os-pkgs")` as `package_type` and `classify_finding_reachability` (line 599) checks `pkg_type in ("GO", "GOBINARY", "LANG-PKGS")`, if `--govulncheck-binary` is ever supplied (which is plausible since `/opt/apache/beam/boot` is a Go binary in Python and Java containers too) or `reachable_go_cves` is non-`None`, **every Python, Java, or Node.js vulnerability reported by Trivy will be falsely classified as `not_affected`**. Use `result.get("Type", result.get("Class", "os-pkgs"))` here and restrict line 599 to `("GO", "GOBINARY", "GOMOD")`. ```suggestion pkg_type = result.get("Type") or result.get("Class", "os-pkgs") ``` ########## .github/workflows/beam_VEX_Triage_and_Publish.yml: ########## @@ -0,0 +1,164 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Beam Container VEX Triage and Publish + +on: + workflow_dispatch: + inputs: + release: + description: "Beam release tag to scan and attach VEX to (e.g., 2.77.0)" + required: false + default: "2.77.0" + attach_vex: + description: "Attach OpenVEX (cosign) and CSAF 2.0 VEX (Artifact Registry)" + type: boolean + default: true + open_pr: + description: "Open a draft PR if new non-exploitable CVEs are discovered" + type: boolean + default: true + schedule: + - cron: "0 12 * * 1" + +permissions: + contents: write + pull-requests: write + id-token: write + +env: + RELEASE: "${{ github.event.inputs.release || '2.77.0' }}" + +jobs: + triage-and-publish-vex: + runs-on: [self-hosted, ubuntu-24.04, main] + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: true + + - name: Set up Python 3.11 + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.24.x" + + - name: Install scanner and signing dependencies + run: | + python3 -m pip install pyyaml + go install golang.org/x/vuln/cmd/govulncheck@latest + if ! command -v cosign >/dev/null 2>&1; then + sudo curl -sSfL https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64 -o /usr/local/bin/cosign + sudo chmod +x /usr/local/bin/cosign + fi + if ! command -v trivy >/dev/null 2>&1; then + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin + fi + + - name: Authenticate on GCP + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + service_account: ${{ secrets.GCP_SA_EMAIL }} + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db + + - name: Run VEX unit tests + run: | + python3 sdks/containers/vex/vex_manager_test.py + + - name: Scan containers in Artifact Registry, Trivy, and govulncheck + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + mkdir -p /tmp/vex-reports + for entry in \ + "beam_go_sdk:beam_go_sdk" \ Review Comment: **🟡 Medium**: Although the step is named `Scan containers in Artifact Registry, Trivy, and govulncheck` and installs `govulncheck`, `--govulncheck-binary` is never passed to `vex_manager.py scan`, and the container list here omits `beam_python3.13_sdk` and `beam_java11_sdk` which are included in the subsequent attach step (lines 108-109). Additionally, trailing `|| true` on `scan` (line 96) and `attach` (line 125) silently swallows errors. ########## sdks/containers/vex/vex_manager.py: ########## @@ -0,0 +1,1008 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Generates, scans/triages, and attaches VEX statements for Beam containers. + +Supported capabilities: + 1. `generate`: Produces OpenVEX v0.2.0 JSON, CSAF 2.0 VEX JSON (compatible + with `gcloud artifacts vulnerabilities load-vex`), and AutoVM / Lichene + triage rule snippets from `beam_containers_vex.yaml`. + 2. `scan`: Queries vulnerabilities from Google Artifact Registry + (`gcloud artifacts vulnerabilities list`), Trivy (`trivy image`), and + `govulncheck`, filters out CVEs already covered by VEX, performs automated + and optional LLM-assisted reachability triage, and optionally appends + newly verified `not_affected` entries to the VEX config. + 3. `attach`: Attaches OpenVEX attestations to container digests via + `cosign attest --type https://openvex.dev/ns` and loads CSAF 2.0 VEX + assessments into Artifact Registry via + `gcloud artifacts vulnerabilities load-vex`. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime +from datetime import timezone +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import urllib.request +from typing import Any +import yaml + +CSAF_JUSTIFICATION_MAP = { + "component_not_present": "component_not_present", + "vulnerable_code_not_present": "vulnerable_code_not_present", + "vulnerable_code_not_in_execute_path": "vulnerable_code_not_in_execute_path", + "vulnerable_code_cannot_be_controlled_by_adversary": ( + "vulnerable_code_cannot_be_controlled_by_adversary" + ), + "inline_mitigations_already_exist": "inline_mitigations_already_exist", +} + +LICHENE_STATUS_MAP = { + "component_not_present": "COMPONENT_NOT_PRESENT", + "vulnerable_code_not_present": "CODE_NOT_PRESENT", + "vulnerable_code_not_in_execute_path": "CODE_UNREACHABLE", + "vulnerable_code_cannot_be_controlled_by_adversary": "CODE_NOT_CONTROLLABLE", + "inline_mitigations_already_exist": "MITIGATED", +} + +BUILD_ONLY_OS_PACKAGES = { + "binutils": ( + "GNU binutils CLI utilities (objdump, readelf, nm, ld, strip) and " + "libbfd are build-time tools inherited from the base OS image and are " + "never invoked on untrusted ELF inputs at runtime." + ), + "elfutils": ( + "elfutils DWARF/ELF inspection utilities are build/debug tools not " + "executed at runtime by the container entrypoint." + ), + "gcc-12": ( + "gcc-12 compiler binaries and C++ demangler CLI utilities are " + "build-time tools not invoked at runtime." + ), + "linux-libc-dev": ( + "linux-libc-dev contains C header files for compiling userspace " + "programs and ships no runtime executable code or shared libraries." + ), + "m4": ( + "GNU m4 is a build-time macro processor pulled in by autotools and is " + "never invoked at runtime." + ), + "toolchain": ( + "Go cmd/go build-time toolchain vulnerability reported from embedded " + "Go binary build metadata; the runtime container image does not ship " + "or execute cmd/go." + ), + "bluez": ( + "Bluetooth protocol stack libraries present in the upstream Debian " + "base image are unused by container worker processes." + ), + "cairo": ( + "2D desktop graphics library present in the upstream Debian base " + "image is not loaded by headless worker processes." + ), + "gdk-pixbuf": ( + "Desktop GUI pixel-buffer library in the upstream Debian base image " + "is unused by headless worker processes." + ), + "imagemagick": ( + "ImageMagick CLI/library in the upstream Debian base image is not " + "invoked by the container entrypoint." + ), + "libheif": ( + "HEIF image decoder library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "libwmf": ( + "Windows Metafile library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openexr": ( + "OpenEXR HDR image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openjpeg2": ( + "JPEG 2000 image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), +} + +GCR_HOST_RE = re.compile( + r"^(?P<host>(?:(?P<prefix>us|eu|asia)\.)?gcr\.io)/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/(?P<image>.+)$" +) +AR_REDIRECTED_GCR_RE = re.compile( + r"^(?P<region>us|europe|asia)-docker\.pkg\.dev/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/" + r"(?P<repo>(?:(?:us|eu|asia)\.)?gcr\.io)/(?P<image>.+)$" +) + + +def expand_uri_variants(uri: str, extra_digests: list[str] | None = None) -> list[str]: + """Expands a container repository URI into all GCR / AR forms needed by gcloud load-vex.""" + cleaned = re.sub(r"^https?://", "", uri.strip()) + base_uri = cleaned.split("@")[0].split(":")[0] + explicit_suffix = "" + if "@" in cleaned: + explicit_suffix = "@" + cleaned.split("@", 1)[1] + elif ":" in cleaned.split("/")[-1]: + explicit_suffix = ":" + cleaned.split("/")[-1].split(":", 1)[1] + + base_variants: list[str] = [base_uri] + + m_gcr = GCR_HOST_RE.match(base_uri) + if m_gcr: + host = m_gcr.group("host") + prefix = m_gcr.group("prefix") or "" + project = m_gcr.group("project") + image = m_gcr.group("image") + ar_region = {"": "us", "us": "us", "eu": "europe", "asia": "asia"}[prefix] + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{host}/{pv}/{image}") + base_variants.append(f"{ar_region}-docker.pkg.dev/{pv}/{host}/{image}") + if host == "gcr.io": + base_variants.append(f"us.gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/us.gcr.io/{image}") + elif host == "us.gcr.io": + base_variants.append(f"gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/gcr.io/{image}") + + m_ar = AR_REDIRECTED_GCR_RE.match(base_uri) + if m_ar: + region = m_ar.group("region") + project = m_ar.group("project") + repo = m_ar.group("repo") + image = m_ar.group("image") + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{region}-docker.pkg.dev/{pv}/{repo}/{image}") + base_variants.append(f"{repo}/{pv}/{image}") + + deduped_bases: list[str] = [] + for candidate in base_variants: + if candidate not in deduped_bases: + deduped_bases.append(candidate) + + suffixes = [""] + if explicit_suffix and explicit_suffix not in suffixes: + suffixes.append(explicit_suffix) + for digest in extra_digests or []: + norm_digest = digest if digest.startswith("@") else f"@{digest}" + if norm_digest not in suffixes: + suffixes.append(norm_digest) + + expanded: list[str] = [] + for b in deduped_bases: + for s in suffixes: + cand = f"{b}{s}" + if cand not in expanded: + expanded.append(cand) + return expanded + + +def load_vex_config(config_path: Path) -> dict[str, Any]: + with config_path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def normalize_statements( + family_cfg: dict[str, Any], shared_rule_sets: dict[str, Any] +) -> list[dict[str, Any]]: + """Flattens inherited_rule_sets and per-family statements into individual CVE entries.""" + raw_entries: list[dict[str, Any]] = [] + for rule_set_name in family_cfg.get("inherited_rule_sets", []): + rule_set = shared_rule_sets.get(rule_set_name, {}) + raw_entries.extend(rule_set.get("statements", [])) + raw_entries.extend(family_cfg.get("statements", [])) + + by_cve: dict[str, dict[str, Any]] = {} + for entry in raw_entries: + cve_ids = entry.get("ids") or ([entry["id"]] if "id" in entry else []) + for cve_id in cve_ids: + by_cve[cve_id] = { + "id": cve_id, + "status": entry.get("status", "not_affected"), + "justification": entry.get( + "justification", "vulnerable_code_not_in_execute_path" + ), + "packages": list(entry.get("packages", [])), + "impact_statement": entry.get("impact_statement", "").strip(), + "action_statement": entry.get("action_statement", "").strip(), + } + return [by_cve[k] for k in sorted(by_cve.keys())] + + +def build_openvex_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + product_ids: list[str] = [] + for repo in repos: + for expanded in expand_uri_variants(repo, extra_digests=extra_digests): + if expanded not in product_ids: + product_ids.append(expanded) + + openvex_statements: list[dict[str, Any]] = [] + for stmt in statements: + item: dict[str, Any] = { + "vulnerability": {"name": stmt["id"]}, + "timestamp": timestamp, + "products": [{"@id": pid} for pid in product_ids], + "status": stmt["status"], + } + if stmt["status"] == "not_affected": + item["justification"] = stmt["justification"] + if stmt.get("impact_statement"): + item["impact_statement"] = stmt["impact_statement"] + elif stmt["status"] == "affected" and stmt.get("action_statement"): + item["action_statement"] = stmt["action_statement"] + openvex_statements.append(item) + + namespace = publisher.get("namespace", "https://beam.apache.org").rstrip("/") + return { + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": f"{namespace}/vex/{family_name}", + "author": publisher.get("name", "Apache Beam Project"), + "role": publisher.get("role", "vendor"), + "timestamp": timestamp, + "version": 1, + "statements": openvex_statements, + } + + +def build_csaf_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + """Builds a CSAF 2.0 VEX document compatible with gcloud artifacts vulnerabilities load-vex.""" + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + expanded_uris: list[str] = [] + for repo in repos: + for variant in expand_uri_variants(repo, extra_digests=extra_digests): + if variant not in expanded_uris: + expanded_uris.append(variant) + + branches: list[dict[str, Any]] = [] + product_ids: list[str] = [] + for idx, uri in enumerate(expanded_uris, start=1): + pid = f"product_{idx}" + product_ids.append(pid) + branches.append({ + "category": "product_version", + "name": uri, + "product": { + "name": uri, + "product_id": pid, + }, + }) + + vulnerabilities: list[dict[str, Any]] = [] + for stmt in statements: + cve_id = stmt["id"] + status = stmt["status"] + csaf_status_key = { + "not_affected": "known_not_affected", + "affected": "known_affected", + "fixed": "fixed", + "under_investigation": "under_investigation", + }.get(status, "known_not_affected") + + vuln_entry: dict[str, Any] = { + "cve": cve_id, + "title": f"VEX assessment for {cve_id} in {family_name}", + "notes": [ + { + "category": "description", + "title": f"{cve_id} exploitability assessment", + "text": ( + stmt.get("impact_statement") + or f"{cve_id} is {status} in {family_name}." + ), + } + ], + "product_status": { + csaf_status_key: product_ids, + }, + } + if status == "not_affected": + just = CSAF_JUSTIFICATION_MAP.get( + stmt.get("justification", ""), "vulnerable_code_not_in_execute_path" + ) + vuln_entry["threats"] = [ + { + "category": "impact", + "details": ( + stmt.get("impact_statement") + or f"{cve_id} is not exploitable in {family_name}." + ), + "product_ids": product_ids, + } + ] + vuln_entry["flags"] = [ + { + "label": just, + "product_ids": product_ids, + } + ] + elif stmt.get("action_statement"): + vuln_entry["remediations"] = [ + { + "category": "mitigation", + "details": stmt["action_statement"], + "product_ids": product_ids, + } + ] + vulnerabilities.append(vuln_entry) + + return { + "document": { + "Category": "csaf_vex", + "csaf_version": "2.0", + "title": f"{publisher.get('name', 'Apache Beam')} VEX for {family_name}", + "publisher": { + "category": publisher.get("role", "vendor"), + "name": publisher.get("name", "Apache Beam Project"), + "namespace": publisher.get("namespace", "https://beam.apache.org"), + "contact_details": publisher.get( + "contact_details", + "https://beam.apache.org/community/contact-us/", + ), + }, + "tracking": { + "id": f"apache-beam-vex-{family_name}", + "status": "final", + "version": "1", + "initial_release_date": timestamp, + "current_release_date": timestamp, + }, + }, + "product_tree": { + "branches": branches, + }, + "vulnerabilities": vulnerabilities, + } + + +def build_lichene_rules_snippet( + config: dict[str, Any], +) -> str: + """Generates a Lichene Piccolo (`rules.pi`) snippet from the VEX config.""" + shared_sets = config.get("shared_rule_sets", {}) + grouped: dict[tuple[str, str], set[str]] = {} + for family_name, family_cfg in sorted( + config.get("image_families", {}).items() + ): + for stmt in normalize_statements(family_cfg, shared_sets): + if stmt["status"] != "not_affected": + continue + lichene_status = LICHENE_STATUS_MAP.get( + stmt["justification"], "CODE_UNREACHABLE" + ) + key = (lichene_status, stmt["impact_statement"]) + grouped.setdefault(key, set()).add(stmt["id"]) + + lines = [ + "# Licensed to the Apache Software Foundation (ASF) under one", + "# or more contributor license agreements. See the NOTICE file", + "# distributed with this work for additional information", + "# regarding copyright ownership. The ASF licenses this file", + "# to you under the Apache License, Version 2.0 (the", + '# "License"); you may not use this file except in compliance', + "# with the License. You may obtain a copy of the License at", + "#", + "# http://www.apache.org/licenses/LICENSE-2.0", + "#", + "# Unless required by applicable law or agreed to in writing,", + "# software distributed under the License is distributed on an", + '# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY', + "# KIND, either express or implied. See the License for the", + "# specific language governing permissions and limitations", + "# under the License.", + "#", + "# Auto-generated Lichene triage rules from beam_containers_vex.yaml", + "# Target: //testing/security/lichene/config/env/prod/tombolo_self_service/cloud_dataflow/rules.pi", + "", + ] + for idx, ((lichene_status, justification), cves) in enumerate( + sorted(grouped.items()), start=1 + ): + lines.append("containers.triage_vulns(") + lines.append(f' rule_id="vex_auto_rule_{idx}_{lichene_status.lower()}",') + lines.append(f" triage_as=lichene.TriageStatus.{lichene_status},") + lines.append(f" justification={json.dumps(justification)},") + lines.append(' structured_justifications=["b/502535467", "b/553250543"],') + lines.append(' approval_process="b/492390417",') + lines.append(" advisory_references=[") + for cve in sorted(cves): + lines.append(f' "{cve}",') + lines.append(" ],") + lines.append(" for_artifacts=[") + lines.append( + ' containers.create_ar_filter("google.com:dataflow-containers"),' + ) + lines.append(' containers.create_gcr_filter("cloud-dataflow"),') + lines.append( + ' containers.create_gcr_filter("dataflow-templates-base"),' + ) + lines.append(' containers.create_gcr_filter("dataflow-templates"),') + lines.append(" ],") + lines.append(" applies_also_to_deployments=True,") + lines.append("),") + return "\n".join(lines) + "\n" + + +def parse_artifact_registry_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `gcloud artifacts vulnerabilities list --format=json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + if isinstance(data, dict): + occurrences = ( + data.get("Occurrences") + or data.get("occurrences") + or [] + ) + elif isinstance(data, list): + occurrences = data + else: + occurrences = [] + + findings: list[dict[str, Any]] = [] + for item in occurrences: + if not isinstance(item, dict): + continue + occ = item.get("occurrence", item) + if not isinstance(occ, dict): + continue + vuln = occ.get("vulnerability", {}) + cve_id = vuln.get("shortDescription", "") + if not cve_id.startswith("CVE-"): + continue + vex_state = ( + vuln.get("vexAssessment", {}).get("state", "") + if isinstance(vuln.get("vexAssessment"), dict) + else "" + ) + raw_pi = vuln.get("packageIssue", []) + pi_list = [raw_pi] if isinstance(raw_pi, dict) else (raw_pi if isinstance(raw_pi, list) else []) + for pi in pi_list: + if not isinstance(pi, dict): + continue + findings.append({ + "id": cve_id, + "package": pi.get("affectedPackage", ""), + "package_type": pi.get("packageType", "OS"), + "severity": pi.get( + "effectiveSeverity", vuln.get("effectiveSeverity", "UNKNOWN") + ), + "fix_available": bool(pi.get("fixAvailable", False)), + "vex_state": vex_state, + "source": "artifact_registry", + }) + return findings + + +def parse_trivy_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `trivy image --format json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + findings: list[dict[str, Any]] = [] + for result in data.get("Results", []): + pkg_type = result.get("Class", "os-pkgs") + for vuln in result.get("Vulnerabilities", []) or []: + cve_id = vuln.get("VulnerabilityID", "") + if not cve_id.startswith("CVE-"): + continue + findings.append({ + "id": cve_id, + "package": vuln.get("PkgName", ""), + "package_type": pkg_type, + "severity": vuln.get("Severity", "UNKNOWN"), + "fix_available": bool(vuln.get("FixedVersion")), + "vex_state": "", + "source": "trivy", + }) + return findings + + +def parse_govulncheck_json(raw_stream: str) -> set[str]: + """Extracts reachable Go OSV/CVE IDs reported with symbol-level callstacks by govulncheck.""" + reachable: set[str] = set() + dec = json.JSONDecoder() + idx = 0 + text = raw_stream.strip() + osv_to_cves: dict[str, list[str]] = {} + called_osvs: set[str] = set() + while idx < len(text): + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text): + break + obj, idx = dec.raw_decode(text, idx) + if not isinstance(obj, dict): + continue + if "osv" in obj and isinstance(obj["osv"], dict): + osv_id = obj["osv"].get("id", "") + aliases = [ + a for a in obj["osv"].get("aliases", []) if a.startswith("CVE-") + ] + if osv_id: + osv_to_cves[osv_id] = aliases + if "finding" in obj and isinstance(obj["finding"], dict): + finding = obj["finding"] + trace = finding.get("trace", []) + if trace and any(frame.get("function") for frame in trace): + called_osvs.add(finding.get("osv", "")) + for osv_id in called_osvs: + reachable.update(osv_to_cves.get(osv_id, [])) + return reachable + + +def classify_finding_reachability( + finding: dict[str, Any], + reachable_go_cves: set[str] | None = None, +) -> dict[str, Any]: + """Classifies whether a newly discovered CVE is non-exploitable or requires investigation.""" + pkg = finding.get("package", "") + cve_id = finding["id"] + pkg_type = str(finding.get("package_type", "")).upper() + + if pkg in BUILD_ONLY_OS_PACKAGES: + return { + "id": cve_id, + "package": pkg, + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": BUILD_ONLY_OS_PACKAGES[pkg], + "confidence": "high", + } Review Comment: **🔴 Critical**: Remove `"LANG-PKGS"` from this tuple so non-Go language vulnerabilities (Python, Java, JS) cannot be matched by Go's `govulncheck` reachability check. ```suggestion if reachable_go_cves is not None and pkg_type in ("GO", "GOBINARY", "GOMOD"): ``` ########## sdks/containers/vex/vex_manager.py: ########## @@ -0,0 +1,1008 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Generates, scans/triages, and attaches VEX statements for Beam containers. + +Supported capabilities: + 1. `generate`: Produces OpenVEX v0.2.0 JSON, CSAF 2.0 VEX JSON (compatible + with `gcloud artifacts vulnerabilities load-vex`), and AutoVM / Lichene + triage rule snippets from `beam_containers_vex.yaml`. + 2. `scan`: Queries vulnerabilities from Google Artifact Registry + (`gcloud artifacts vulnerabilities list`), Trivy (`trivy image`), and + `govulncheck`, filters out CVEs already covered by VEX, performs automated + and optional LLM-assisted reachability triage, and optionally appends + newly verified `not_affected` entries to the VEX config. + 3. `attach`: Attaches OpenVEX attestations to container digests via + `cosign attest --type https://openvex.dev/ns` and loads CSAF 2.0 VEX + assessments into Artifact Registry via + `gcloud artifacts vulnerabilities load-vex`. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime +from datetime import timezone +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import urllib.request +from typing import Any +import yaml + +CSAF_JUSTIFICATION_MAP = { + "component_not_present": "component_not_present", + "vulnerable_code_not_present": "vulnerable_code_not_present", + "vulnerable_code_not_in_execute_path": "vulnerable_code_not_in_execute_path", + "vulnerable_code_cannot_be_controlled_by_adversary": ( + "vulnerable_code_cannot_be_controlled_by_adversary" + ), + "inline_mitigations_already_exist": "inline_mitigations_already_exist", +} + +LICHENE_STATUS_MAP = { + "component_not_present": "COMPONENT_NOT_PRESENT", + "vulnerable_code_not_present": "CODE_NOT_PRESENT", + "vulnerable_code_not_in_execute_path": "CODE_UNREACHABLE", + "vulnerable_code_cannot_be_controlled_by_adversary": "CODE_NOT_CONTROLLABLE", + "inline_mitigations_already_exist": "MITIGATED", +} + +BUILD_ONLY_OS_PACKAGES = { + "binutils": ( + "GNU binutils CLI utilities (objdump, readelf, nm, ld, strip) and " + "libbfd are build-time tools inherited from the base OS image and are " + "never invoked on untrusted ELF inputs at runtime." + ), + "elfutils": ( + "elfutils DWARF/ELF inspection utilities are build/debug tools not " + "executed at runtime by the container entrypoint." + ), + "gcc-12": ( + "gcc-12 compiler binaries and C++ demangler CLI utilities are " + "build-time tools not invoked at runtime." + ), + "linux-libc-dev": ( + "linux-libc-dev contains C header files for compiling userspace " + "programs and ships no runtime executable code or shared libraries." + ), + "m4": ( + "GNU m4 is a build-time macro processor pulled in by autotools and is " + "never invoked at runtime." + ), + "toolchain": ( + "Go cmd/go build-time toolchain vulnerability reported from embedded " + "Go binary build metadata; the runtime container image does not ship " + "or execute cmd/go." + ), + "bluez": ( + "Bluetooth protocol stack libraries present in the upstream Debian " + "base image are unused by container worker processes." + ), + "cairo": ( + "2D desktop graphics library present in the upstream Debian base " + "image is not loaded by headless worker processes." + ), + "gdk-pixbuf": ( + "Desktop GUI pixel-buffer library in the upstream Debian base image " + "is unused by headless worker processes." + ), + "imagemagick": ( + "ImageMagick CLI/library in the upstream Debian base image is not " + "invoked by the container entrypoint." + ), + "libheif": ( + "HEIF image decoder library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "libwmf": ( + "Windows Metafile library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openexr": ( + "OpenEXR HDR image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openjpeg2": ( + "JPEG 2000 image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), +} + +GCR_HOST_RE = re.compile( + r"^(?P<host>(?:(?P<prefix>us|eu|asia)\.)?gcr\.io)/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/(?P<image>.+)$" +) +AR_REDIRECTED_GCR_RE = re.compile( + r"^(?P<region>us|europe|asia)-docker\.pkg\.dev/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/" + r"(?P<repo>(?:(?:us|eu|asia)\.)?gcr\.io)/(?P<image>.+)$" +) + + +def expand_uri_variants(uri: str, extra_digests: list[str] | None = None) -> list[str]: + """Expands a container repository URI into all GCR / AR forms needed by gcloud load-vex.""" + cleaned = re.sub(r"^https?://", "", uri.strip()) + base_uri = cleaned.split("@")[0].split(":")[0] + explicit_suffix = "" + if "@" in cleaned: + explicit_suffix = "@" + cleaned.split("@", 1)[1] + elif ":" in cleaned.split("/")[-1]: + explicit_suffix = ":" + cleaned.split("/")[-1].split(":", 1)[1] + + base_variants: list[str] = [base_uri] + + m_gcr = GCR_HOST_RE.match(base_uri) + if m_gcr: + host = m_gcr.group("host") + prefix = m_gcr.group("prefix") or "" + project = m_gcr.group("project") + image = m_gcr.group("image") + ar_region = {"": "us", "us": "us", "eu": "europe", "asia": "asia"}[prefix] + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{host}/{pv}/{image}") + base_variants.append(f"{ar_region}-docker.pkg.dev/{pv}/{host}/{image}") + if host == "gcr.io": + base_variants.append(f"us.gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/us.gcr.io/{image}") + elif host == "us.gcr.io": + base_variants.append(f"gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/gcr.io/{image}") + + m_ar = AR_REDIRECTED_GCR_RE.match(base_uri) + if m_ar: + region = m_ar.group("region") + project = m_ar.group("project") + repo = m_ar.group("repo") + image = m_ar.group("image") + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{region}-docker.pkg.dev/{pv}/{repo}/{image}") + base_variants.append(f"{repo}/{pv}/{image}") + + deduped_bases: list[str] = [] + for candidate in base_variants: + if candidate not in deduped_bases: + deduped_bases.append(candidate) + + suffixes = [""] + if explicit_suffix and explicit_suffix not in suffixes: + suffixes.append(explicit_suffix) + for digest in extra_digests or []: + norm_digest = digest if digest.startswith("@") else f"@{digest}" + if norm_digest not in suffixes: + suffixes.append(norm_digest) + + expanded: list[str] = [] + for b in deduped_bases: + for s in suffixes: + cand = f"{b}{s}" + if cand not in expanded: + expanded.append(cand) + return expanded + + +def load_vex_config(config_path: Path) -> dict[str, Any]: + with config_path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def normalize_statements( + family_cfg: dict[str, Any], shared_rule_sets: dict[str, Any] +) -> list[dict[str, Any]]: + """Flattens inherited_rule_sets and per-family statements into individual CVE entries.""" + raw_entries: list[dict[str, Any]] = [] + for rule_set_name in family_cfg.get("inherited_rule_sets", []): + rule_set = shared_rule_sets.get(rule_set_name, {}) + raw_entries.extend(rule_set.get("statements", [])) + raw_entries.extend(family_cfg.get("statements", [])) + + by_cve: dict[str, dict[str, Any]] = {} + for entry in raw_entries: + cve_ids = entry.get("ids") or ([entry["id"]] if "id" in entry else []) + for cve_id in cve_ids: + by_cve[cve_id] = { + "id": cve_id, + "status": entry.get("status", "not_affected"), + "justification": entry.get( + "justification", "vulnerable_code_not_in_execute_path" + ), + "packages": list(entry.get("packages", [])), + "impact_statement": entry.get("impact_statement", "").strip(), + "action_statement": entry.get("action_statement", "").strip(), + } + return [by_cve[k] for k in sorted(by_cve.keys())] + + +def build_openvex_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + product_ids: list[str] = [] + for repo in repos: + for expanded in expand_uri_variants(repo, extra_digests=extra_digests): + if expanded not in product_ids: + product_ids.append(expanded) + + openvex_statements: list[dict[str, Any]] = [] + for stmt in statements: + item: dict[str, Any] = { + "vulnerability": {"name": stmt["id"]}, + "timestamp": timestamp, + "products": [{"@id": pid} for pid in product_ids], + "status": stmt["status"], + } + if stmt["status"] == "not_affected": + item["justification"] = stmt["justification"] + if stmt.get("impact_statement"): + item["impact_statement"] = stmt["impact_statement"] + elif stmt["status"] == "affected" and stmt.get("action_statement"): + item["action_statement"] = stmt["action_statement"] + openvex_statements.append(item) + + namespace = publisher.get("namespace", "https://beam.apache.org").rstrip("/") + return { + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": f"{namespace}/vex/{family_name}", + "author": publisher.get("name", "Apache Beam Project"), + "role": publisher.get("role", "vendor"), + "timestamp": timestamp, + "version": 1, + "statements": openvex_statements, + } + + +def build_csaf_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + """Builds a CSAF 2.0 VEX document compatible with gcloud artifacts vulnerabilities load-vex.""" + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + expanded_uris: list[str] = [] + for repo in repos: + for variant in expand_uri_variants(repo, extra_digests=extra_digests): + if variant not in expanded_uris: + expanded_uris.append(variant) + + branches: list[dict[str, Any]] = [] + product_ids: list[str] = [] + for idx, uri in enumerate(expanded_uris, start=1): + pid = f"product_{idx}" + product_ids.append(pid) + branches.append({ + "category": "product_version", + "name": uri, + "product": { + "name": uri, + "product_id": pid, + }, + }) + + vulnerabilities: list[dict[str, Any]] = [] + for stmt in statements: + cve_id = stmt["id"] + status = stmt["status"] + csaf_status_key = { + "not_affected": "known_not_affected", + "affected": "known_affected", + "fixed": "fixed", + "under_investigation": "under_investigation", + }.get(status, "known_not_affected") + + vuln_entry: dict[str, Any] = { + "cve": cve_id, + "title": f"VEX assessment for {cve_id} in {family_name}", + "notes": [ + { + "category": "description", + "title": f"{cve_id} exploitability assessment", + "text": ( + stmt.get("impact_statement") + or f"{cve_id} is {status} in {family_name}." + ), + } + ], + "product_status": { + csaf_status_key: product_ids, + }, + } + if status == "not_affected": + just = CSAF_JUSTIFICATION_MAP.get( + stmt.get("justification", ""), "vulnerable_code_not_in_execute_path" + ) + vuln_entry["threats"] = [ + { + "category": "impact", + "details": ( + stmt.get("impact_statement") + or f"{cve_id} is not exploitable in {family_name}." + ), + "product_ids": product_ids, + } + ] + vuln_entry["flags"] = [ + { + "label": just, + "product_ids": product_ids, + } + ] + elif stmt.get("action_statement"): + vuln_entry["remediations"] = [ + { + "category": "mitigation", + "details": stmt["action_statement"], + "product_ids": product_ids, + } + ] + vulnerabilities.append(vuln_entry) + + return { + "document": { + "Category": "csaf_vex", + "csaf_version": "2.0", + "title": f"{publisher.get('name', 'Apache Beam')} VEX for {family_name}", + "publisher": { + "category": publisher.get("role", "vendor"), + "name": publisher.get("name", "Apache Beam Project"), + "namespace": publisher.get("namespace", "https://beam.apache.org"), + "contact_details": publisher.get( + "contact_details", + "https://beam.apache.org/community/contact-us/", + ), + }, + "tracking": { + "id": f"apache-beam-vex-{family_name}", + "status": "final", + "version": "1", + "initial_release_date": timestamp, + "current_release_date": timestamp, + }, + }, + "product_tree": { + "branches": branches, + }, + "vulnerabilities": vulnerabilities, + } + + +def build_lichene_rules_snippet( + config: dict[str, Any], +) -> str: + """Generates a Lichene Piccolo (`rules.pi`) snippet from the VEX config.""" + shared_sets = config.get("shared_rule_sets", {}) + grouped: dict[tuple[str, str], set[str]] = {} + for family_name, family_cfg in sorted( + config.get("image_families", {}).items() + ): + for stmt in normalize_statements(family_cfg, shared_sets): + if stmt["status"] != "not_affected": + continue + lichene_status = LICHENE_STATUS_MAP.get( + stmt["justification"], "CODE_UNREACHABLE" + ) + key = (lichene_status, stmt["impact_statement"]) + grouped.setdefault(key, set()).add(stmt["id"]) + + lines = [ + "# Licensed to the Apache Software Foundation (ASF) under one", + "# or more contributor license agreements. See the NOTICE file", + "# distributed with this work for additional information", + "# regarding copyright ownership. The ASF licenses this file", + "# to you under the Apache License, Version 2.0 (the", + '# "License"); you may not use this file except in compliance', + "# with the License. You may obtain a copy of the License at", + "#", + "# http://www.apache.org/licenses/LICENSE-2.0", + "#", + "# Unless required by applicable law or agreed to in writing,", + "# software distributed under the License is distributed on an", + '# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY', + "# KIND, either express or implied. See the License for the", + "# specific language governing permissions and limitations", + "# under the License.", + "#", + "# Auto-generated Lichene triage rules from beam_containers_vex.yaml", + "# Target: //testing/security/lichene/config/env/prod/tombolo_self_service/cloud_dataflow/rules.pi", + "", + ] + for idx, ((lichene_status, justification), cves) in enumerate( + sorted(grouped.items()), start=1 + ): + lines.append("containers.triage_vulns(") + lines.append(f' rule_id="vex_auto_rule_{idx}_{lichene_status.lower()}",') + lines.append(f" triage_as=lichene.TriageStatus.{lichene_status},") + lines.append(f" justification={json.dumps(justification)},") + lines.append(' structured_justifications=["b/502535467", "b/553250543"],') + lines.append(' approval_process="b/492390417",') + lines.append(" advisory_references=[") + for cve in sorted(cves): + lines.append(f' "{cve}",') + lines.append(" ],") + lines.append(" for_artifacts=[") + lines.append( + ' containers.create_ar_filter("google.com:dataflow-containers"),' + ) + lines.append(' containers.create_gcr_filter("cloud-dataflow"),') + lines.append( + ' containers.create_gcr_filter("dataflow-templates-base"),' + ) + lines.append(' containers.create_gcr_filter("dataflow-templates"),') + lines.append(" ],") + lines.append(" applies_also_to_deployments=True,") + lines.append("),") + return "\n".join(lines) + "\n" + + +def parse_artifact_registry_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `gcloud artifacts vulnerabilities list --format=json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + if isinstance(data, dict): + occurrences = ( + data.get("Occurrences") + or data.get("occurrences") + or [] + ) + elif isinstance(data, list): + occurrences = data + else: + occurrences = [] + + findings: list[dict[str, Any]] = [] + for item in occurrences: + if not isinstance(item, dict): + continue + occ = item.get("occurrence", item) + if not isinstance(occ, dict): + continue + vuln = occ.get("vulnerability", {}) + cve_id = vuln.get("shortDescription", "") + if not cve_id.startswith("CVE-"): + continue + vex_state = ( + vuln.get("vexAssessment", {}).get("state", "") + if isinstance(vuln.get("vexAssessment"), dict) + else "" + ) + raw_pi = vuln.get("packageIssue", []) + pi_list = [raw_pi] if isinstance(raw_pi, dict) else (raw_pi if isinstance(raw_pi, list) else []) + for pi in pi_list: + if not isinstance(pi, dict): + continue + findings.append({ + "id": cve_id, + "package": pi.get("affectedPackage", ""), + "package_type": pi.get("packageType", "OS"), + "severity": pi.get( + "effectiveSeverity", vuln.get("effectiveSeverity", "UNKNOWN") + ), + "fix_available": bool(pi.get("fixAvailable", False)), + "vex_state": vex_state, + "source": "artifact_registry", + }) + return findings + + +def parse_trivy_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `trivy image --format json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + findings: list[dict[str, Any]] = [] + for result in data.get("Results", []): + pkg_type = result.get("Class", "os-pkgs") + for vuln in result.get("Vulnerabilities", []) or []: + cve_id = vuln.get("VulnerabilityID", "") + if not cve_id.startswith("CVE-"): + continue + findings.append({ + "id": cve_id, + "package": vuln.get("PkgName", ""), + "package_type": pkg_type, + "severity": vuln.get("Severity", "UNKNOWN"), + "fix_available": bool(vuln.get("FixedVersion")), + "vex_state": "", + "source": "trivy", + }) + return findings + + +def parse_govulncheck_json(raw_stream: str) -> set[str]: + """Extracts reachable Go OSV/CVE IDs reported with symbol-level callstacks by govulncheck.""" + reachable: set[str] = set() + dec = json.JSONDecoder() + idx = 0 + text = raw_stream.strip() + osv_to_cves: dict[str, list[str]] = {} + called_osvs: set[str] = set() + while idx < len(text): + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text): + break + obj, idx = dec.raw_decode(text, idx) + if not isinstance(obj, dict): + continue + if "osv" in obj and isinstance(obj["osv"], dict): + osv_id = obj["osv"].get("id", "") + aliases = [ + a for a in obj["osv"].get("aliases", []) if a.startswith("CVE-") + ] + if osv_id: + osv_to_cves[osv_id] = aliases + if "finding" in obj and isinstance(obj["finding"], dict): + finding = obj["finding"] + trace = finding.get("trace", []) + if trace and any(frame.get("function") for frame in trace): + called_osvs.add(finding.get("osv", "")) + for osv_id in called_osvs: + reachable.update(osv_to_cves.get(osv_id, [])) + return reachable + + +def classify_finding_reachability( + finding: dict[str, Any], + reachable_go_cves: set[str] | None = None, +) -> dict[str, Any]: + """Classifies whether a newly discovered CVE is non-exploitable or requires investigation.""" + pkg = finding.get("package", "") + cve_id = finding["id"] + pkg_type = str(finding.get("package_type", "")).upper() + + if pkg in BUILD_ONLY_OS_PACKAGES: + return { + "id": cve_id, + "package": pkg, + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": BUILD_ONLY_OS_PACKAGES[pkg], + "confidence": "high", + } + + if reachable_go_cves is not None and pkg_type in ("GO", "GOBINARY", "LANG-PKGS"): + if cve_id not in reachable_go_cves: + return { + "id": cve_id, + "package": pkg, + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": ( + f"Verified via govulncheck that {cve_id} in {pkg} has no " + "reachable symbol call path from the container entrypoint." + ), + "confidence": "high", + } + + return { + "id": cve_id, + "package": pkg, + "status": "under_investigation", + "justification": "", + "impact_statement": ( + f"{cve_id} in package {pkg} ({pkg_type}) requires reachability " + "verification or base image / dependency upgrade." + ), + "confidence": "needs_review", + } + + +def invoke_llm_triage( + finding: dict[str, Any], + family_name: str, + family_cfg: dict[str, Any], +) -> dict[str, Any] | None: + """Optionally queries Gemini API when GEMINI_API_KEY is configured for agentic reachability analysis.""" + api_key = os.environ.get("GEMINI_API_KEY", "").strip() + if not api_key: + return None + prompt = ( + "You are a container security reachability analyzer for Apache Beam.\n" + f"Container family: {family_name}\n" + f"Runtime entrypoints: {family_cfg.get('runtime_entrypoints', [])}\n" + f"Vulnerability: {json.dumps(finding)}\n" + "Return ONLY a JSON object with keys: status ('not_affected' or " + "'under_investigation'), justification (one of CISA VEX justifications " + "if not_affected, else ''), and impact_statement (1-2 concise sentences)." + ) + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"gemini-2.5-flash:generateContent?key={api_key}" + ) + payload = json.dumps({ + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"responseMimeType": "application/json"}, + }).encode("utf-8") + req = urllib.request.Request( + url, data=payload, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = json.loads(resp.read().decode("utf-8")) + text = body["candidates"][0]["content"]["parts"][0]["text"] + parsed = json.loads(text) + if parsed.get("status") in ("not_affected", "under_investigation"): + return { + "id": finding["id"], + "package": finding.get("package", ""), + "status": parsed["status"], + "justification": parsed.get( + "justification", "vulnerable_code_not_in_execute_path" + ), + "impact_statement": parsed.get("impact_statement", ""), + "confidence": "agentic_llm", + } + except Exception as exc: # pylint: disable=broad-except + print(f"Warning: LLM triage request failed for {finding['id']}: {exc}", file=sys.stderr) + return None + + +def cmd_generate(args: argparse.Namespace) -> int: + config_path = Path(args.config) + output_dir = Path(args.output_dir) + config = load_vex_config(config_path) + publisher = config.get("publisher", {}) + shared_sets = config.get("shared_rule_sets", {}) + timestamp = args.timestamp or datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + openvex_dir = output_dir / "openvex" + csaf_dir = output_dir / "csaf" + lichene_dir = output_dir / "lichene" + openvex_dir.mkdir(parents=True, exist_ok=True) + csaf_dir.mkdir(parents=True, exist_ok=True) + lichene_dir.mkdir(parents=True, exist_ok=True) + + families = config.get("image_families", {}) + selected_families = ( + [args.family] if args.family else sorted(families.keys()) + ) + for family_name in selected_families: + if family_name not in families: + raise ValueError(f"Unknown image family: {family_name}") + family_cfg = families[family_name] + statements = normalize_statements(family_cfg, shared_sets) + + openvex_doc = build_openvex_document( + family_name=family_name, + family_cfg=family_cfg, + publisher=publisher, + statements=statements, + timestamp=timestamp, + extra_uris=args.extra_uri, + extra_digests=args.extra_digest, + ) + csaf_doc = build_csaf_document( + family_name=family_name, + family_cfg=family_cfg, + publisher=publisher, + statements=statements, + timestamp=timestamp, + extra_uris=args.extra_uri, + extra_digests=args.extra_digest, + ) + + openvex_path = openvex_dir / f"{family_name}.openvex.json" + csaf_path = csaf_dir / f"{family_name}.vex.json" + openvex_path.write_text( + json.dumps(openvex_doc, indent=2) + "\n", encoding="utf-8" + ) + csaf_path.write_text( + json.dumps(csaf_doc, indent=2) + "\n", encoding="utf-8" + ) + print(f"Generated {openvex_path} and {csaf_path} ({len(statements)} CVEs)") + + lichene_path = lichene_dir / "beam_vex_rules.pi" + lichene_path.write_text(build_lichene_rules_snippet(config), encoding="utf-8") + print(f"Generated {lichene_path}") + return 0 + + +def cmd_scan(args: argparse.Namespace) -> int: + config_path = Path(args.config) + config = load_vex_config(config_path) + families = config.get("image_families", {}) + shared_sets = config.get("shared_rule_sets", {}) + if args.family not in families: + raise ValueError(f"Unknown image family: {args.family}") + family_cfg = families[args.family] + covered_cves = { + s["id"] for s in normalize_statements(family_cfg, shared_sets) + } + + discovered: list[dict[str, Any]] = [] + if args.ar_image: + cmd = [ + "gcloud", + "artifacts", + "vulnerabilities", + "list", + args.ar_image, + f"--location={args.ar_location}", + "--format=json", + ] + if args.ar_project: + cmd.append(f"--project={args.ar_project}") + res = subprocess.run(cmd, check=True, capture_output=True, text=True) + discovered.extend(parse_artifact_registry_vulns(res.stdout)) + + if args.trivy_image: + cmd = ["trivy", "image", "--format", "json", "--quiet", args.trivy_image] + res = subprocess.run(cmd, check=True, capture_output=True, text=True) + discovered.extend(parse_trivy_vulns(res.stdout)) + + reachable_go_cves: set[str] | None = None + if args.govulncheck_binary: + cmd = [ + "govulncheck", + "-format", + "json", + "-mode", + "binary", + args.govulncheck_binary, + ] + res = subprocess.run(cmd, check=False, capture_output=True, text=True) + reachable_go_cves = parse_govulncheck_json(res.stdout) + + uncovered_by_cve: dict[str, dict[str, Any]] = {} + for finding in discovered: + cve_id = finding["id"] + if cve_id in covered_cves or finding.get("vex_state") == "NOT_AFFECTED": + continue + uncovered_by_cve.setdefault(cve_id, finding) + + triage_results: list[dict[str, Any]] = [] + for cve_id in sorted(uncovered_by_cve.keys()): + finding = uncovered_by_cve[cve_id] + verdict = classify_finding_reachability( + finding, reachable_go_cves=reachable_go_cves + ) + if verdict["status"] == "under_investigation" and args.agentic_triage: + llm_verdict = invoke_llm_triage(finding, args.family, family_cfg) + if llm_verdict is not None: + verdict = llm_verdict + triage_results.append(verdict) + + report = { + "family": args.family, + "ar_image": args.ar_image, Review Comment: **🟠 High**: Using `yaml.safe_dump(config, f, sort_keys=False)` overwrites `beam_containers_vex.yaml` and strips the Apache License 2.0 header and all documentation comments from the file. Since the workflow automatically commits changes to `sdks/containers/vex/` and opens a PR, the resulting PR will fail Apache RAT / license header checks. Preserve or prepend the Apache license header when writing the updated YAML. ########## sdks/containers/vex/vex_manager.py: ########## @@ -0,0 +1,1008 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Generates, scans/triages, and attaches VEX statements for Beam containers. + +Supported capabilities: + 1. `generate`: Produces OpenVEX v0.2.0 JSON, CSAF 2.0 VEX JSON (compatible + with `gcloud artifacts vulnerabilities load-vex`), and AutoVM / Lichene + triage rule snippets from `beam_containers_vex.yaml`. + 2. `scan`: Queries vulnerabilities from Google Artifact Registry + (`gcloud artifacts vulnerabilities list`), Trivy (`trivy image`), and + `govulncheck`, filters out CVEs already covered by VEX, performs automated + and optional LLM-assisted reachability triage, and optionally appends + newly verified `not_affected` entries to the VEX config. + 3. `attach`: Attaches OpenVEX attestations to container digests via + `cosign attest --type https://openvex.dev/ns` and loads CSAF 2.0 VEX + assessments into Artifact Registry via + `gcloud artifacts vulnerabilities load-vex`. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime +from datetime import timezone +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import urllib.request +from typing import Any +import yaml + +CSAF_JUSTIFICATION_MAP = { + "component_not_present": "component_not_present", + "vulnerable_code_not_present": "vulnerable_code_not_present", + "vulnerable_code_not_in_execute_path": "vulnerable_code_not_in_execute_path", + "vulnerable_code_cannot_be_controlled_by_adversary": ( + "vulnerable_code_cannot_be_controlled_by_adversary" + ), + "inline_mitigations_already_exist": "inline_mitigations_already_exist", +} + +LICHENE_STATUS_MAP = { + "component_not_present": "COMPONENT_NOT_PRESENT", + "vulnerable_code_not_present": "CODE_NOT_PRESENT", + "vulnerable_code_not_in_execute_path": "CODE_UNREACHABLE", + "vulnerable_code_cannot_be_controlled_by_adversary": "CODE_NOT_CONTROLLABLE", + "inline_mitigations_already_exist": "MITIGATED", +} + +BUILD_ONLY_OS_PACKAGES = { + "binutils": ( + "GNU binutils CLI utilities (objdump, readelf, nm, ld, strip) and " + "libbfd are build-time tools inherited from the base OS image and are " + "never invoked on untrusted ELF inputs at runtime." + ), + "elfutils": ( + "elfutils DWARF/ELF inspection utilities are build/debug tools not " + "executed at runtime by the container entrypoint." + ), + "gcc-12": ( + "gcc-12 compiler binaries and C++ demangler CLI utilities are " + "build-time tools not invoked at runtime." + ), + "linux-libc-dev": ( + "linux-libc-dev contains C header files for compiling userspace " + "programs and ships no runtime executable code or shared libraries." + ), + "m4": ( + "GNU m4 is a build-time macro processor pulled in by autotools and is " + "never invoked at runtime." + ), + "toolchain": ( + "Go cmd/go build-time toolchain vulnerability reported from embedded " + "Go binary build metadata; the runtime container image does not ship " + "or execute cmd/go." + ), + "bluez": ( + "Bluetooth protocol stack libraries present in the upstream Debian " + "base image are unused by container worker processes." + ), + "cairo": ( + "2D desktop graphics library present in the upstream Debian base " + "image is not loaded by headless worker processes." + ), + "gdk-pixbuf": ( + "Desktop GUI pixel-buffer library in the upstream Debian base image " + "is unused by headless worker processes." + ), + "imagemagick": ( + "ImageMagick CLI/library in the upstream Debian base image is not " + "invoked by the container entrypoint." + ), + "libheif": ( + "HEIF image decoder library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "libwmf": ( + "Windows Metafile library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openexr": ( + "OpenEXR HDR image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), + "openjpeg2": ( + "JPEG 2000 image library in the upstream Debian base image is not " + "loaded by headless worker processes." + ), +} + +GCR_HOST_RE = re.compile( + r"^(?P<host>(?:(?P<prefix>us|eu|asia)\.)?gcr\.io)/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/(?P<image>.+)$" +) +AR_REDIRECTED_GCR_RE = re.compile( + r"^(?P<region>us|europe|asia)-docker\.pkg\.dev/" + r"(?P<project>(?:[^/@:.]+\.[^/@:]+/[^/@:]+|[^/@:]+))/" + r"(?P<repo>(?:(?:us|eu|asia)\.)?gcr\.io)/(?P<image>.+)$" +) + + +def expand_uri_variants(uri: str, extra_digests: list[str] | None = None) -> list[str]: + """Expands a container repository URI into all GCR / AR forms needed by gcloud load-vex.""" + cleaned = re.sub(r"^https?://", "", uri.strip()) + base_uri = cleaned.split("@")[0].split(":")[0] + explicit_suffix = "" + if "@" in cleaned: + explicit_suffix = "@" + cleaned.split("@", 1)[1] + elif ":" in cleaned.split("/")[-1]: + explicit_suffix = ":" + cleaned.split("/")[-1].split(":", 1)[1] + + base_variants: list[str] = [base_uri] + + m_gcr = GCR_HOST_RE.match(base_uri) + if m_gcr: + host = m_gcr.group("host") + prefix = m_gcr.group("prefix") or "" + project = m_gcr.group("project") + image = m_gcr.group("image") + ar_region = {"": "us", "us": "us", "eu": "europe", "asia": "asia"}[prefix] + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{host}/{pv}/{image}") + base_variants.append(f"{ar_region}-docker.pkg.dev/{pv}/{host}/{image}") + if host == "gcr.io": + base_variants.append(f"us.gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/us.gcr.io/{image}") + elif host == "us.gcr.io": + base_variants.append(f"gcr.io/{pv}/{image}") + base_variants.append(f"us-docker.pkg.dev/{pv}/gcr.io/{image}") + + m_ar = AR_REDIRECTED_GCR_RE.match(base_uri) + if m_ar: + region = m_ar.group("region") + project = m_ar.group("project") + repo = m_ar.group("repo") + image = m_ar.group("image") + proj_variants = {project, project.replace(":", "/"), project.replace("/", ":")} + for pv in sorted(proj_variants): + base_variants.append(f"{region}-docker.pkg.dev/{pv}/{repo}/{image}") + base_variants.append(f"{repo}/{pv}/{image}") + + deduped_bases: list[str] = [] + for candidate in base_variants: + if candidate not in deduped_bases: + deduped_bases.append(candidate) + + suffixes = [""] + if explicit_suffix and explicit_suffix not in suffixes: + suffixes.append(explicit_suffix) + for digest in extra_digests or []: + norm_digest = digest if digest.startswith("@") else f"@{digest}" + if norm_digest not in suffixes: + suffixes.append(norm_digest) + + expanded: list[str] = [] + for b in deduped_bases: + for s in suffixes: + cand = f"{b}{s}" + if cand not in expanded: + expanded.append(cand) + return expanded + + +def load_vex_config(config_path: Path) -> dict[str, Any]: + with config_path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def normalize_statements( + family_cfg: dict[str, Any], shared_rule_sets: dict[str, Any] +) -> list[dict[str, Any]]: + """Flattens inherited_rule_sets and per-family statements into individual CVE entries.""" + raw_entries: list[dict[str, Any]] = [] + for rule_set_name in family_cfg.get("inherited_rule_sets", []): + rule_set = shared_rule_sets.get(rule_set_name, {}) + raw_entries.extend(rule_set.get("statements", [])) + raw_entries.extend(family_cfg.get("statements", [])) + + by_cve: dict[str, dict[str, Any]] = {} + for entry in raw_entries: + cve_ids = entry.get("ids") or ([entry["id"]] if "id" in entry else []) + for cve_id in cve_ids: + by_cve[cve_id] = { + "id": cve_id, + "status": entry.get("status", "not_affected"), + "justification": entry.get( + "justification", "vulnerable_code_not_in_execute_path" + ), + "packages": list(entry.get("packages", [])), + "impact_statement": entry.get("impact_statement", "").strip(), + "action_statement": entry.get("action_statement", "").strip(), + } + return [by_cve[k] for k in sorted(by_cve.keys())] + + +def build_openvex_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + product_ids: list[str] = [] + for repo in repos: + for expanded in expand_uri_variants(repo, extra_digests=extra_digests): + if expanded not in product_ids: + product_ids.append(expanded) + + openvex_statements: list[dict[str, Any]] = [] + for stmt in statements: + item: dict[str, Any] = { + "vulnerability": {"name": stmt["id"]}, + "timestamp": timestamp, + "products": [{"@id": pid} for pid in product_ids], + "status": stmt["status"], + } + if stmt["status"] == "not_affected": + item["justification"] = stmt["justification"] + if stmt.get("impact_statement"): + item["impact_statement"] = stmt["impact_statement"] + elif stmt["status"] == "affected" and stmt.get("action_statement"): + item["action_statement"] = stmt["action_statement"] + openvex_statements.append(item) + + namespace = publisher.get("namespace", "https://beam.apache.org").rstrip("/") + return { + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": f"{namespace}/vex/{family_name}", + "author": publisher.get("name", "Apache Beam Project"), + "role": publisher.get("role", "vendor"), + "timestamp": timestamp, + "version": 1, + "statements": openvex_statements, + } + + +def build_csaf_document( + family_name: str, + family_cfg: dict[str, Any], + publisher: dict[str, Any], + statements: list[dict[str, Any]], + timestamp: str, + extra_uris: list[str] | None = None, + extra_digests: list[str] | None = None, +) -> dict[str, Any]: + """Builds a CSAF 2.0 VEX document compatible with gcloud artifacts vulnerabilities load-vex.""" + repos = list(family_cfg.get("repositories", [])) + for uri in extra_uris or []: + if uri not in repos: + repos.append(uri) + + expanded_uris: list[str] = [] + for repo in repos: + for variant in expand_uri_variants(repo, extra_digests=extra_digests): + if variant not in expanded_uris: + expanded_uris.append(variant) + + branches: list[dict[str, Any]] = [] + product_ids: list[str] = [] + for idx, uri in enumerate(expanded_uris, start=1): + pid = f"product_{idx}" + product_ids.append(pid) + branches.append({ + "category": "product_version", + "name": uri, + "product": { + "name": uri, + "product_id": pid, + }, + }) + + vulnerabilities: list[dict[str, Any]] = [] + for stmt in statements: + cve_id = stmt["id"] + status = stmt["status"] + csaf_status_key = { + "not_affected": "known_not_affected", + "affected": "known_affected", + "fixed": "fixed", + "under_investigation": "under_investigation", + }.get(status, "known_not_affected") + + vuln_entry: dict[str, Any] = { + "cve": cve_id, + "title": f"VEX assessment for {cve_id} in {family_name}", + "notes": [ + { + "category": "description", + "title": f"{cve_id} exploitability assessment", + "text": ( + stmt.get("impact_statement") + or f"{cve_id} is {status} in {family_name}." + ), + } + ], + "product_status": { + csaf_status_key: product_ids, + }, + } + if status == "not_affected": + just = CSAF_JUSTIFICATION_MAP.get( + stmt.get("justification", ""), "vulnerable_code_not_in_execute_path" + ) + vuln_entry["threats"] = [ + { + "category": "impact", + "details": ( + stmt.get("impact_statement") + or f"{cve_id} is not exploitable in {family_name}." + ), + "product_ids": product_ids, + } + ] + vuln_entry["flags"] = [ + { + "label": just, + "product_ids": product_ids, + } + ] + elif stmt.get("action_statement"): + vuln_entry["remediations"] = [ + { + "category": "mitigation", + "details": stmt["action_statement"], + "product_ids": product_ids, + } + ] + vulnerabilities.append(vuln_entry) + + return { + "document": { + "Category": "csaf_vex", + "csaf_version": "2.0", + "title": f"{publisher.get('name', 'Apache Beam')} VEX for {family_name}", + "publisher": { + "category": publisher.get("role", "vendor"), + "name": publisher.get("name", "Apache Beam Project"), + "namespace": publisher.get("namespace", "https://beam.apache.org"), + "contact_details": publisher.get( + "contact_details", + "https://beam.apache.org/community/contact-us/", + ), + }, + "tracking": { + "id": f"apache-beam-vex-{family_name}", + "status": "final", + "version": "1", + "initial_release_date": timestamp, + "current_release_date": timestamp, + }, + }, + "product_tree": { + "branches": branches, + }, + "vulnerabilities": vulnerabilities, + } + + +def build_lichene_rules_snippet( + config: dict[str, Any], +) -> str: + """Generates a Lichene Piccolo (`rules.pi`) snippet from the VEX config.""" + shared_sets = config.get("shared_rule_sets", {}) + grouped: dict[tuple[str, str], set[str]] = {} + for family_name, family_cfg in sorted( + config.get("image_families", {}).items() + ): + for stmt in normalize_statements(family_cfg, shared_sets): + if stmt["status"] != "not_affected": + continue + lichene_status = LICHENE_STATUS_MAP.get( + stmt["justification"], "CODE_UNREACHABLE" + ) + key = (lichene_status, stmt["impact_statement"]) + grouped.setdefault(key, set()).add(stmt["id"]) + + lines = [ + "# Licensed to the Apache Software Foundation (ASF) under one", + "# or more contributor license agreements. See the NOTICE file", + "# distributed with this work for additional information", + "# regarding copyright ownership. The ASF licenses this file", + "# to you under the Apache License, Version 2.0 (the", + '# "License"); you may not use this file except in compliance', + "# with the License. You may obtain a copy of the License at", + "#", + "# http://www.apache.org/licenses/LICENSE-2.0", + "#", + "# Unless required by applicable law or agreed to in writing,", + "# software distributed under the License is distributed on an", + '# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY', + "# KIND, either express or implied. See the License for the", + "# specific language governing permissions and limitations", + "# under the License.", + "#", + "# Auto-generated Lichene triage rules from beam_containers_vex.yaml", + "# Target: //testing/security/lichene/config/env/prod/tombolo_self_service/cloud_dataflow/rules.pi", + "", + ] + for idx, ((lichene_status, justification), cves) in enumerate( + sorted(grouped.items()), start=1 + ): + lines.append("containers.triage_vulns(") + lines.append(f' rule_id="vex_auto_rule_{idx}_{lichene_status.lower()}",') + lines.append(f" triage_as=lichene.TriageStatus.{lichene_status},") + lines.append(f" justification={json.dumps(justification)},") + lines.append(' structured_justifications=["b/502535467", "b/553250543"],') + lines.append(' approval_process="b/492390417",') + lines.append(" advisory_references=[") + for cve in sorted(cves): + lines.append(f' "{cve}",') + lines.append(" ],") + lines.append(" for_artifacts=[") + lines.append( + ' containers.create_ar_filter("google.com:dataflow-containers"),' + ) + lines.append(' containers.create_gcr_filter("cloud-dataflow"),') + lines.append( + ' containers.create_gcr_filter("dataflow-templates-base"),' + ) + lines.append(' containers.create_gcr_filter("dataflow-templates"),') + lines.append(" ],") + lines.append(" applies_also_to_deployments=True,") + lines.append("),") + return "\n".join(lines) + "\n" + + +def parse_artifact_registry_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `gcloud artifacts vulnerabilities list --format=json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + if isinstance(data, dict): + occurrences = ( + data.get("Occurrences") + or data.get("occurrences") + or [] + ) + elif isinstance(data, list): + occurrences = data + else: + occurrences = [] + + findings: list[dict[str, Any]] = [] + for item in occurrences: + if not isinstance(item, dict): + continue + occ = item.get("occurrence", item) + if not isinstance(occ, dict): + continue + vuln = occ.get("vulnerability", {}) + cve_id = vuln.get("shortDescription", "") + if not cve_id.startswith("CVE-"): + continue + vex_state = ( + vuln.get("vexAssessment", {}).get("state", "") + if isinstance(vuln.get("vexAssessment"), dict) + else "" + ) + raw_pi = vuln.get("packageIssue", []) + pi_list = [raw_pi] if isinstance(raw_pi, dict) else (raw_pi if isinstance(raw_pi, list) else []) + for pi in pi_list: + if not isinstance(pi, dict): + continue + findings.append({ + "id": cve_id, + "package": pi.get("affectedPackage", ""), + "package_type": pi.get("packageType", "OS"), + "severity": pi.get( + "effectiveSeverity", vuln.get("effectiveSeverity", "UNKNOWN") + ), + "fix_available": bool(pi.get("fixAvailable", False)), + "vex_state": vex_state, + "source": "artifact_registry", + }) + return findings + + +def parse_trivy_vulns(raw_json: str) -> list[dict[str, Any]]: + """Parses JSON output from `trivy image --format json`.""" + if not raw_json.strip(): + return [] + data = json.loads(raw_json) + findings: list[dict[str, Any]] = [] + for result in data.get("Results", []): + pkg_type = result.get("Class", "os-pkgs") + for vuln in result.get("Vulnerabilities", []) or []: + cve_id = vuln.get("VulnerabilityID", "") + if not cve_id.startswith("CVE-"): + continue + findings.append({ + "id": cve_id, + "package": vuln.get("PkgName", ""), + "package_type": pkg_type, + "severity": vuln.get("Severity", "UNKNOWN"), + "fix_available": bool(vuln.get("FixedVersion")), + "vex_state": "", + "source": "trivy", + }) + return findings + + +def parse_govulncheck_json(raw_stream: str) -> set[str]: + """Extracts reachable Go OSV/CVE IDs reported with symbol-level callstacks by govulncheck.""" + reachable: set[str] = set() + dec = json.JSONDecoder() + idx = 0 + text = raw_stream.strip() + osv_to_cves: dict[str, list[str]] = {} + called_osvs: set[str] = set() + while idx < len(text): + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text): + break + obj, idx = dec.raw_decode(text, idx) + if not isinstance(obj, dict): + continue + if "osv" in obj and isinstance(obj["osv"], dict): + osv_id = obj["osv"].get("id", "") + aliases = [ + a for a in obj["osv"].get("aliases", []) if a.startswith("CVE-") + ] + if osv_id: + osv_to_cves[osv_id] = aliases + if "finding" in obj and isinstance(obj["finding"], dict): + finding = obj["finding"] + trace = finding.get("trace", []) + if trace and any(frame.get("function") for frame in trace): + called_osvs.add(finding.get("osv", "")) + for osv_id in called_osvs: + reachable.update(osv_to_cves.get(osv_id, [])) + return reachable + + +def classify_finding_reachability( + finding: dict[str, Any], + reachable_go_cves: set[str] | None = None, +) -> dict[str, Any]: + """Classifies whether a newly discovered CVE is non-exploitable or requires investigation.""" + pkg = finding.get("package", "") + cve_id = finding["id"] + pkg_type = str(finding.get("package_type", "")).upper() + + if pkg in BUILD_ONLY_OS_PACKAGES: + return { + "id": cve_id, + "package": pkg, + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": BUILD_ONLY_OS_PACKAGES[pkg], + "confidence": "high", + } + + if reachable_go_cves is not None and pkg_type in ("GO", "GOBINARY", "LANG-PKGS"): + if cve_id not in reachable_go_cves: + return { + "id": cve_id, + "package": pkg, + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": ( + f"Verified via govulncheck that {cve_id} in {pkg} has no " + "reachable symbol call path from the container entrypoint." + ), + "confidence": "high", + } + + return { + "id": cve_id, + "package": pkg, + "status": "under_investigation", + "justification": "", + "impact_statement": ( + f"{cve_id} in package {pkg} ({pkg_type}) requires reachability " + "verification or base image / dependency upgrade." + ), + "confidence": "needs_review", + } + + +def invoke_llm_triage( + finding: dict[str, Any], + family_name: str, + family_cfg: dict[str, Any], +) -> dict[str, Any] | None: + """Optionally queries Gemini API when GEMINI_API_KEY is configured for agentic reachability analysis.""" + api_key = os.environ.get("GEMINI_API_KEY", "").strip() + if not api_key: + return None + prompt = ( + "You are a container security reachability analyzer for Apache Beam.\n" + f"Container family: {family_name}\n" + f"Runtime entrypoints: {family_cfg.get('runtime_entrypoints', [])}\n" + f"Vulnerability: {json.dumps(finding)}\n" + "Return ONLY a JSON object with keys: status ('not_affected' or " + "'under_investigation'), justification (one of CISA VEX justifications " + "if not_affected, else ''), and impact_statement (1-2 concise sentences)." + ) + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"gemini-2.5-flash:generateContent?key={api_key}" + ) + payload = json.dumps({ + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"responseMimeType": "application/json"}, + }).encode("utf-8") + req = urllib.request.Request( + url, data=payload, headers={"Content-Type": "application/json"} + ) Review Comment: **🟡 Medium**: `invoke_llm_triage` accepts any `justification` string returned by the LLM without validating that it belongs to `CSAF_JUSTIFICATION_MAP` (or is non-empty when `status == 'not_affected'`). If the LLM returns an invalid or empty justification for a `not_affected` verdict, invalid OpenVEX documents will be generated when `--update-config` writes it back to the YAML. ```suggestion "justification": parsed.get("justification") if parsed.get("justification") in CSAF_JUSTIFICATION_MAP else "vulnerable_code_not_in_execute_path", ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
