Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions scanner_py/cli/retrieve_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, "
Expand Down
15 changes: 14 additions & 1 deletion scanner_py/core/attestation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand All @@ -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 []

Expand All @@ -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 []

Expand Down
56 changes: 56 additions & 0 deletions scanner_py/core/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading