Root cause
Resource abuse & availability introduced
0.6.0: SCA enrichment — EOL-aware container image checks
AI-assisted change: Claude
How AI contributed: Direct introductionPublished May 12, 2026 in
jo-jo98/ciguardAll 4 findings in this repository
SCA HTTP clients call unbounded resp.read(); invariant: advisory HTTP bodies must be capped before decode.
Root cause
0.6.0: SCA enrichment — EOL-aware container image checks
AI-assisted change: Claude
Fix
The patch reads at most 5 MiB plus one byte from both SCA HTTP clients, rejects oversized bodies before JSON decoding, and keeps normal in-limit responses working.
AI-assisted fix: Claude Code · Jo.Moore
Case facts
@@ -0,0 +1,222 @@+"""+endoflife.date client + on-disk cache.++endoflife.date is a free, well-maintained vendor-neutral catalogue of EOL+schedules for ~250 products. Each product endpoint returns+`[{cycle, eol, releaseDate, latest, ...}, ...]` JSON, sorted newest first.++ - `cycle` is the major or major.minor identifier (e.g. "3.18" for+ Alpine, "12" for Debian, "3.9" for Python).+ - `eol` is an ISO date string OR `false` if not yet announced. We treat+ `false` as "no EOL signal" (skip).+ - `releaseDate` is ISO date.++Caching: file-based at `<cache_dir>/endoflife-<product>.json` with a default+24h TTL. Pipeline scans must stay fast — we cannot hit the network on every+run. Cache misses fall back to network; network errors fall back to a stale+cache if available, otherwise return `None` (rules then skip the check).++Offline mode (`--offline` CLI flag): never hits the network. Uses cache if+present, returns `None` otherwise. Designed for air-gapped CI environments.+"""+from __future__ import annotations++import json+import time+import urllib.error+import urllib.request+from datetime import datetime, timezone+from pathlib import Path+from typing import Optional++API_BASE = "https://endoflife.date/api"+USER_AGENT = "ciguard-sca/0.6 (+https://github.com/Jo-Jo98/ciguard)"+DEFAULT_TTL_SECONDS = 24 * 60 * 60 # 24 hours+DEFAULT_TIMEOUT_SECONDS = 8++# Map of "image name → endoflife.date product slug". Many image names match+# their endoflife slug exactly; the ones below need explicit redirection.+# Add aggressively — false negatives are worse than false positives here+# (a missing mapping = silently skipped EOL check).+IMAGE_TO_PRODUCT: dict[str, str] = {+ # OS bases+ "alpine": "alpine-linux",+ "debian": "debian",+ "ubuntu": "ubuntu",+ "centos": "centos",+ "rockylinux": "rocky-linux",+ "rocky": "rocky-linux",+ "almalinux": "alma-linux",+ "fedora": "fedora",+ # Language runtimes+ "python": "python",+ "node": "nodejs",+ "nodejs": "nodejs",+ "ruby": "ruby",+ "golang": "go",+ "go": "go",+ "rust": "rust",+ "php": "php",+ "openjdk": "java",+ "eclipse-temurin": "eclipse-temurin",+ "amazoncorretto": "amazon-corretto",+ "ibm-semeru-runtimes": "ibm-semeru-runtimes",+ # Databases (rarely in CI base images but covered)+ "postgres": "postgresql",+ "postgresql": "postgresql",+ "mysql": "mysql",+ "mariadb": "mariadb",+ "mongo": "mongodb",+ "mongodb": "mongodb",+ "redis": "redis",+ # Tooling+ "maven": "maven",+ "gradle": "gradle",+}+++class EndOfLifeClient:+ """Thin wrapper around the endoflife.date REST API with an on-disk+ cache. Designed to be reused across many lookups in a single scan."""++ def __init__(+ self,+ cache_dir: Path,+ offline: bool = False,+ ttl_seconds: int = DEFAULT_TTL_SECONDS,+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,+ ) -> None:+ self.cache_dir = cache_dir@@ -0,0 +1,256 @@+"""+SCA (Software Composition Analysis) rules — v0.6.0.++These rules cross-reference container image / language runtime references+in the pipeline against the endoflife.date catalogue to flag:++ SCA-EOL-001 Image base release is past end-of-life (Critical / High)+ SCA-EOL-002 Pinned language runtime is past end-of-life (Critical / High)+ SCA-EOL-003 Image / runtime is approaching EOL (≤90 d) (Info)+ SCA-PIN-001 Image is tag-pinned but not digest-pinned (Low — best-practice nudge)++All rules run for every platform (GitLab CI, GitHub Actions, Jenkins) since+each can reference container images. The image extraction layer+(`sca/image_extractor.py`) flattens platform-specific shapes into a single+`ImageReference` list.++Network: rules use the `EndOfLifeClient` with on-disk caching (24h TTL by+default). `--offline` skips the network entirely and uses only cached data.+A missing cache + offline mode = silent skip, not a finding.++Why we split EOL into severity levels:+ - **Critical** — past EOL by >90 days. The supplier has stopped publishing+ security patches; any new vulnerability disclosed after EOL will not be+ fixed in this version. Real ongoing risk.+ - **High** — past EOL by ≤90 days. Recently EOL — no new patches but the+ inventory of known issues is still small. Window to upgrade.+ - **Info** — approaching EOL (≤90 days remaining). Advance warning so+ the upgrade can be planned, not reactive.++We deliberately distinguish these because flagging "approaching EOL" as+Critical would create alert fatigue and dilute the signal of the genuine+"past-EOL-and-vulnerable" findings.+"""+from __future__ import annotations++from datetime import datetime, timezone+from typing import Callable, List, Union++from ..models.jenkinsfile import Jenkinsfile+from ..models.pipeline import (+ Category,+ ComplianceMapping,+ Finding,+ Pipeline,+ Severity,+)+from ..models.workflow import Workflow+from .rules import _finding_id+from .sca.endoflife import EndOfLifeClient+from .sca.image_extractor import ImageReference, extract_images+++SCATarget = Union[Pipeline, Workflow, Jenkinsfile]+SCARuleFunc = Callable[[SCATarget, EndOfLifeClient], List[Finding]]+++# Standard compliance mapping for EOL findings — maps to "use of unsupported+# / vulnerable components" controls across all three frameworks.+_EOL_COMPLIANCE = ComplianceMapping(+ iso_27001=["A.12.6.1", "A.14.2.7"],+ soc2=["CC7.1", "CC8.1"],+ nist=["ID.RA-1", "PR.IP-12"],+)++_PIN_COMPLIANCE = ComplianceMapping(+ iso_27001=["A.12.5.1", "A.14.2.2"],+ soc2=["CC8.1"],+ nist=["PR.IP-1"],+)++# Cutoff (days past EOL) for Critical-vs-High split.+_CRITICAL_DAYS_PAST_EOL = 90++# How far ahead to look for "approaching EOL" (Info severity).+_APPROACHING_EOL_DAYS = 90+++def _eol_severity_and_label(days_until_eol: int) -> tuple[Severity, str, str]:+ """Map days-until-eol into severity + human-readable phase + summary+ fragment. Negative days = past EOL; positive = approaching."""+ if days_until_eol < -_CRITICAL_DAYS_PAST_EOL:+ return Severity.CRITICAL, "long past EOL", f"{abs(days_until_eol)} days past EOL"+ if days_until_eol < 0:+ return Severity.HIGH, "recently past EOL", f"{abs(days_until_eol)} days past EOL"+ if days_until_eol <= _APPROACHING_EOL_DAYS:+ return Severity.INFO, "approaching EOL", f"EOL in {days_until_eol} days"+ # Not in scope of any SCA-EOL rule+ return Severity.INFO, "supported", f"EOL in {days_until_eol} days"+@@ -3,6 +3,34 @@ All notable changes to `ciguard` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.6.0] — 2026-04-26++First release after the original PRD closed. Adds **SCA enrichment** — CVE/EOL awareness for the container images and language runtimes referenced inside the pipeline. ciguard remains a *pipeline-configuration* scanner; this release adds *dependency awareness* to existing findings without overlapping with full SCA tools (Snyk / Dependabot / Nexus IQ). The headline use-case is end-of-life detection: pipelines silently accumulate `python:3.9`, `node:16`, `alpine:3.16`, `debian:11` references that go EOL and stay in production for years afterwards.++### Added+- **`SCA-EOL-001` — End-of-Life Container Base Image** (Critical past 90d EOL, High past EOL ≤90d). Fires on Alpine, Debian, Ubuntu, CentOS, Rocky, AlmaLinux, Fedora image bases whose cycle has reached upstream end-of-life.+- **`SCA-EOL-002` — End-of-Life Language Runtime** (same severity scheme as 001). Fires on Python, Node, Ruby, Go, Java (OpenJDK / Eclipse Temurin / Amazon Corretto), Rust, PHP image tags whose runtime cycle is past EOL.+- **`SCA-EOL-003` — Container Image Approaching End-of-Life** (Info, ≤90 days remaining). Advance warning so EOL upgrades can be planned, not reactive. Deliberately Info-level to avoid alert fatigue.+- **`SCA-PIN-001` — Image Pinned by Tag, Not by Digest** (Low). Catches the gap between PIPE-001 (which fires on `:latest` / no tag) and true content immutability. Tags are mutable in most registries; digest pinning (`@sha256:...`) is the only way to be sure the same image runs on every CI invocation. Deliberately Low — most teams accept this trade-off knowingly, but high-trust pipelines should pin to digest.+- **`endoflife.date` integration** as the EOL data source. Free, vendor-neutral, ~250 products covered. ciguard caches the per-product JSON at `~/.ciguard/cache/endoflife-<product>.json` with a 24h TTL — pipeline scans stay fast, and the cache survives across runs and across pipelines on the same machine.+- **`--offline` CLI flag** on `ciguard scan` — disables all SCA network lookups, uses cache only. Required for air-gapped CI environments. Cache misses + offline = silent skip (not a finding); we never fabricate EOL data.+- **`AnalysisEngine(enable_sca=False)`** opt-out for callers that want strict platform-rule-only behaviour (and for tests).+- **Cross-platform image extraction** — SCA rules run for GitLab CI (job `image:`), GitHub Actions (`jobs.<id>.container` + `services`), and Jenkins (Declarative agents at top-level + per-stage + parallel children). One SCA module, three platforms.++### Why we stop here (and what's NOT in scope)+- **NOT general SCA** on the user's project dependencies (`requirements.txt`, `package.json`, `pom.xml`, etc.). That's the Snyk / Dependabot / Nexus IQ space — they have years of vulnerability database curation and we'd lose head-on. ciguard scans what's *referenced in the pipeline*, not what runs at application runtime.+- **NOT writing our own vulnerability database.** OSV.dev (planned for v0.6.1 CVE work) and endoflife.date are exhaustive and free.+- **NOT licence compliance.** Different user persona; separate adjacent product.++### Validation+- **318 / 318 tests passing** (was 288 in v0.5.0; +30 new tests in `test_sca_rules.py` covering image-reference parsing, EndOfLifeClient cache + offline behaviour, EOL severity dispatch, digest-pinning nudge logic, and engine opt-out).+- All existing fixtures pass with `enable_sca=False` — no regression to the 17-project GitLab corpus, the 14-project Jenkins corpus, or the labelled fixture validator (still 100% recall, 0 FP across 10 fixtures).+- Real-world smoke (against a deliberately-bad fixture with `python:3.9-slim`, `alpine:3.16`, `node:18-alpine`): all three correctly flagged Critical with accurate EOL date evidence (`python:3.9` EOL 2025-10-31, `alpine:3.16` EOL 2024-05-23, `node:18` EOL 2025-04-30).+- Lint clean.++### Roadmap context+This release implements PRD Slice 14 (SCA enrichment, originally planned as v0.7.0; brought forward in response to user prioritisation 2026-04-26). CVE lookup against OSV.dev + GitHub Advisory DB is **deferred to v0.6.1** — same infrastructure (HTTP client, cache, offline flag), different data source, deserves its own focused session.+ ## [0.5.0] — 2026-04-25 This release closes the original PRD with the last outstanding feature: **baseline / delta reports** for incremental scanning. Teams can now seed a baseline of acknowledged findings, and subsequent scans report only what *changed* — new findings appearing, prior findings resolved — rather than re-flagging the same set of known issues every CI run.@@ -45,6 +45,12 @@ DEFAULT_TTL_SECONDS = 24 * 60 * 60 DEFAULT_TIMEOUT_SECONDS = 8 +# Cap on the response body we'll accept. OSV.dev advisory responses are+# typically <100 KB; 5 MB is generous headroom. Prevents memory exhaustion+# from a hostile / compromised endpoint or successful TLS MITM returning a+# multi-GB response.+MAX_RESPONSE_BYTES = 5 * 1024 * 1024+ # OSV.dev ecosystem string for GitHub Actions advisories. Case-sensitive. ECOSYSTEM_GITHUB_ACTIONS = "GitHub Actions" @@ -180,7 +186,12 @@ def _fetch( with urllib.request.urlopen(req, timeout=self.timeout_seconds) as resp: # nosec B310 if resp.status != 200: return None- payload = json.loads(resp.read().decode("utf-8"))+ # Read with size cap (+1) so we can detect overflow. A hostile+ # server returning gigabytes would otherwise OOM-kill ciguard.+ body = resp.read(MAX_RESPONSE_BYTES + 1)+ if len(body) > MAX_RESPONSE_BYTES:+ return None+ payload = json.loads(body.decode("utf-8")) if not isinstance(payload, dict): return None vulns = payload.get("vulns")@@ -34,6 +34,11 @@ DEFAULT_TTL_SECONDS = 24 * 60 * 60 # 24 hours DEFAULT_TIMEOUT_SECONDS = 8 +# Cap on the response body we'll accept. endoflife.date product responses+# are typically <100 KB; 5 MB is generous headroom. Prevents memory exhaustion+# from a hostile / compromised endpoint or successful TLS MITM.+MAX_RESPONSE_BYTES = 5 * 1024 * 1024+ # Map of "image name → endoflife.date product slug". Many image names match # their endoflife slug exactly; the ones below need explicit redirection. # Add aggressively — false negatives are worse than false positives here@@ -213,7 +218,12 @@ def _fetch(self, product: str) -> Optional[list[dict]]: with urllib.request.urlopen(req, timeout=self.timeout_seconds) as resp: # nosec B310 if resp.status != 200: return None- payload = json.loads(resp.read().decode("utf-8"))+ # Read with size cap (+1) so we can detect overflow. A hostile+ # server returning gigabytes would otherwise OOM-kill ciguard.+ body = resp.read(MAX_RESPONSE_BYTES + 1)+ if len(body) > MAX_RESPONSE_BYTES:+ return None+ payload = json.loads(body.decode("utf-8")) if not isinstance(payload, list): return None return payloadAI-assisted change 67585276020a4f6c3ea777f1195c068ef5500fff6d2d7d0e1aa173aed9a43d7c · Fix d32b05fdbb2abd0ec9cb2ad8e5bc8b7399882f7fcb683201cfc153a062e85231