diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fab8c77..662de4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,7 +75,10 @@ package across our portfolio. the `repository_url` qualifier; the affected package is named in `subcomponents`. Because OCI PURLs are registry-coupled, list one product entry per distribution registry — typically both - `quay.io/stackstate/` and the Rancher-registry copy. + `quay.io/stackstate/` and the Rancher-registry copy + `registry.rancher.com/suse-observability/`. The + `repository_url` value must be percent-encoded (every `/` as `%2F`) + per the PURL spec; `build_index.py` rejects unencoded values. ### Steps @@ -85,10 +88,18 @@ package across our portfolio. [tools/README.md](./tools/README.md) for command examples. - Lane 1 path: `pkg/maven/org.eclipse.jetty/jetty-http/scan.openvex.json`. - - Lane 2 path: - `pkg/oci/quay.io/stackstate/zookeeper/scan.openvex.json` - (and a sibling under the Rancher-registry path, or a single file - listing both in `products`). + - Lane 2 path (default, single file listing every registry as a + separate product): `pkg/oci//scan.openvex.json`, e.g. + `pkg/oci/zookeeper/scan.openvex.json`. Drop the registry and + namespace segments from the path — they no longer identify the + file once `products` covers multiple registries; the registry + identity lives in each product's `repository_url` qualifier. + - Sibling-file alternative: only when the registry copies need + distinct reasoning, file + `pkg/oci/quay.io/stackstate//scan.openvex.json` and + `pkg/oci/registry.rancher.com/suse-observability//scan.openvex.json` + separately. Avoid this when the assertion is identical across + registries — duplication invites drift. 2. Run `python3 tools/build_index.py` to regenerate `index.json`. CI asserts the on-disk index matches the `pkg/` tree (`tools/build_index.py --check`). diff --git a/index.json b/index.json index 0dc2b3c..1f03e0a 100644 --- a/index.json +++ b/index.json @@ -1,5 +1,5 @@ { - "updated_at": "2026-06-17T07:31:03Z", + "updated_at": "2026-06-18T11:53:07Z", "packages": [ { "id": "pkg:maven/org.eclipse.jetty/jetty-http", @@ -7,13 +7,23 @@ "format": "openvex" }, { - "id": "pkg:oci/opentelemetry-target-allocator", - "location": "pkg/oci/quay.io/stackstate/opentelemetry-target-allocator/scan.openvex.json", + "id": "pkg:oci/opentelemetry-target-allocator?repository_url=quay.io%2Fstackstate%2Fopentelemetry-target-allocator", + "location": "pkg/oci/opentelemetry-target-allocator/scan.openvex.json", "format": "openvex" }, { - "id": "pkg:oci/stackstate-k8s-agent", - "location": "pkg/oci/quay.io/stackstate/stackstate-k8s-agent/scan.openvex.json", + "id": "pkg:oci/opentelemetry-target-allocator?repository_url=registry.rancher.com%2Fsuse-observability%2Fopentelemetry-target-allocator", + "location": "pkg/oci/opentelemetry-target-allocator/scan.openvex.json", + "format": "openvex" + }, + { + "id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "location": "pkg/oci/stackstate-k8s-agent/scan.openvex.json", + "format": "openvex" + }, + { + "id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", + "location": "pkg/oci/stackstate-k8s-agent/scan.openvex.json", "format": "openvex" } ] diff --git a/pkg/oci/quay.io/stackstate/opentelemetry-target-allocator/scan.openvex.json b/pkg/oci/opentelemetry-target-allocator/scan.openvex.json similarity index 65% rename from pkg/oci/quay.io/stackstate/opentelemetry-target-allocator/scan.openvex.json rename to pkg/oci/opentelemetry-target-allocator/scan.openvex.json index 61e8aa7..3256910 100644 --- a/pkg/oci/quay.io/stackstate/opentelemetry-target-allocator/scan.openvex.json +++ b/pkg/oci/opentelemetry-target-allocator/scan.openvex.json @@ -1,6 +1,6 @@ { "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "https://github.com/StackVista/vexhub/pkg/oci/quay.io/stackstate/opentelemetry-target-allocator/docker-engine-server-side-not-affected", + "@id": "https://github.com/StackVista/vexhub/pkg/oci/opentelemetry-target-allocator/docker-engine-server-side-not-affected", "author": "SUSE Observability Security Team", "version": 1, "statements": [ @@ -13,7 +13,15 @@ }, "products": [ { - "@id": "pkg:oci/opentelemetry-target-allocator", + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=quay.io%2Fstackstate%2Fopentelemetry-target-allocator", + "subcomponents": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ] + }, + { + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=registry.rancher.com%2Fsuse-observability%2Fopentelemetry-target-allocator", "subcomponents": [ { "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" @@ -35,7 +43,15 @@ }, "products": [ { - "@id": "pkg:oci/opentelemetry-target-allocator", + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=quay.io%2Fstackstate%2Fopentelemetry-target-allocator", + "subcomponents": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ] + }, + { + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=registry.rancher.com%2Fsuse-observability%2Fopentelemetry-target-allocator", "subcomponents": [ { "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" @@ -57,7 +73,15 @@ }, "products": [ { - "@id": "pkg:oci/opentelemetry-target-allocator", + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=quay.io%2Fstackstate%2Fopentelemetry-target-allocator", + "subcomponents": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ] + }, + { + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=registry.rancher.com%2Fsuse-observability%2Fopentelemetry-target-allocator", "subcomponents": [ { "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" @@ -79,7 +103,15 @@ }, "products": [ { - "@id": "pkg:oci/opentelemetry-target-allocator", + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=quay.io%2Fstackstate%2Fopentelemetry-target-allocator", + "subcomponents": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ] + }, + { + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=registry.rancher.com%2Fsuse-observability%2Fopentelemetry-target-allocator", "subcomponents": [ { "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" @@ -91,6 +123,36 @@ "justification": "vulnerable_code_not_in_execute_path", "status_notes": "Review by 2026-07-29 (6 weeks after 2026-06-17): re-check whether the next prometheus-operator release has dropped the legacy github.com/docker/docker dependency and the OpenTelemetry Operator has bumped to it; retire this statement once the dependency is gone.", "impact_statement": "CVE-2026-33997 is an off-by-one error in the Moby server's plugin privilege validation during docker plugin install: the daemon's privilege-set comparison can accept a privilege set that differs from the one approved by the user, and plugins requesting exactly one privilege are not compared at all. The vulnerable code lives in the Docker Engine server (the plugin install/privilege validation path in the daemon). The quay.io/stackstate/opentelemetry-target-allocator image ships only the targetallocator Go binary; it contains no dockerd, executes no docker plugin install flow, and is not invoked as a Docker daemon. github.com/docker/docker is pulled in transitively through github.com/prometheus/prometheus/discovery, and per upstream open-telemetry/opentelemetry-operator#4926 only the client-side packages are used: \"It only uses the client side of the docker package, whereas the vulnerabilities affect the server side.\" The fix lives at the new github.com/moby/moby/v2 module path (Docker Engine 29.3.1 / v2.0.0-beta.8); Prometheus has migrated in prometheus/prometheus#18433, and once the next prometheus-operator release picks up that Prometheus version and the OpenTelemetry Operator bumps to it, the docker/docker dependency will disappear from the target allocator entirely and this VEX will become moot." + }, + { + "vulnerability": { + "name": "CVE-2026-41568", + "aliases": [ + "GHSA-vp62-88p7-qqf5" + ] + }, + "products": [ + { + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=quay.io%2Fstackstate%2Fopentelemetry-target-allocator", + "subcomponents": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ] + }, + { + "@id": "pkg:oci/opentelemetry-target-allocator?repository_url=registry.rancher.com%2Fsuse-observability%2Fopentelemetry-target-allocator", + "subcomponents": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ] + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "status_notes": "Review by 2026-07-29 (6 weeks after 2026-06-17): re-check whether the next prometheus-operator release has dropped the legacy github.com/docker/docker dependency and the OpenTelemetry Operator has bumped to it; retire this statement once the dependency is gone.", + "impact_statement": "CVE-2026-41568 is a TOCTOU symlink race in the Moby server's docker cp mountpoint setup: between GetResourcePath resolving the in-container destination and createIfNotExists materialising it via os.MkdirAll/os.OpenFile, a container process can swap a path component for a symlink, causing the daemon (running as host root) to create an empty file or directory at an arbitrary absolute host path. The vulnerable code lives in the Docker Engine server (daemon/archive.go and the docker cp mountpoint setup path), classified as CWE-61 / CWE-367. The quay.io/stackstate/opentelemetry-target-allocator image ships only the targetallocator Go binary; it contains no dockerd, performs no docker cp mountpoint setup, and is not invoked as a Docker daemon. github.com/docker/docker is pulled in transitively through github.com/prometheus/prometheus/discovery, and per upstream open-telemetry/opentelemetry-operator#4926 only the client-side packages are used: \"It only uses the client side of the docker package, whereas the vulnerabilities affect the server side.\" The fix lives at the new github.com/moby/moby/v2 module path (Docker Engine 29.5.1 / v2.0.0-beta.14); Prometheus has migrated in prometheus/prometheus#18433, and once the next prometheus-operator release picks up that Prometheus version and the OpenTelemetry Operator bumps to it, the docker/docker dependency will disappear from the target allocator entirely and this VEX will become moot." } ], "timestamp": "2026-06-17T00:00:00Z" diff --git a/pkg/oci/quay.io/stackstate/stackstate-k8s-agent/scan.openvex.json b/pkg/oci/stackstate-k8s-agent/scan.openvex.json similarity index 85% rename from pkg/oci/quay.io/stackstate/stackstate-k8s-agent/scan.openvex.json rename to pkg/oci/stackstate-k8s-agent/scan.openvex.json index 2aa4f9b..667ddf7 100644 --- a/pkg/oci/quay.io/stackstate/stackstate-k8s-agent/scan.openvex.json +++ b/pkg/oci/stackstate-k8s-agent/scan.openvex.json @@ -1,6 +1,6 @@ { "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "https://github.com/StackVista/vexhub/pkg/oci/quay.io/stackstate/stackstate-k8s-agent/CVE-2026-6100", + "@id": "https://github.com/StackVista/vexhub/pkg/oci/stackstate-k8s-agent/CVE-2026-6100", "author": "SUSE Observability Security Team", "version": 1, "statements": [ @@ -10,7 +10,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -30,7 +38,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -51,7 +67,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -68,7 +92,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -86,7 +118,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -104,7 +144,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -121,7 +169,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -139,7 +195,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -157,7 +221,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -175,7 +247,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -194,7 +274,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -215,7 +303,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" @@ -234,7 +330,15 @@ }, "products": [ { - "@id": "pkg:oci/stackstate-k8s-agent", + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=quay.io%2Fstackstate%2Fstackstate-k8s-agent", + "subcomponents": [ + { + "@id": "pkg:generic/python@3.13.13" + } + ] + }, + { + "@id": "pkg:oci/stackstate-k8s-agent?repository_url=registry.rancher.com%2Fsuse-observability%2Fstackstate-k8s-agent", "subcomponents": [ { "@id": "pkg:generic/python@3.13.13" diff --git a/tools/build_index.py b/tools/build_index.py index 004974e..3427a43 100644 --- a/tools/build_index.py +++ b/tools/build_index.py @@ -15,22 +15,109 @@ import argparse import json +import re import sys from datetime import datetime, timezone from pathlib import Path +from urllib.parse import quote, unquote def now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -def index_id_for_purl(purl: str) -> str: - """Return the canonical index id for a PURL: version and qualifiers stripped.""" +_QUALIFIER_KEY_RE = re.compile(r"^[a-z_][a-z0-9._-]*$") + + +def _where(source: Path | None) -> str: + return f" in {source}" if source else "" + + +def _ensure_percent_encoded(value: str, key: str, purl: str, source: Path | None) -> None: + """Reject qualifier values that are not fully percent-encoded. + + PURL qualifier values must percent-encode anything outside the RFC 3986 + unreserved set (``[A-Za-z0-9-._~]``). For ``repository_url`` that means + every ``/`` must appear as ``%2F`` (and every ``:`` in a non-default + port as ``%3A``). We normalise by decoding then re-encoding with + ``quote(..., safe="")``; if the result differs from the input (case + insensitively, since ``%2f`` and ``%2F`` are equivalent), the input + was not properly encoded. + """ + canonical = quote(unquote(value), safe="") + if canonical.lower() != value.lower(): + sys.exit( + f"PURL qualifier {key}={value!r} in {purl!r}{_where(source)} " + f"is not properly percent-encoded. Expected {key}={canonical}." + ) + + +def _parse_purl(purl: str, source: Path | None) -> tuple[str, dict[str, str]]: + """Parse a PURL into (head, qualifiers). + + ``head`` is ``pkg://`` with version and subpath + stripped. ``qualifiers`` preserves the original (validated) encoding so + callers can re-emit byte-identical strings. + + Grammar enforced: ``pkg:type/namespace/name@version?qualifiers#subpath`` + with qualifier values percent-encoded per RFC 3986. + """ + if not purl.startswith("pkg:"): + sys.exit(f"{purl!r}{_where(source)} is not a PURL (must start with 'pkg:').") head = purl + # Subpath (#...) and qualifiers (?...) come after version; strip both + # before splitting on '@' so version sweeps don't accidentally cross a + # qualifier/subpath boundary. + if "#" in head: + head, _ = head.split("#", 1) + qualifiers: dict[str, str] = {} if "?" in head: - head, _ = head.split("?", 1) + head, qual_str = head.split("?", 1) + for pair in qual_str.split("&"): + if "=" not in pair: + sys.exit( + f"PURL qualifier {pair!r} in {purl!r}{_where(source)} " + "is malformed (expected key=value)." + ) + key, value = pair.split("=", 1) + if not _QUALIFIER_KEY_RE.fullmatch(key): + sys.exit( + f"PURL qualifier key {key!r} in {purl!r}{_where(source)} " + "is invalid (must be lowercase ASCII identifier)." + ) + _ensure_percent_encoded(value, key, purl, source) + qualifiers[key] = value if "@" in head: head, _ = head.split("@", 1) + return head, qualifiers + + +def index_id_for_purl(purl: str, source: Path | None = None) -> str: + """Return the canonical index id for a PURL. + + Per the VEX Repository Specification, version, subpath, and qualifiers + are stripped from the index id; for ``pkg:oci/*`` PURLs the + ``repository_url`` qualifier MUST be preserved (and must be + percent-encoded) so Trivy can match the entry against the image PURL it + generates at scan time. + """ + head, qualifiers = _parse_purl(purl, source) + if head.startswith("pkg:oci/"): + if "repository_url" not in qualifiers: + sys.exit( + f"OCI PURL {purl!r}{_where(source)} is missing the required " + "'repository_url' qualifier. Per the VEX Repository " + "Specification, OCI product @id values must include " + "?repository_url=// with " + "slashes percent-encoded as %2F." + ) + repo = qualifiers["repository_url"] + if not repo: + sys.exit( + f"OCI PURL {purl!r}{_where(source)} has an empty " + "'repository_url' qualifier." + ) + return f"{head}?repository_url={repo}" return head @@ -54,7 +141,7 @@ def collect_packages(hub_root: Path) -> list[dict]: if pid and pid.startswith("pkg:"): purls.add(pid) for purl in sorted(purls): - pid = index_id_for_purl(purl) + pid = index_id_for_purl(purl, source=vex_file) existing = entries.get(pid) if existing and existing["location"] != rel_location: sys.exit(