diff --git a/api/bazel/external_deps.bzl b/api/bazel/external_deps.bzl index 2d1498cb626b4..9837c5aeeabc9 100644 --- a/api/bazel/external_deps.bzl +++ b/api/bazel/external_deps.bzl @@ -14,7 +14,10 @@ DEPENDENCY_ANNOTATIONS = [ "extensions", # Additional dependencies loaded transitively via this dependency that are not tracked in - # Envoy (see the external dependency at the given version for information). + # Envoy (see the external dependency at the given version for information). Names listed + # here carry no metadata of their own; a transitive dependency that *is* tracked instead + # gets its own top-level entry with a used_by list, and must not appear in any + # implied_untracked_deps list. "implied_untracked_deps", # Project metadata. @@ -35,6 +38,16 @@ DEPENDENCY_ANNOTATIONS = [ # This attribute is mandatory for each dependecy. "use_category", + # For a dependency that is only reached transitively (it is not a direct + # dependency), the list of tracked dependencies through which it is loaded. + # Such entries are tracked in their own right — they carry their own + # metadata (cpe, use_category, extensions, ...) — as opposed to + # implied_untracked_deps, which declares transitives we carry no metadata + # for. A name must never appear both as a top-level entry and in any + # implied_untracked_deps list, and every name listed in used_by must be a + # top-level metadata key. + "used_by", + # The dependency version. This may be either a tagged release (preferred) # or git SHA (as an exception when no release tagged version is suitable). "version", diff --git a/bazel/BUILD b/bazel/BUILD index 2e7d1f9b28635..4f63df2edc3bb 100644 --- a/bazel/BUILD +++ b/bazel/BUILD @@ -964,16 +964,21 @@ json_data( json_merge( name = "legacy_all_repository_locations", srcs = [":legacy_all_repository_locations_version_info"], + # Union of version-carrying deps and metadata-only entries: tracked + # transitive deps (annotated with `used_by`) have no entry in + # repository_locations.bzl but must still surface in the merged output so + # that their metadata (cpe, use_category, ...) reaches validation and CVE + # scanning. filter = """ .[0] as $versions | .[1] as $metadata1 | .[2] as $metadata2 | ($metadata1 * $metadata2) as $metadata - | $versions + | ($versions + $metadata) | keys | reduce .[] as $k ({}; . + {($k): ( - ($versions[$k] + ($metadata[$k] // {})) + ((($versions[$k] // {}) + ($metadata[$k] // {}))) | .version as $ver | if .license_url and $ver then .license_url |= ( diff --git a/tools/dependency/cve_utils.jq b/tools/dependency/cve_utils.jq index c46cb65d1331e..ae036dcf77fbe 100644 --- a/tools/dependency/cve_utils.jq +++ b/tools/dependency/cve_utils.jq @@ -106,23 +106,32 @@ def get_severity(metrics): ## def parse_deps(deps): + # A dep carrying a cpe but lacking a resolvable version cannot be + # meaningfully matched against version-ranged CVEs - a silent skip would + # make false negatives look identical to "clean", so it is a hard failure. . | deps | with_entries( select(.value.cpe != null and .value.cpe != "N/A") + | .key as $name | .value - |= parse_cpe_tag(.cpe) as $cc - | { - release_date, - version: Version::parse(.version), - cpe: { - match: .cpe, - part: $cc.part, - vendor: $cc.vendor, - product: $cc.product, - version: $cc.version + |= (Version::parse(.version) as $version + | if $version == null then + error("Dependency '\($name)' declares a cpe but has no resolvable version, CVE matching is not possible") + else . end + | parse_cpe_tag(.cpe) as $cc + | { + release_date, + version: $version, + cpe: { + match: .cpe, + part: $cc.part, + vendor: $cc.vendor, + product: $cc.product, + version: $cc.version + } } - } + ) ); def iterate_cves(cves; deps; ignored): diff --git a/tools/dependency/validate_reachability_test.py b/tools/dependency/validate_reachability_test.py index 438e8c6f65066..5a425435558e8 100644 --- a/tools/dependency/validate_reachability_test.py +++ b/tools/dependency/validate_reachability_test.py @@ -199,6 +199,39 @@ def check_apparent_name_uniqueness(metadata): return errors +def check_tracked_implied_disjointness(metadata): + """Verify tracked entries and implied_untracked_deps names are disjoint. + + A dependency name is either tracked (a top-level metadata entry, possibly + reached only transitively and annotated with ``used_by``) or untracked + (listed in some entry's ``implied_untracked_deps``), never both. The + metadata must not be able to express the ambiguity. + + Rules: + - A name in any ``implied_untracked_deps`` list MUST NOT be a top-level + metadata key. + - A name in any ``used_by`` list MUST be a top-level metadata key. + + Returns a list of error strings (empty when the sets are disjoint and all + ``used_by`` references resolve). + """ + errors = [] + for key, meta in metadata.items(): + for untracked in meta.get("implied_untracked_deps", []): + if untracked in metadata: + errors.append( + "%r is a tracked entry - remove it from %r's implied_untracked_deps" + % (untracked, key) + ) + for parent in meta.get("used_by", []): + if parent not in metadata: + errors.append( + "%r lists %r in used_by but %r is not a top-level metadata key" + % (key, parent, parent) + ) + return errors + + # --------------------------------------------------------------------------- # Validation logic (mirrors validate.py check-by-check) # --------------------------------------------------------------------------- @@ -435,6 +468,14 @@ def test_apparent_name_uniqueness(self): "apparent_name uniqueness violations:\n" + "\n".join(errors) ) + def test_tracked_implied_disjointness(self): + errors = check_tracked_implied_disjointness(self.metadata) + if errors: + raise AssertionError( + "tracked/implied_untracked_deps disjointness violations:\n" + + "\n".join(errors) + ) + def test_dep_names_resolved(self): validate_dep_names_resolved( self.deps, self.metadata, self.apparent_lookup, self.revmap