Skip to content

Commit 7a50e8c

Browse files
author
Tobias Pfaffelmoser
authored
fix(triage): cache Referrers-API support per registry to skip slow oras discover (#112)
JFrog has no OCI Referrers API, so `oras discover` does not fail fast — it hangs until the timeout — making retrieve-triage pay the full ~15s probe per image, plus up to ~360s more per untriaged image via the legacy triage.toml path (which also calls oras discover despite a comment saying it should be skipped once the Referrers API has timed out). Add a process-wide RegistryCapabilityCache: the first Referrers-API timeout marks that registry host unsupported, and every later image on the same host skips both oras-discover probes and falls straight through to tag-based cosign attestation. On a 41-image JFrog list this drops oras discover calls from 41 to 1 and the run from ~14m to ~4m, with no change in which images get triage.
1 parent fca0b17 commit 7a50e8c

3 files changed

Lines changed: 88 additions & 10 deletions

File tree

scanner_py/cli/retrieve_triage.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def fetch_triage_for_image(
5555
Returns True if triage was fetched and saved.
5656
"""
5757
from .oras_scan import find_triage_reference, fetch_triage_toml
58+
from ..core.cache import get_registry_capability_cache
5859

5960
dir_name = sanitize_image_dir(image)
6061
image_dir = output_dir / dir_name
@@ -72,15 +73,23 @@ def fetch_triage_for_image(
7273
logger.debug(f"Fetched triage via tag-based attestation for {image}")
7374
return True
7475

75-
# 3) Legacy ORAS triage.toml — skip if referrers API already timed out,
76-
# since this also uses oras discover.
77-
triage_ref = find_triage_reference(image, timeout=timeout, verbose=verbose)
78-
if triage_ref:
79-
triage_toml = image_dir / "triage.toml"
80-
manifest_file = image_dir / "manifest.json"
81-
if fetch_triage_toml(image, triage_ref, str(triage_toml), str(manifest_file), timeout=timeout):
82-
logger.debug(f"Fetched triage via ORAS triage.toml for {image}")
83-
return True
76+
# 3) Legacy ORAS triage.toml — also uses `oras discover`, so skip it entirely
77+
# if the registry already proved it has no Referrers API (e.g. JFrog).
78+
# Otherwise oras discover would hang for the full timeout (twice, counting
79+
# the --plain-http retry) for every image with no tag-based attestation.
80+
if get_registry_capability_cache().referrers_unsupported(image):
81+
logger.debug(
82+
f"Skipping legacy ORAS triage.toml for {image} — "
83+
f"registry has no Referrers API (oras discover would time out)"
84+
)
85+
else:
86+
triage_ref = find_triage_reference(image, timeout=timeout, verbose=verbose)
87+
if triage_ref:
88+
triage_toml = image_dir / "triage.toml"
89+
manifest_file = image_dir / "manifest.json"
90+
if fetch_triage_toml(image, triage_ref, str(triage_toml), str(manifest_file), timeout=timeout):
91+
logger.debug(f"Fetched triage via ORAS triage.toml for {image}")
92+
return True
8493

8594
logger.debug(
8695
f"No triage found for {image}: tried Referrers API, tag-based attestation, "

scanner_py/core/attestation.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from ..utils.subprocess import run_command, run_with_timeout
2121
from ..utils.logging import get_logger, is_verbose
22-
from .cache import get_digest_cache
22+
from .cache import get_digest_cache, get_registry_capability_cache
2323

2424
logger = get_logger(__name__)
2525

@@ -343,7 +343,18 @@ def _discover_referrers(self, image: str, image_digest: Optional[str] = None) ->
343343
344344
Returns an empty list if the registry doesn't support the Referrers API
345345
(timeout or error). Callers should use extract_triage_tag_based() as fallback.
346+
347+
Registries that time out once are remembered (per host) so every other
348+
image on the same registry skips the slow probe instead of re-paying the
349+
timeout. This is the common case for JFrog, which has no Referrers API.
346350
"""
351+
capabilities = get_registry_capability_cache()
352+
if capabilities.referrers_unsupported(image):
353+
logger.debug(
354+
f"Skipping Referrers API for {image} — registry already known to not support it"
355+
)
356+
return []
357+
347358
fast_timeout = min(self.timeout, 15)
348359

349360
if image_digest:
@@ -361,6 +372,7 @@ def _discover_referrers(self, image: str, image_digest: Optional[str] = None) ->
361372
pass
362373

363374
if result.timed_out:
375+
capabilities.mark_referrers_unsupported(image)
364376
logger.debug(f"Referrers API timed out for {image} — registry may not support it")
365377
return []
366378

@@ -372,6 +384,7 @@ def _discover_referrers(self, image: str, image_digest: Optional[str] = None) ->
372384
)
373385
if not result.success:
374386
if result.timed_out:
387+
capabilities.mark_referrers_unsupported(image)
375388
logger.debug(f"Referrers API timed out for {image} — registry may not support it")
376389
return []
377390

scanner_py/core/cache.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,62 @@ def reset_digest_cache() -> None:
102102
_digest_cache = None
103103

104104

105+
class RegistryCapabilityCache:
106+
"""
107+
Thread-safe in-memory cache of per-registry capabilities.
108+
109+
Some registries (notably JFrog Artifactory remote/virtual repos) do not
110+
implement the OCI Referrers API. `oras discover` against them does not fail
111+
fast — it hangs until the timeout — so probing every image on such a registry
112+
wastes the full timeout per image. Once we observe a timeout for a registry
113+
host we remember it and skip referrer discovery for the rest of the run,
114+
falling straight through to tag-based cosign attestation.
115+
"""
116+
117+
def __init__(self):
118+
self._referrers_unsupported: set = set()
119+
self._lock = threading.Lock()
120+
121+
@staticmethod
122+
def _host(image: str) -> str:
123+
"""Registry hostname for an image reference."""
124+
from ..utils.registry import RegistryChecker
125+
return RegistryChecker.extract_registry(image)
126+
127+
def referrers_unsupported(self, image: str) -> bool:
128+
"""True if the image's registry is known to not support the Referrers API."""
129+
host = self._host(image)
130+
with self._lock:
131+
return host in self._referrers_unsupported
132+
133+
def mark_referrers_unsupported(self, image: str) -> None:
134+
"""Record that the image's registry does not support the Referrers API."""
135+
host = self._host(image)
136+
with self._lock:
137+
self._referrers_unsupported.add(host)
138+
139+
140+
# Global registry capability cache instance for use across modules
141+
_registry_capability_cache: Optional[RegistryCapabilityCache] = None
142+
_registry_capability_cache_lock = threading.Lock()
143+
144+
145+
def get_registry_capability_cache() -> RegistryCapabilityCache:
146+
"""Get the global registry capability cache instance."""
147+
global _registry_capability_cache
148+
with _registry_capability_cache_lock:
149+
if _registry_capability_cache is None:
150+
_registry_capability_cache = RegistryCapabilityCache()
151+
return _registry_capability_cache
152+
153+
154+
def reset_registry_capability_cache() -> None:
155+
"""Reset the global registry capability cache (useful for testing)."""
156+
global _registry_capability_cache
157+
with _registry_capability_cache_lock:
158+
_registry_capability_cache = None
159+
160+
105161
@dataclass
106162
class CacheStats:
107163
"""Cache statistics."""

0 commit comments

Comments
 (0)