diff --git a/scanner_py/cli/retrieve_triage.py b/scanner_py/cli/retrieve_triage.py index db56961..c5c2dd5 100644 --- a/scanner_py/cli/retrieve_triage.py +++ b/scanner_py/cli/retrieve_triage.py @@ -55,6 +55,7 @@ def fetch_triage_for_image( Returns True if triage was fetched and saved. """ from .oras_scan import find_triage_reference, fetch_triage_toml + from ..core.cache import get_registry_capability_cache dir_name = sanitize_image_dir(image) image_dir = output_dir / dir_name @@ -72,15 +73,23 @@ def fetch_triage_for_image( logger.debug(f"Fetched triage via tag-based attestation for {image}") return True - # 3) Legacy ORAS triage.toml — skip if referrers API already timed out, - # since this also uses oras discover. - triage_ref = find_triage_reference(image, timeout=timeout, verbose=verbose) - if triage_ref: - triage_toml = image_dir / "triage.toml" - manifest_file = image_dir / "manifest.json" - if fetch_triage_toml(image, triage_ref, str(triage_toml), str(manifest_file), timeout=timeout): - logger.debug(f"Fetched triage via ORAS triage.toml for {image}") - return True + # 3) Legacy ORAS triage.toml — also uses `oras discover`, so skip it entirely + # if the registry already proved it has no Referrers API (e.g. JFrog). + # Otherwise oras discover would hang for the full timeout (twice, counting + # the --plain-http retry) for every image with no tag-based attestation. + if get_registry_capability_cache().referrers_unsupported(image): + logger.debug( + f"Skipping legacy ORAS triage.toml for {image} — " + f"registry has no Referrers API (oras discover would time out)" + ) + else: + triage_ref = find_triage_reference(image, timeout=timeout, verbose=verbose) + if triage_ref: + triage_toml = image_dir / "triage.toml" + manifest_file = image_dir / "manifest.json" + if fetch_triage_toml(image, triage_ref, str(triage_toml), str(manifest_file), timeout=timeout): + logger.debug(f"Fetched triage via ORAS triage.toml for {image}") + return True logger.debug( f"No triage found for {image}: tried Referrers API, tag-based attestation, " diff --git a/scanner_py/core/attestation.py b/scanner_py/core/attestation.py index a580025..ec33027 100644 --- a/scanner_py/core/attestation.py +++ b/scanner_py/core/attestation.py @@ -19,7 +19,7 @@ from ..utils.subprocess import run_command, run_with_timeout from ..utils.logging import get_logger, is_verbose -from .cache import get_digest_cache +from .cache import get_digest_cache, get_registry_capability_cache logger = get_logger(__name__) @@ -343,7 +343,18 @@ def _discover_referrers(self, image: str, image_digest: Optional[str] = None) -> Returns an empty list if the registry doesn't support the Referrers API (timeout or error). Callers should use extract_triage_tag_based() as fallback. + + Registries that time out once are remembered (per host) so every other + image on the same registry skips the slow probe instead of re-paying the + timeout. This is the common case for JFrog, which has no Referrers API. """ + capabilities = get_registry_capability_cache() + if capabilities.referrers_unsupported(image): + logger.debug( + f"Skipping Referrers API for {image} — registry already known to not support it" + ) + return [] + fast_timeout = min(self.timeout, 15) if image_digest: @@ -361,6 +372,7 @@ def _discover_referrers(self, image: str, image_digest: Optional[str] = None) -> pass if result.timed_out: + capabilities.mark_referrers_unsupported(image) logger.debug(f"Referrers API timed out for {image} — registry may not support it") return [] @@ -372,6 +384,7 @@ def _discover_referrers(self, image: str, image_digest: Optional[str] = None) -> ) if not result.success: if result.timed_out: + capabilities.mark_referrers_unsupported(image) logger.debug(f"Referrers API timed out for {image} — registry may not support it") return [] diff --git a/scanner_py/core/cache.py b/scanner_py/core/cache.py index 5bd8d62..5b01b30 100644 --- a/scanner_py/core/cache.py +++ b/scanner_py/core/cache.py @@ -102,6 +102,62 @@ def reset_digest_cache() -> None: _digest_cache = None +class RegistryCapabilityCache: + """ + Thread-safe in-memory cache of per-registry capabilities. + + Some registries (notably JFrog Artifactory remote/virtual repos) do not + implement the OCI Referrers API. `oras discover` against them does not fail + fast — it hangs until the timeout — so probing every image on such a registry + wastes the full timeout per image. Once we observe a timeout for a registry + host we remember it and skip referrer discovery for the rest of the run, + falling straight through to tag-based cosign attestation. + """ + + def __init__(self): + self._referrers_unsupported: set = set() + self._lock = threading.Lock() + + @staticmethod + def _host(image: str) -> str: + """Registry hostname for an image reference.""" + from ..utils.registry import RegistryChecker + return RegistryChecker.extract_registry(image) + + def referrers_unsupported(self, image: str) -> bool: + """True if the image's registry is known to not support the Referrers API.""" + host = self._host(image) + with self._lock: + return host in self._referrers_unsupported + + def mark_referrers_unsupported(self, image: str) -> None: + """Record that the image's registry does not support the Referrers API.""" + host = self._host(image) + with self._lock: + self._referrers_unsupported.add(host) + + +# Global registry capability cache instance for use across modules +_registry_capability_cache: Optional[RegistryCapabilityCache] = None +_registry_capability_cache_lock = threading.Lock() + + +def get_registry_capability_cache() -> RegistryCapabilityCache: + """Get the global registry capability cache instance.""" + global _registry_capability_cache + with _registry_capability_cache_lock: + if _registry_capability_cache is None: + _registry_capability_cache = RegistryCapabilityCache() + return _registry_capability_cache + + +def reset_registry_capability_cache() -> None: + """Reset the global registry capability cache (useful for testing).""" + global _registry_capability_cache + with _registry_capability_cache_lock: + _registry_capability_cache = None + + @dataclass class CacheStats: """Cache statistics."""