From f942088c4d256a510b8d1f85683b6080643cd012 Mon Sep 17 00:00:00 2001 From: worker-4-b Date: Sun, 28 Jun 2026 07:59:22 +0000 Subject: [PATCH 1/5] feat(vuln): cache_info in output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #30 — expose OSV cache freshness in vuln-scan JSON output. - OSVCache.peek(key): pure inspection returning (timestamp, ttl) without mutating the cache (unlike get(), which deletes expired entries). - OSVClient.get_cache_info(packages, max_age=None): returns {last_refresh, age_hours, ttl_hours, is_stale, stale_packages}. is_stale when any cached entry is past the effective TTL, the newest entry's age >= TTL, or there are OSV-queriable packages but none cached. - vulnscan_engine.scan_vulnerabilities: attach cache_info to the result dict (additive — no existing output fields changed). Static cache_info blocks are surfaced on the no-packages and OSV-failure paths so consumers can rely on the shape. Network calls unchanged: cache_info only inspects the local SQLite cache. --- scripts/osv_client.py | 113 +++++++++++++++++++++++++++++++++++++ scripts/vulnscan_engine.py | 29 +++++++++- 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/scripts/osv_client.py b/scripts/osv_client.py index fa702484..54fef1c1 100644 --- a/scripts/osv_client.py +++ b/scripts/osv_client.py @@ -27,6 +27,7 @@ from dataclasses import dataclass, field, asdict from typing import Dict, List, Any, Optional, Set, Tuple from collections import defaultdict +from datetime import datetime, timezone from pathlib import Path try: @@ -348,6 +349,33 @@ def get_stats(self) -> Dict[str, Any]: finally: conn.close() + def peek(self, key: str) -> Optional[Tuple[float, int]]: + """Return ``(timestamp, ttl)`` for a cache key WITHOUT mutating the cache. + + Unlike :meth:`get`, this is a pure inspection — it never deletes + expired or corrupt entries. Used by staleness reporting (issue #30) + so we can describe cache freshness without side effects. + + Args: + key: Cache key (e.g., "npm|lodash|4.17.15") + + Returns: + ``(timestamp, ttl)`` tuple if the key exists, else ``None``. + """ + with self._lock: + conn = sqlite3.connect(self.db_path) + try: + cursor = conn.execute( + "SELECT timestamp, ttl FROM cache WHERE package_ecosystem_version = ?", + (key,) + ) + row = cursor.fetchone() + if row is None: + return None + return (float(row[0]), int(row[1])) + finally: + conn.close() + # ─── Rate Limiter ────────────────────────────────────────────── @@ -520,6 +548,91 @@ def batch_query( """ return self.query_packages(packages) + def get_cache_info( + self, + packages: List[OSVPackage], + max_age: Optional[int] = None, + ) -> Dict[str, Any]: + """Return OSV cache freshness info for ``packages`` (issue #30). + + Inspects the cache WITHOUT mutating it (uses :meth:`OSVCache.peek`). + Intended to be attached to ``vuln-scan`` output as ``cache_info`` so + agents can decide whether to trust the cached CVE data or re-scan. + + Args: + packages: The OSV packages that were (or would be) queried. + max_age: Optional per-run TTL override in seconds. When set, the + effective TTL is ``max_age`` instead of the configured cache + TTL — entries older than this count as stale for this run. + ``None`` means use the configured cache TTL. + + Returns: + Dict with:: + + { + "last_refresh": "2026-06-28T10:00:00Z" | None, + "age_hours": 23.5 | None, # hours since last_refresh + "ttl_hours": 24.0, # effective TTL in hours + "is_stale": bool, + "stale_packages": ["requests@2.20.0", ...] + } + + ``last_refresh`` / ``age_hours`` are ``None`` when none of the + packages are cached. ``is_stale`` is True when any cached entry + is past the effective TTL, OR the newest entry's age >= the + effective TTL, OR there are OSV-queriable packages but none are + cached (no fresh coverage). + """ + now = time.time() + configured_ttl = self.cache.ttl + effective_ttl = ( + max_age if (max_age is not None and max_age > 0) else configured_ttl + ) + ttl_hours = round(effective_ttl / 3600.0, 2) + + timestamps: List[float] = [] + stale_packages: List[str] = [] + seen: Set[str] = set() + + for pkg in packages: + if pkg.ecosystem is None: + continue # Not supported by OSV — never cached + key = pkg.cache_key() + if key in seen: + continue + seen.add(key) + + peek = self.cache.peek(key) + if peek is None: + continue # Not cached yet + ts, _stored_ttl = peek + timestamps.append(ts) + if (now - ts) > effective_ttl: + stale_packages.append(f"{pkg.name}@{pkg.version}") + + if timestamps: + last_refresh_ts = max(timestamps) + last_refresh = datetime.fromtimestamp( + last_refresh_ts, tz=timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ") + age_raw = (now - last_refresh_ts) / 3600.0 + age_hours = round(age_raw, 2) + is_stale = bool(stale_packages) or age_raw >= (effective_ttl / 3600.0) + else: + last_refresh = None + age_hours = None + # OSV-queriable packages exist but none are cached → no fresh + # coverage, so the data is effectively stale. + is_stale = len(seen) > 0 + + return { + "last_refresh": last_refresh, + "age_hours": age_hours, + "ttl_hours": ttl_hours, + "is_stale": is_stale, + "stale_packages": stale_packages, + } + def _batch_query_api( self, packages: List[OSVPackage], diff --git a/scripts/vulnscan_engine.py b/scripts/vulnscan_engine.py index 3402b4af..da5d3c3d 100755 --- a/scripts/vulnscan_engine.py +++ b/scripts/vulnscan_engine.py @@ -524,7 +524,10 @@ def scan_vulnerabilities( osv_ttl: Cache TTL for OSV results in seconds (default 86400 = 24h) Returns: - Dict with findings, stats, risk level, audit availability, and recommendations + Dict with findings, stats, risk level, audit availability, + recommendations, and a ``cache_info`` block (issue #30) describing + OSV cache freshness (``last_refresh``, ``age_hours``, ``ttl_hours``, + ``is_stale``, ``stale_packages``). """ workspace = os.path.abspath(workspace) @@ -548,6 +551,7 @@ def scan_vulnerabilities( ignore_packages: Set[str] = set() skip_audit: bool = False osv_stats: Optional[Dict[str, Any]] = None + cache_info: Optional[Dict[str, Any]] = None # Parse config if config: @@ -584,13 +588,35 @@ def scan_vulnerabilities( } logger.info("OSV.dev: queried %d packages, found %d vulnerabilities", len(osv_packages), len(osv_findings)) + + # Issue #30: cache freshness info (computed AFTER the query so + # it reflects the post-query state — any package just fetched + # or refreshed is now fresh). + cache_info = osv_client.get_cache_info(osv_packages) else: osv_stats = {"packages_queried": 0, "vulnerabilities_found": 0} logger.debug("OSV.dev: no packages to query") + # No packages → no cache entries to inspect. Still surface a + # cache_info block so consumers can rely on the shape. + cache_info = { + "last_refresh": None, + "age_hours": None, + "ttl_hours": round(osv_ttl / 3600.0, 2), + "is_stale": False, + "stale_packages": [], + } except Exception as exc: logger.warning("OSV.dev integration failed, continuing with native audit: %s", exc) osv_stats = {"error": str(exc)} + cache_info = { + "last_refresh": None, + "age_hours": None, + "ttl_hours": round(osv_ttl / 3600.0, 2), + "is_stale": False, + "stale_packages": [], + "error": str(exc), + } else: logger.debug("OSV.dev client not available (osv_client.py not importable)") @@ -717,6 +743,7 @@ def scan_vulnerabilities( "findings": findings[:200], # Cap to avoid explosion "audit_available": any_audit_available, "osv_stats": osv_stats, + "cache_info": cache_info, "recommendations": recommendations, } From 82b94ed4862ca15a18ed0d92f32c9c9d14fee87a Mon Sep 17 00:00:00 2001 From: worker-4-b Date: Sun, 28 Jun 2026 08:02:24 +0000 Subject: [PATCH 2/5] feat(vuln): --refresh flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #30 — 'codelens vuln-scan --refresh' bypasses the OSV cache and forces fresh OSV.dev API calls for every package, then updates the cache with the new results. - OSVClient.query_packages(packages, force_refresh=False): when True (and not offline), skip cache reads and route all packages to _batch_query_api, which already caches each response via cache.set. No-op in offline mode. - vulnscan_engine.scan_vulnerabilities(..., refresh=False): passes through as force_refresh to the OSV client. - vuln-scan CLI: new --refresh flag (store_true). Default behaviour unchanged: without --refresh the cache is consulted first and only uncached packages hit the API. --- scripts/commands/vuln_scan.py | 5 +++++ scripts/osv_client.py | 22 +++++++++++++++++++--- scripts/vulnscan_engine.py | 8 +++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/commands/vuln_scan.py b/scripts/commands/vuln_scan.py index 96fb0066..b163e81a 100644 --- a/scripts/commands/vuln_scan.py +++ b/scripts/commands/vuln_scan.py @@ -13,6 +13,10 @@ def add_args(parser): help="Skip OSV.dev API queries (use cached data only)") parser.add_argument("--osv-ttl", type=int, default=86400, help="OSV cache TTL in seconds (default: 86400 = 24h)") + parser.add_argument("--refresh", action="store_true", default=False, + help="Bypass the OSV cache and force fresh API calls for " + "every package (issue #30). Updates the cache with new " + "results. Ignored in --offline mode.") def execute(args, workspace): @@ -21,6 +25,7 @@ def execute(args, workspace): severity=args.severity, offline=args.offline, osv_ttl=args.osv_ttl, + refresh=args.refresh, ) diff --git a/scripts/osv_client.py b/scripts/osv_client.py index 54fef1c1..669ae25e 100644 --- a/scripts/osv_client.py +++ b/scripts/osv_client.py @@ -477,14 +477,19 @@ def query_single( def query_packages( self, packages: List[OSVPackage], + force_refresh: bool = False, ) -> List[OSVVulnerability]: """Query multiple packages against OSV.dev. - Uses batch queries for efficiency. Checks cache first, - then queries API for uncached packages. + Uses batch queries for efficiency. Checks cache first, then queries + the API for uncached packages. Cached API responses are updated with + the fresh results. Args: - packages: List of OSVPackage objects to query + packages: List of OSVPackage objects to query. + force_refresh: If True, bypass the cache and force fresh API + calls for every package (issue #30 ``--refresh`` flag). + Ignored when ``offline`` is True (cannot hit the network). Returns: List of OSVVulnerability objects (deduplicated) @@ -496,12 +501,23 @@ def query_packages( uncached_packages: List[OSVPackage] = [] uncached_keys: List[str] = [] + # --refresh bypasses cache reads entirely (issue #30). It is a no-op + # in offline mode, where we cannot reach the API and must rely on + # whatever the cache already holds. + bypass_cache = bool(force_refresh) and not self.offline + # Check cache first for pkg in packages: if pkg.ecosystem is None: continue # Not supported by OSV cache_key = pkg.cache_key() + + if bypass_cache: + uncached_packages.append(pkg) + uncached_keys.append(cache_key) + continue + cached = self.cache.get(cache_key) if cached is not None: self._cache_hit_count += 1 diff --git a/scripts/vulnscan_engine.py b/scripts/vulnscan_engine.py index da5d3c3d..94934a3c 100755 --- a/scripts/vulnscan_engine.py +++ b/scripts/vulnscan_engine.py @@ -507,6 +507,7 @@ def scan_vulnerabilities( config: Optional[Dict] = None, offline: bool = False, osv_ttl: int = 86400, + refresh: bool = False, ) -> Dict[str, Any]: """ Scan dependency files for known vulnerabilities. @@ -522,6 +523,9 @@ def scan_vulnerabilities( "vulnscan.skip_audit_tools" options) offline: If True, skip OSV API queries (use cache only) osv_ttl: Cache TTL for OSV results in seconds (default 86400 = 24h) + refresh: If True, bypass the OSV cache and force fresh API calls for + every package (issue #30 ``--refresh`` flag). Ignored when + ``offline`` is True. Returns: Dict with findings, stats, risk level, audit availability, @@ -572,7 +576,9 @@ def scan_vulnerabilities( osv_packages = OSVQueryBuilder.build_from_workspace(workspace) if osv_packages: - osv_vulns = osv_client.query_packages(osv_packages) + osv_vulns = osv_client.query_packages( + osv_packages, force_refresh=refresh + ) osv_findings = [v.to_finding() for v in osv_vulns] # Tag OSV findings so we can prioritize them From d18e830a425f863e14d8b0c7f9476d081dbd4213 Mon Sep 17 00:00:00 2001 From: worker-4-b Date: Sun, 28 Jun 2026 08:05:22 +0000 Subject: [PATCH 3/5] feat(vuln): --max-age flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #30 — 'codelens vuln-scan --max-age Nh' treats OSV cache entries older than N hours as stale for the current run only (overrides the default 24h TTL without changing the stored TTL). Useful for agents that want fresher data without a full --refresh. - OSVClient.query_packages(packages, force_refresh=False, max_age=None): cached entries whose age exceeds max_age are re-fetched from the API for this run. Uses OSVCache.peek() to read timestamps without mutating the cache. Ignored when force_refresh is set (cache bypassed entirely) or in offline mode. - OSVClient.get_cache_info(packages, max_age=None): ttl_hours and the staleness threshold now honour max_age so cache_info stays consistent with the run's effective TTL. - vulnscan_engine.scan_vulnerabilities(..., max_age=None): passes through to both query_packages and get_cache_info. - vuln-scan CLI: new --max-age flag + _parse_max_age() helper accepting '6h'/'30m'/'2d'/'90s'/bare-integer (hours). Invalid values return an error result instead of crashing. --- scripts/commands/vuln_scan.py | 62 +++++++++++++++++++++++++++++++++++ scripts/osv_client.py | 24 ++++++++++++-- scripts/vulnscan_engine.py | 14 ++++++-- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/scripts/commands/vuln_scan.py b/scripts/commands/vuln_scan.py index b163e81a..41419101 100644 --- a/scripts/commands/vuln_scan.py +++ b/scripts/commands/vuln_scan.py @@ -1,9 +1,55 @@ """Vuln-scan command — Scan dependencies for known CVEs using OSV.dev + native audit tools.""" +import re + from vulnscan_engine import scan_vulnerabilities from commands import register_command +# --max-age accepts a duration string like "6h", "30m", "2d", "90s", or a +# bare integer (interpreted as hours, matching --osv-ttl semantics). +_MAX_AGE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([hmsd]?)\s*$", re.IGNORECASE) +_MAX_AGE_UNITS = { + "": 3600, # bare number → hours + "h": 3600, + "m": 60, + "s": 1, + "d": 86400, +} + + +def _parse_max_age(raw): + """Parse a ``--max-age`` duration string into seconds. + + Accepts forms like ``6h`` (6 hours), ``30m`` (30 minutes), ``2d`` + (2 days), ``90s`` (90 seconds), or a bare integer (interpreted as + hours, matching ``--osv-ttl`` semantics). + + Args: + raw: The raw string from argparse. May be ``None``. + + Returns: + int number of seconds, or ``None`` if ``raw`` is ``None``. + + Raises: + ValueError: If the string cannot be parsed or is not positive. + """ + if raw is None: + return None + match = _MAX_AGE_RE.match(str(raw)) + if match is None: + raise ValueError( + f"invalid --max-age value {raw!r} — expected forms like " + f"'6h', '30m', '2d', '90s', or a bare integer (hours)" + ) + value = float(match.group(1)) + unit = match.group(2).lower() + seconds = int(value * _MAX_AGE_UNITS[unit]) + if seconds <= 0: + raise ValueError(f"--max-age must be positive, got {raw!r}") + return seconds + + def add_args(parser): parser.add_argument("workspace", nargs="?", default=None, help="Path to workspace root (auto-detected if omitted)") @@ -17,15 +63,31 @@ def add_args(parser): help="Bypass the OSV cache and force fresh API calls for " "every package (issue #30). Updates the cache with new " "results. Ignored in --offline mode.") + parser.add_argument("--max-age", dest="max_age", default=None, + help="Treat OSV cache entries older than this as stale for " + "this run only (issue #30). Examples: '6h' (6 hours), " + "'30m' (30 minutes), '2d' (2 days), '90s' (90 seconds), " + "or a bare integer (interpreted as hours). Overrides the " + "default 24h TTL for this run; does not change stored TTL. " + "Ignored in --offline mode and with --refresh.") def execute(args, workspace): + try: + max_age_seconds = _parse_max_age(getattr(args, "max_age", None)) + except ValueError as exc: + return { + "status": "error", + "error": "invalid_argument", + "message": str(exc), + } return scan_vulnerabilities( workspace, severity=args.severity, offline=args.offline, osv_ttl=args.osv_ttl, refresh=args.refresh, + max_age=max_age_seconds, ) diff --git a/scripts/osv_client.py b/scripts/osv_client.py index 669ae25e..9917e104 100644 --- a/scripts/osv_client.py +++ b/scripts/osv_client.py @@ -478,18 +478,25 @@ def query_packages( self, packages: List[OSVPackage], force_refresh: bool = False, + max_age: Optional[int] = None, ) -> List[OSVVulnerability]: """Query multiple packages against OSV.dev. Uses batch queries for efficiency. Checks cache first, then queries - the API for uncached packages. Cached API responses are updated with - the fresh results. + the API for uncached (or stale) packages. Cached API responses are + updated with the fresh results. Args: packages: List of OSVPackage objects to query. force_refresh: If True, bypass the cache and force fresh API calls for every package (issue #30 ``--refresh`` flag). Ignored when ``offline`` is True (cannot hit the network). + max_age: Optional per-run TTL override in seconds. When set, + cached entries older than ``max_age`` are treated as stale + and re-fetched from the API for this run only (issue #30 + ``--max-age`` flag). The stored TTL is unchanged. ``None`` + means use each entry's stored TTL. Ignored when + ``force_refresh`` is True (cache is bypassed entirely). Returns: List of OSVVulnerability objects (deduplicated) @@ -501,10 +508,14 @@ def query_packages( uncached_packages: List[OSVPackage] = [] uncached_keys: List[str] = [] + now = time.time() # --refresh bypasses cache reads entirely (issue #30). It is a no-op # in offline mode, where we cannot reach the API and must rely on # whatever the cache already holds. bypass_cache = bool(force_refresh) and not self.offline + # --max-age (issue #30): treat still-fresh entries as stale for this + # run only. Only meaningful when we are actually consulting the cache. + max_age_ttl = max_age if (max_age is not None and max_age > 0) else None # Check cache first for pkg in packages: @@ -520,6 +531,15 @@ def query_packages( cached = self.cache.get(cache_key) if cached is not None: + # Honor --max-age: an entry that is still fresh per its + # stored TTL may be considered stale for this run. + if max_age_ttl is not None: + peek = self.cache.peek(cache_key) + if peek is not None and (now - peek[0]) > max_age_ttl: + uncached_packages.append(pkg) + uncached_keys.append(cache_key) + continue + self._cache_hit_count += 1 # cached can be: diff --git a/scripts/vulnscan_engine.py b/scripts/vulnscan_engine.py index 94934a3c..fcdb4c95 100755 --- a/scripts/vulnscan_engine.py +++ b/scripts/vulnscan_engine.py @@ -508,6 +508,7 @@ def scan_vulnerabilities( offline: bool = False, osv_ttl: int = 86400, refresh: bool = False, + max_age: Optional[int] = None, ) -> Dict[str, Any]: """ Scan dependency files for known vulnerabilities. @@ -526,6 +527,10 @@ def scan_vulnerabilities( refresh: If True, bypass the OSV cache and force fresh API calls for every package (issue #30 ``--refresh`` flag). Ignored when ``offline`` is True. + max_age: Optional per-run TTL override in seconds. When set, cached + OSV entries older than ``max_age`` are treated as stale and + re-fetched from the API for this run only (issue #30 ``--max-age`` + flag). The stored TTL is unchanged. Returns: Dict with findings, stats, risk level, audit availability, @@ -577,7 +582,9 @@ def scan_vulnerabilities( if osv_packages: osv_vulns = osv_client.query_packages( - osv_packages, force_refresh=refresh + osv_packages, + force_refresh=refresh, + max_age=max_age, ) osv_findings = [v.to_finding() for v in osv_vulns] @@ -597,8 +604,9 @@ def scan_vulnerabilities( # Issue #30: cache freshness info (computed AFTER the query so # it reflects the post-query state — any package just fetched - # or refreshed is now fresh). - cache_info = osv_client.get_cache_info(osv_packages) + # or refreshed is now fresh). Pass max_age so ttl_hours and + # the staleness threshold match the --max-age override. + cache_info = osv_client.get_cache_info(osv_packages, max_age=max_age) else: osv_stats = {"packages_queried": 0, "vulnerabilities_found": 0} logger.debug("OSV.dev: no packages to query") From 81e93c43ac097e5fdc2326ac77a62ef4d8e94990 Mon Sep 17 00:00:00 2001 From: worker-4-b Date: Sun, 28 Jun 2026 08:08:36 +0000 Subject: [PATCH 4/5] test(vuln): staleness tests for cache_info, --refresh, --max-age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #30 — 36 new network-free tests in tests/test_vuln_staleness.py: - OSVClient.get_cache_info(): shape, empty-cache staleness, fresh entry, expired entry listing, --max-age override, peek non-mutation, last_refresh = most-recent among multiple packages. - OSVClient.query_packages(force_refresh=...): bypasses cache + calls API, default still hits cache, no-op in offline mode. - OSVClient.query_packages(max_age=...): treats fresh entries as stale, below-threshold uses cache, ignored when force_refresh is set. - _parse_max_age(): valid forms (6h/30m/90s/2d/bare/1.5h/whitespace/upper), None passthrough, invalid + zero/negative rejection. - vuln-scan execute(): forwards refresh + parsed max_age to scan_vulnerabilities, returns error result on invalid --max-age. - scan_vulnerabilities(): cache_info present with no deps, reflects seeded fresh cache, reports stale under --max-age, and existing output fields remain unchanged (additive). The OSV API layer is mocked throughout; the SQLite cache is seeded directly with backdated timestamps. --- tests/test_vuln_staleness.py | 441 +++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 tests/test_vuln_staleness.py diff --git a/tests/test_vuln_staleness.py b/tests/test_vuln_staleness.py new file mode 100644 index 00000000..1d84a1ff --- /dev/null +++ b/tests/test_vuln_staleness.py @@ -0,0 +1,441 @@ +""" +Tests for OSV cache staleness reporting and the vuln-scan refresh / max-age +flags (GitHub issue #30). + +Covers: + - OSVClient.get_cache_info(): shape, freshness, stale-packages, max-age. + - OSVCache.peek(): pure inspection (no mutation of expired entries). + - OSVClient.query_packages(force_refresh=...): bypasses cache, updates it. + - OSVClient.query_packages(max_age=...): treats fresh entries as stale. + - vuln-scan CLI: --refresh / --max-age wiring + _parse_max_age parsing. + - scan_vulnerabilities(): cache_info present in output (additive). + +All tests are network-free: the OSV API layer (_batch_query_api and friends) +is mocked, and the SQLite cache is seeded directly. +""" + +import json +import os +import shutil +import sqlite3 +import sys +import tempfile +import time +from unittest.mock import patch + +import pytest + +SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") +sys.path.insert(0, SCRIPT_DIR) + +from osv_client import OSVClient, OSVPackage # noqa: E402 +from vulnscan_engine import scan_vulnerabilities # noqa: E402 +from commands.vuln_scan import _parse_max_age, execute as vuln_scan_execute # noqa: E402 + + +# ─── Helpers ─────────────────────────────────────────────────── + +def _make_workspace(): + """Create an empty temp workspace and return its path.""" + return tempfile.mkdtemp(prefix="codelens_vuln_test_") + + +def _seed_cache_entry(client, package, age_seconds, response=None, ttl=86400): + """Insert a cache row for ``package`` aged ``age_seconds`` directly into SQLite. + + Bypasses OSVCache.set() so we can backdate the timestamp for staleness + tests. ``response`` defaults to an empty list (no vulnerabilities). + """ + if response is None: + response = [] + conn = sqlite3.connect(client.cache.db_path) + try: + conn.execute( + "INSERT OR REPLACE INTO cache " + "(package_ecosystem_version, response_json, timestamp, ttl) " + "VALUES (?, ?, ?, ?)", + (package.cache_key(), json.dumps(response), time.time() - age_seconds, ttl), + ) + conn.commit() + finally: + conn.close() + + +def _npm_package(name="lodash", version="4.17.15"): + return OSVPackage(name=name, version=version, ecosystem="npm") + + +def _write_package_json(workspace, deps): + """Write a minimal package.json with the given dependencies.""" + pkg = {"name": "test-pkg", "version": "1.0.0", "dependencies": deps} + with open(os.path.join(workspace, "package.json"), "w") as f: + json.dump(pkg, f) + + +# ─── get_cache_info ──────────────────────────────────────────── + +class TestGetCacheInfo: + """OSVClient.get_cache_info() — issue #30 staleness reporting.""" + + def test_shape_and_keys(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + info = client.get_cache_info([_npm_package()]) + assert set(info.keys()) == { + "last_refresh", "age_hours", "ttl_hours", + "is_stale", "stale_packages", + } + assert info["stale_packages"] == [] + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_empty_cache_is_stale_no_coverage(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + info = client.get_cache_info([_npm_package()]) + # OSV-queriable package exists but nothing cached → stale. + assert info["last_refresh"] is None + assert info["age_hours"] is None + assert info["ttl_hours"] == 24.0 + assert info["is_stale"] is True + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_fresh_entry_not_stale(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=60) # 1 minute old + info = client.get_cache_info([pkg]) + assert info["is_stale"] is False + assert info["last_refresh"] is not None + assert info["last_refresh"].endswith("Z") + assert info["age_hours"] is not None + assert 0.0 <= info["age_hours"] < 1.0 + assert info["ttl_hours"] == 24.0 + assert info["stale_packages"] == [] + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_expired_entry_is_stale_and_listed(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + pkg = _npm_package() + # 25h old → past the 24h TTL. + _seed_cache_entry(client, pkg, age_seconds=25 * 3600) + info = client.get_cache_info([pkg]) + assert info["is_stale"] is True + assert "lodash@4.17.15" in info["stale_packages"] + assert info["age_hours"] >= 24.0 + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_max_age_makes_fresh_entry_stale(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + pkg = _npm_package() + # 10h old — fresh per the 24h stored TTL, but stale per --max-age 6h. + _seed_cache_entry(client, pkg, age_seconds=10 * 3600) + info = client.get_cache_info([pkg], max_age=6 * 3600) + assert info["ttl_hours"] == 6.0 + assert info["is_stale"] is True + assert "lodash@4.17.15" in info["stale_packages"] + # And without the override it is fresh. + info_default = client.get_cache_info([pkg]) + assert info_default["ttl_hours"] == 24.0 + assert info_default["is_stale"] is False + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_peek_does_not_mutate_expired_entries(self): + """get_cache_info must not delete expired entries (pure inspection).""" + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=48 * 3600) # well past TTL + # Inspect twice — the entry must survive the first inspection. + client.get_cache_info([pkg]) + info = client.get_cache_info([pkg]) + assert info["is_stale"] is True + assert info["last_refresh"] is not None # entry still present + # Direct peek confirmation: + assert client.cache.peek(pkg.cache_key()) is not None + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_last_refresh_is_most_recent_among_packages(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + old_pkg = OSVPackage(name="left-pad", version="1.0.0", ecosystem="npm") + new_pkg = OSVPackage(name="lodash", version="4.17.15", ecosystem="npm") + _seed_cache_entry(client, old_pkg, age_seconds=20 * 3600) # 20h + _seed_cache_entry(client, new_pkg, age_seconds=1 * 3600) # 1h + info = client.get_cache_info([old_pkg, new_pkg]) + # last_refresh should reflect the 1h-old entry. + assert info["age_hours"] is not None + assert info["age_hours"] < 2.0 + assert info["is_stale"] is False # newest entry is fresh + finally: + shutil.rmtree(ws, ignore_errors=True) + + +# ─── query_packages: --refresh ───────────────────────────────── + +class TestRefreshFlag: + """OSVClient.query_packages(force_refresh=...) — issue #30 --refresh.""" + + def test_refresh_bypasses_cache_and_calls_api(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=False) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=60) # cached & fresh + with patch.object(client, "_batch_query_api", return_value=[]) as m: + client.query_packages([pkg], force_refresh=True) + assert m.call_count == 1 + sent = m.call_args[0][0] + assert len(sent) == 1 and sent[0].name == "lodash" + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_default_uses_cache_no_api_call(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=False) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=60) + with patch.object(client, "_batch_query_api", return_value=[]) as m: + client.query_packages([pkg], force_refresh=False) + assert m.call_count == 0 + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_refresh_ignored_in_offline_mode(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=True) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=60) + with patch.object(client, "_batch_query_api", return_value=[]) as m: + client.query_packages([pkg], force_refresh=True) + assert m.call_count == 0 # cannot hit network → falls back to cache + finally: + shutil.rmtree(ws, ignore_errors=True) + + +# ─── query_packages: --max-age ───────────────────────────────── + +class TestMaxAgeFlag: + """OSVClient.query_packages(max_age=...) — issue #30 --max-age.""" + + def test_max_age_treats_fresh_entry_as_stale(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=False) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=10 * 3600) # 10h old + # Without max-age: cache hit (10h < 24h stored TTL). + with patch.object(client, "_batch_query_api", return_value=[]) as m: + client.query_packages([pkg]) + assert m.call_count == 0 + # With max-age=6h: 10h > 6h → re-fetched. + with patch.object(client, "_batch_query_api", return_value=[]) as m: + client.query_packages([pkg], max_age=6 * 3600) + assert m.call_count == 1 + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_max_age_below_age_uses_cache(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=False) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=2 * 3600) # 2h old + with patch.object(client, "_batch_query_api", return_value=[]) as m: + client.query_packages([pkg], max_age=6 * 3600) # 2h < 6h → fresh + assert m.call_count == 0 + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_max_age_ignored_when_force_refresh_set(self): + ws = _make_workspace() + try: + client = OSVClient(workspace=ws, ttl=86400, offline=False) + pkg = _npm_package() + _seed_cache_entry(client, pkg, age_seconds=2 * 3600) + with patch.object(client, "_batch_query_api", return_value=[]) as m: + client.query_packages([pkg], force_refresh=True, max_age=6 * 3600) + assert m.call_count == 1 # force_refresh wins; cache bypassed + finally: + shutil.rmtree(ws, ignore_errors=True) + + +# ─── _parse_max_age ──────────────────────────────────────────── + +class TestParseMaxAge: + """vuln-scan --max-age duration parsing.""" + + @pytest.mark.parametrize("raw,expected", [ + ("6h", 6 * 3600), + ("30m", 30 * 60), + ("90s", 90), + ("2d", 2 * 86400), + ("12", 12 * 3600), # bare integer → hours + ("1.5h", 5400), # fractional + (" 3h ", 3 * 3600), # surrounding whitespace + ("6H", 6 * 3600), # uppercase unit + ]) + def test_valid(self, raw, expected): + assert _parse_max_age(raw) == expected + + def test_none_returns_none(self): + assert _parse_max_age(None) is None + + @pytest.mark.parametrize("raw", ["6x", "h", "abc", "6hr", "", "-3h"]) + def test_invalid_raises(self, raw): + with pytest.raises(ValueError): + _parse_max_age(raw) + + def test_zero_or_negative_rejected(self): + with pytest.raises(ValueError): + _parse_max_age("0h") + with pytest.raises(ValueError): + _parse_max_age("0") + + +# ─── vuln-scan command wiring ────────────────────────────────── + +class _FakeArgs: + """Minimal stand-in for an argparse Namespace for vuln-scan.""" + + def __init__(self, **kwargs): + self.workspace = kwargs.get("workspace", "/tmp/ws") + self.severity = kwargs.get("severity", None) + self.offline = kwargs.get("offline", False) + self.osv_ttl = kwargs.get("osv_ttl", 86400) + self.refresh = kwargs.get("refresh", False) + self.max_age = kwargs.get("max_age", None) + + +class TestVulnScanCommandWiring: + """vuln-scan execute() forwards flags to scan_vulnerabilities.""" + + def test_execute_passes_refresh_and_max_age(self): + captured = {} + + def fake_scan(workspace, **kwargs): + captured.update(kwargs) + captured["workspace"] = workspace + return {"status": "ok"} + + with patch("commands.vuln_scan.scan_vulnerabilities", side_effect=fake_scan): + args = _FakeArgs(refresh=True, max_age="6h") + result = vuln_scan_execute(args, workspace="/tmp/ws") + assert result["status"] == "ok" + assert captured["refresh"] is True + assert captured["max_age"] == 6 * 3600 # parsed to seconds + assert captured["offline"] is False + + def test_execute_invalid_max_age_returns_error(self): + with patch("commands.vuln_scan.scan_vulnerabilities") as m: + args = _FakeArgs(max_age="not-a-duration") + result = vuln_scan_execute(args, workspace="/tmp/ws") + assert result["status"] == "error" + assert result["error"] == "invalid_argument" + assert "max-age" in result["message"] + assert m.call_count == 0 # scan never invoked on bad input + + def test_execute_no_max_age_passes_none(self): + captured = {} + + def fake_scan(workspace, **kwargs): + captured.update(kwargs) + return {"status": "ok"} + + with patch("commands.vuln_scan.scan_vulnerabilities", side_effect=fake_scan): + args = _FakeArgs() + vuln_scan_execute(args, workspace="/tmp/ws") + assert captured["max_age"] is None + assert captured["refresh"] is False + + +# ─── scan_vulnerabilities integration ────────────────────────── + +class TestScanVulnerabilitiesCacheInfo: + """scan_vulnerabilities() surfaces cache_info (additive, issue #30).""" + + def test_cache_info_present_no_deps(self): + ws = _make_workspace() + try: + result = scan_vulnerabilities( + ws, offline=True, + config={"vulnscan": {"skip_audit_tools": True}}, + ) + assert result["status"] == "ok" + assert "cache_info" in result + info = result["cache_info"] + assert info["is_stale"] is False # no packages → nothing stale + assert info["last_refresh"] is None + assert info["ttl_hours"] == 24.0 + assert info["stale_packages"] == [] + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_cache_info_reflects_seeded_fresh_cache(self): + ws = _make_workspace() + try: + _write_package_json(ws, {"lodash": "4.17.15"}) + # Pre-seed the cache before running so offline mode finds it. + client = OSVClient(workspace=ws, ttl=86400, offline=True) + _seed_cache_entry(client, _npm_package(), age_seconds=120) + result = scan_vulnerabilities( + ws, offline=True, + config={"vulnscan": {"skip_audit_tools": True}}, + ) + info = result["cache_info"] + assert info["last_refresh"] is not None + assert info["is_stale"] is False + assert info["stale_packages"] == [] + assert info["ttl_hours"] == 24.0 + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_cache_info_with_max_age_reports_stale(self): + ws = _make_workspace() + try: + _write_package_json(ws, {"lodash": "4.17.15"}) + client = OSVClient(workspace=ws, ttl=86400, offline=True) + # 10h old — fresh per 24h stored TTL, stale per --max-age 6h. + _seed_cache_entry(client, _npm_package(), age_seconds=10 * 3600) + result = scan_vulnerabilities( + ws, offline=True, + config={"vulnscan": {"skip_audit_tools": True}}, + max_age=6 * 3600, + ) + info = result["cache_info"] + assert info["ttl_hours"] == 6.0 + assert info["is_stale"] is True + assert "lodash@4.17.15" in info["stale_packages"] + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_existing_output_fields_unchanged(self): + """cache_info is additive — pre-existing top-level keys still present.""" + ws = _make_workspace() + try: + result = scan_vulnerabilities( + ws, offline=True, + config={"vulnscan": {"skip_audit_tools": True}}, + ) + for key in ("status", "workspace", "stats", "risk", "findings", + "audit_available", "osv_stats", "recommendations", "cache_info"): + assert key in result, f"missing pre-existing key: {key}" + finally: + shutil.rmtree(ws, ignore_errors=True) From db3cac3881293087ea2c8f7e8468cf2fa92b5d65 Mon Sep 17 00:00:00 2001 From: worker-4-b Date: Sun, 28 Jun 2026 08:10:36 +0000 Subject: [PATCH 5/5] docs(vuln): document --refresh, --max-age, and cache_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #30 — document the new vuln-scan staleness flags and the cache_info output block. - README.md: expand the vuln-scan command row with --refresh / --max-age and the cache_info block. - SKILL-QUICK.md: add the flags + cache_info note to the Security command list. - CHANGELOG.md: new 'OSV Cache Staleness Flag + Refresh Flags (issue #30)' section under [8.2.0] with Added / Changed / Non-Breaking subsections. --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- SKILL-QUICK.md | 2 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56702bee..aa37fe5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [8.2.0] — Unreleased +### OSV Cache Staleness Flag + Refresh Flags (issue #30) + +`vuln-scan` now reports OSV cache freshness and lets agents force fresh +data. Previously the 24h OSV cache had no staleness indicator and no way +to force a re-fetch short of deleting the cache — agents relying on +`vuln-scan` for security posture had no way to know whether the CVE +results were minutes or 23 hours old. + +### Added (vuln-scan staleness) + +- **`cache_info` block in `vuln-scan` output** — additive top-level field: + ```json + "cache_info": { + "last_refresh": "2026-06-28T10:00:00Z", + "age_hours": 23.5, + "ttl_hours": 24, + "is_stale": false, + "stale_packages": ["requests@2.20.0"] + } + ``` + `is_stale` is true when any cached entry is past the effective TTL, the + newest entry's age ≥ the effective TTL, or there are OSV-queriable + packages but none are cached (no fresh coverage). `stale_packages` lists + the `"name@version"` of cached entries past the effective TTL. +- **`vuln-scan --refresh`** — bypasses the OSV cache and forces fresh + OSV.dev API calls for every package, then updates the cache with the new + results. No-op in `--offline` mode. +- **`vuln-scan --max-age Nh`** — treats cache entries older than `N` hours + as stale for this run only (overrides the default 24h TTL without + changing the stored TTL). Accepts `6h`/`30m`/`2d`/`90s`/bare-integer + (hours). `cache_info.ttl_hours` and the staleness threshold honour this + override so they stay consistent. Ignored with `--refresh` and in + `--offline` mode. + +### Changed (vuln-scan staleness) + +- `scripts/osv_client.py`: new `OSVCache.peek(key)` — pure inspection + returning `(timestamp, ttl)` without mutating the cache (unlike `get()`, + which deletes expired entries). New `OSVClient.get_cache_info(packages, + max_age=None)`. `OSVClient.query_packages()` gained `force_refresh` and + `max_age` parameters (default behaviour unchanged). +- `scripts/vulnscan_engine.py`: `scan_vulnerabilities()` gained `refresh` + and `max_age` parameters and now attaches `cache_info` to its result. +- `scripts/commands/vuln_scan.py`: new `--refresh` and `--max-age` flags + plus a `_parse_max_age()` helper. Invalid `--max-age` values return an + `error` result instead of crashing. + +### Non-Breaking (vuln-scan staleness) + +- `cache_info` is additive — no existing `vuln-scan` output field is + changed or removed. +- Default network behaviour is unchanged: the API is only contacted for + uncached packages, or when `--refresh` is set, or when `--max-age` + marks cached entries stale. + ### Confidence Fields on Non-Deep Output (test fix) Previously, the `confidence` / `confidence_distribution` fields were only diff --git a/README.md b/README.md index a05b208c..833a2dda 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ python3 scripts/codelens.py query "myFunction" --lite | Command | Description | |---------|-------------| | `secrets [workspace] [--severity ...]` | Detect hardcoded API keys, passwords, tokens | -| `vuln-scan [workspace]` | Scan dependencies for known CVEs (OSV.dev + native audit) | +| `vuln-scan [workspace] [--refresh] [--max-age Nh]` | Scan dependencies for known CVEs (OSV.dev + native audit). `--refresh` bypasses the OSV cache; `--max-age Nh` treats cache entries older than N hours as stale for this run. Output includes a `cache_info` block (`last_refresh`, `age_hours`, `ttl_hours`, `is_stale`, `stale_packages`) — issue #30 | | `taint [workspace]` | Run AST-based taint analysis for vulnerability detection | | `dataflow [workspace] [--source] [--sink]` | Data flow taint analysis with cross-file call graph | | `env-check [workspace] [--var NAME]` | Audit environment variables | diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index 0d0869e6..8eb67e14 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -126,7 +126,7 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co `entrypoints` · `api-map` · `state-map` · `detect` · `handbook` · `diff [--git-aware]` · `dashboard` · `history` · `graph-schema` · `resolve-types` ### Security (5) -`secrets [--severity ...]` · `taint` (AST-based) · `dataflow [--source ...] [--sink ...]` (cross-file) · `vuln-scan` (OSV.dev + native audit) · `env-check [--var NAME]` +`secrets [--severity ...]` · `taint` (AST-based) · `dataflow [--source ...] [--sink ...]` (cross-file) · `vuln-scan [--refresh] [--max-age Nh]` (OSV.dev + native audit; output includes `cache_info` staleness block — issue #30) · `env-check [--var NAME]` ### Quality (9) `smell [--categories ...] [--severity ...]` · `complexity [--name FN] [--threshold N] [--sort ...]` · `dead-code [--categories ...]` · `debug-leak [--category ...]` · `circular [--domain ...]` · `missing-refs` · `side-effect [--name FN]` · `perf-hint [--severity ...] [--category ...]` · `fix [--apply]`