.github/scripts/lib_dependency_scan.sh444 of 444 statements covered (100.00%).
coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.
| 1 | #!/usr/bin/env bash | |
| 2 | # ============================================================================= | |
| 3 | # lib_dependency_scan.sh — sourceable functions for dependency-scan.sh | |
| 4 | # ============================================================================= | |
| 5 | # Extracted from dependency-scan.sh so BATS tests can exercise the real logic | |
| 6 | # without running the full scan (which needs pip, skopeo, helm, AWS creds). | |
| 7 | # | |
| 8 | # Usage: | |
| 9 | # source .github/scripts/lib_dependency_scan.sh | |
| 10 | # ============================================================================= | |
| 11 | ||
| 12 | # extract_direct_python_deps [pyproject_path] | |
| 13 | # | |
| 14 | # Reads the ``project.dependencies`` list and every list under | |
| 15 | # ``project.optional-dependencies`` from ``pyproject.toml`` and prints | |
| 16 | # one normalized package name per line (lowercased, ``_`` → ``-`` per | |
| 17 | # PEP 503). These are the packages we pin *directly* — everything else | |
| 18 | # is a transitive dependency whose version is controlled by something | |
| 19 | # we pin, and bumping it ourselves either does nothing (pip resolves | |
| 20 | # back to the same version) or breaks the resolver. | |
| 21 | # | |
| 22 | # Used by the python-drift path in ``dependency-scan.sh`` to filter | |
| 23 | # ``pip list --outdated`` down to names the operator can actually act | |
| 24 | # on, so the monthly report doesn't flag (for example) ``cattrs`` as | |
| 25 | # "outdated" when it's a jsii transitive we have no input on. | |
| 26 | # | |
| 27 | # Falls back silently to an empty list (prints nothing) if the file | |
| 28 | # isn't present or can't be parsed — the caller treats an empty list | |
| 29 | # as "no filter applied" rather than "no direct deps" so the scan | |
| 30 | # never silently hides genuine drift if the TOML parse breaks. | |
| 31 | # | |
| 32 | # Requires Python 3.11+ for ``tomllib`` (stdlib). The deps-scan | |
| 33 | # workflow already runs on 3.14. | |
| 34 | extract_direct_python_deps() { | |
| 35 | 41 | local pyproject="${1:-pyproject.toml}" |
| 36 | 43 | [ -f "$pyproject" ] || return 0 |
| 37 | 39 | python3 -c " |
| 38 | import re, sys, tomllib | |
| 39 | try: | |
| 40 | with open(sys.argv[1], 'rb') as f: | |
| 41 | data = tomllib.load(f) | |
| 42 | except Exception: | |
| 43 | sys.exit(0) | |
| 44 | ||
| 45 | project = data.get('project', {}) or {} | |
| 46 | deps = list(project.get('dependencies', []) or []) | |
| 47 | for group in (project.get('optional-dependencies', {}) or {}).values(): | |
| 48 | deps.extend(group or []) | |
| 49 | ||
| 50 | # Drop the project self-reference (``gco-cli[dev]`` etc.) before | |
| 51 | # normalising — pip doesn't report it in ``list --outdated`` anyway | |
| 52 | # but we also don't want to match on it. | |
| 53 | seen = set() | |
| 54 | for spec in deps: | |
| 55 | if not isinstance(spec, str): | |
| 56 | continue | |
| 57 | name = re.split(r'[\\[=!<>;~ ]', spec, 1)[0].strip() | |
| 58 | if not name or name.lower() == 'gco-cli': | |
| 59 | continue | |
| 60 | # PEP 503 normalisation: lowercase, ``_`` + ``.`` → ``-``. | |
| 61 | name = re.sub(r'[-_.]+', '-', name).lower() | |
| 62 | if name not in seen: | |
| 63 | seen.add(name) | |
| 64 | print(name) | |
| 65 | " "$pyproject" 2>/dev/null | |
| 66 | } | |
| 67 | ||
| 68 | # ============================================================================= | |
| 69 | # extract_python_extras [pyproject_path] | |
| 70 | # | |
| 71 | # Prints every ``[project.optional-dependencies]`` group name from | |
| 72 | # ``pyproject.toml``, one per line, in declaration order. The python-drift | |
| 73 | # path in ``dependency-scan.sh`` joins these into a single | |
| 74 | # ``pip install -e ".[group1,group2,...]"`` so that packages pinned *only* | |
| 75 | # inside an optional group (``aws-cdk-lib`` in ``cdk``, ``playwright`` in | |
| 76 | # ``diagrams``, ``mypy`` in ``typecheck``, ...) are actually present in the | |
| 77 | # scan venv. ``pip list --outdated`` can only report drift for installed | |
| 78 | # packages, so the previous base-only install silently dropped every | |
| 79 | # extras-only pin from the report even though | |
| 80 | # ``extract_direct_python_deps`` already includes those names in the | |
| 81 | # direct-pin filter. | |
| 82 | # | |
| 83 | # Falls back silently to an empty list (prints nothing) if the file is | |
| 84 | # missing or unparsable — the caller then falls back to a plain ``-e .`` | |
| 85 | # install, which is exactly the old behaviour, rather than dropping the | |
| 86 | # report section. | |
| 87 | extract_python_extras() { | |
| 88 | 40 | local pyproject="${1:-pyproject.toml}" |
| 89 | 42 | [ -f "$pyproject" ] || return 0 |
| 90 | 38 | python3 -c " |
| 91 | import sys, tomllib | |
| 92 | try: | |
| 93 | with open(sys.argv[1], 'rb') as f: | |
| 94 | data = tomllib.load(f) | |
| 95 | except Exception: | |
| 96 | sys.exit(0) | |
| 97 | groups = (data.get('project', {}) or {}).get('optional-dependencies', {}) or {} | |
| 98 | for group in groups: | |
| 99 | print(group) | |
| 100 | " "$pyproject" 2>/dev/null | |
| 101 | } | |
| 102 | ||
| 103 | # extract_build_system_pins [pyproject_path] | |
| 104 | # | |
| 105 | # Reads ``[build-system].requires`` and emits one ``name|version|raw`` | |
| 106 | # line per entry. ``version`` is filled only when the entry is a single | |
| 107 | # exact ``==X[.Y[.Z…]]`` pin with no extras, ranges, or markers; | |
| 108 | # otherwise it is left empty so the consistency check can flag the | |
| 109 | # entry while the PyPI drift check skips it. | |
| 110 | # | |
| 111 | # The build backend is a Python dependency too, but it is invisible to | |
| 112 | # ``pip list --outdated``: pip resolves it inside build isolation, not | |
| 113 | # in the scan venv (a Python 3.14 venv does not even ship setuptools). | |
| 114 | # Before this extractor existed the backend floated on a ``>=`` range | |
| 115 | # with nothing watching it — the exact failure mode the rest of this | |
| 116 | # library exists to catch. | |
| 117 | # | |
| 118 | # Prints nothing for a missing or unparseable file; the consistency | |
| 119 | # check treats that as a finding (fail-visible) rather than a pass. | |
| 120 | 24 | extract_build_system_pins() { |
| 121 | 88 | local pyproject="${1:-pyproject.toml}" |
| 122 | 93 | [ -f "$pyproject" ] || return 0 |
| 123 | 83 | python3 -c " |
| 124 | import re, sys, tomllib | |
| 125 | try: | |
| 126 | with open(sys.argv[1], 'rb') as f: | |
| 127 | data = tomllib.load(f) | |
| 128 | except Exception: | |
| 129 | sys.exit(0) | |
| 130 | requires = (data.get('build-system') or {}).get('requires') or [] | |
| 131 | for raw in requires: | |
| 132 | if not isinstance(raw, str) or not raw.strip(): | |
| 133 | continue | |
| 134 | entry = raw.strip() | |
| 135 | exact = re.fullmatch(r'([A-Za-z0-9][A-Za-z0-9._-]*)==([0-9]+(?:\.[0-9]+)*)', entry) | |
| 136 | name_match = re.match(r'[A-Za-z0-9][A-Za-z0-9._-]*', entry) | |
| 137 | # PEP 503 normalisation: lowercase, ``_`` + ``.`` → ``-``. | |
| 138 | name = re.sub(r'[-_.]+', '-', name_match.group(0)).lower() if name_match else '' | |
| 139 | version = exact.group(2) if exact else '' | |
| 140 | print(f'{name}|{version}|{entry}') | |
| 141 | " "$pyproject" 2>/dev/null | |
| 142 | } | |
| 143 | ||
| 144 | # parse_image_registry <image> | |
| 145 | # | |
| 146 | # Given a Docker image name (without tag), prints "registry|repo" where | |
| 147 | # registry is the domain and repo is the path within that registry. | |
| 148 | # | |
| 149 | # Examples: | |
| 150 | # parse_image_registry "nvcr.io/nvidia/cuda" → "nvcr.io|nvidia/cuda" | |
| 151 | # parse_image_registry "pytorch/pytorch" → "docker.io|pytorch/pytorch" | |
| 152 | # parse_image_registry "python" → "docker.io|library/python" | |
| 153 | # parse_image_registry "public.ecr.aws/eks/coredns" → "public.ecr.aws|eks/coredns" | |
| 154 | # parse_image_registry "docker.io/library/busybox" → "docker.io|library/busybox" | |
| 155 | # parse_image_registry "docker.io/python" → "docker.io|library/python" | |
| 156 | # | |
| 157 | # A first path component containing a dot, a port colon, or the literal | |
| 158 | # ``localhost`` is treated as a registry domain — the same heuristic container | |
| 159 | # runtimes apply — so a newly referenced registry needs no code change here. | |
| 160 | # The previous enumerated-registry list silently misparsed fully-qualified | |
| 161 | # Docker Hub references (``docker.io/library/busybox`` became repository | |
| 162 | # ``docker.io/library/busybox`` under a second ``docker.io``), which made | |
| 163 | # their tag lookups fail every month. | |
| 164 | 254 | parse_image_registry() { |
| 165 | 1343 | local image="$1" |
| 166 | 1343 | local registry="" repo="" first="" |
| 167 | 1343 | case "$image" in |
| 168 | */*) | |
| 169 | 973 | first="${image%%/*}" |
| 170 | 973 | case "$first" in |
| 171 | *.*|*:*|localhost) | |
| 172 | 620 | registry="$first" |
| 173 | 620 | repo="${image#*/}" |
| 174 | # Docker Hub keeps official images under the implicit library/ | |
| 175 | # namespace; restore it for a fully-qualified single-segment repo. | |
| 176 | 655 | if [ "$registry" = "docker.io" ] && [ "${repo#*/}" = "$repo" ]; then |
| 177 | 3 | repo="library/$repo" |
| 178 | fi | |
| 179 | ;; | |
| 180 | *) | |
| 181 | 353 | registry="docker.io" |
| 182 | 353 | repo="$image" |
| 183 | ;; | |
| 184 | esac | |
| 185 | ;; | |
| 186 | *) | |
| 187 | 370 | registry="docker.io" |
| 188 | 370 | repo="library/$image" |
| 189 | ;; | |
| 190 | esac | |
| 191 | 1343 | echo "${registry}|${repo}" |
| 192 | } | |
| 193 | ||
| 194 | # dedupe_image_refs | |
| 195 | # | |
| 196 | # Read ``image:tag`` lines on stdin and print each distinct image once, keyed | |
| 197 | # on the canonical ``registry/repo:tag`` from parse_image_registry, so every | |
| 198 | # spelling of one Docker Hub image (``python:3.14.8-slim``, | |
| 199 | # ``docker.io/library/python:3.14.8-slim``) is looked up and reported once. | |
| 200 | # The shortest spelling is kept for display (ties broken by sort order), and | |
| 201 | # the output is sorted. Blank lines are dropped; a line without a tag is kept | |
| 202 | # as-is (check_image skips it as a non-semver tag). | |
| 203 | dedupe_image_refs() { | |
| 204 | 190 | _image_ref_keys | sort -t "$(printf '\t')" -k1,1 -k2,2n -k3,3 | awk -F '\t' '$1 != previous { print $3; previous = $1 }' | sort |
| 205 | } | |
| 206 | ||
| 207 | # _image_ref_keys — dedupe_image_refs' first stage: one | |
| 208 | # ``<canonical ref>\t<length>\t<original ref>`` line per non-blank stdin line. | |
| 209 | _image_ref_keys() { | |
| 210 | 38 | local ref image tag parsed |
| 211 | 1126 | while IFS= read -r ref; do |
| 212 | 526 | [ -n "$ref" ] || continue |
| 213 | 524 | image="${ref%%:*}" |
| 214 | 524 | tag="${ref#*:}" |
| 215 | 524 | [ "$tag" = "$ref" ] && tag="" |
| 216 | 1048 | parsed="$(parse_image_registry "$image")" |
| 217 | 524 | printf '%s/%s:%s\t%d\t%s\n' "${parsed%%|*}" "${parsed#*|}" "$tag" "${#ref}" "$ref" |
| 218 | done | |
| 219 | } | |
| 220 | ||
| 221 | # is_semver_tag <tag> | |
| 222 | # | |
| 223 | # Returns 0 (true) if the tag looks like a semver version (v1.2.3, 1.2, etc). | |
| 224 | # Returns 1 (false) otherwise. | |
| 225 | 10 | is_semver_tag() { |
| 226 | 790 | echo "$1" | grep -qE "^v?[0-9]+\.[0-9]+(\.[0-9]+)?" |
| 227 | } | |
| 228 | ||
| 229 | # is_project_image <image> | |
| 230 | # | |
| 231 | # Returns 0 (true) if the image is built by this project (gco/*). | |
| 232 | 5 | is_project_image() { |
| 233 | 706 | echo "$1" | grep -q "^gco/" |
| 234 | } | |
| 235 | ||
| 236 | # compare_semver <current> <candidate> | |
| 237 | # | |
| 238 | # Prints "newer" if candidate is strictly newer than current (by sort -V), | |
| 239 | # "same" if they're equal, "older" otherwise. | |
| 240 | 8 | compare_semver() { |
| 241 | 660 | local current="${1#v}" |
| 242 | 660 | local candidate="${2#v}" |
| 243 | 660 | if [ "$current" = "$candidate" ]; then |
| 244 | 350 | echo "same" |
| 245 | 350 | return |
| 246 | fi | |
| 247 | 310 | local newest |
| 248 | 1240 | newest="$(printf '%s\n%s' "$current" "$candidate" | sort -V | tail -1)" |
| 249 | 310 | if [ "$newest" = "$candidate" ]; then |
| 250 | 277 | echo "newer" |
| 251 | else | |
| 252 | 33 | echo "older" |
| 253 | fi | |
| 254 | } | |
| 255 | ||
| 256 | # newer_same_variant_tag <current_tag> | |
| 257 | # | |
| 258 | # Reads a raw registry tag list on stdin (one tag per line) and prints the | |
| 259 | # newest tag in the *same variant family* as <current_tag> that is strictly | |
| 260 | # newer than it — or nothing when the pin is already the family's newest. | |
| 261 | # | |
| 262 | # A variant family shares the exact literal suffix after the leading numeric | |
| 263 | # version: ``24.01-py3`` compares only against ``NN[.NN…]-py3`` tags, | |
| 264 | # ``2.6.0-cuda12.6-cudnn9-runtime`` only against its identical variant | |
| 265 | # suffix, ``3.14.6-slim`` only against ``-slim`` tags, and a bare ``1.38.0`` | |
| 266 | # only against bare ``X.Y[.Z]`` tags. This mirrors the same-family scoping | |
| 267 | # the Bedrock model check uses: moving to a different variant (another CUDA | |
| 268 | # line, another base distro, dropping ``-slim``) is a human decision, not | |
| 269 | # drift, so it is never suggested. A leading ``v`` is accepted on either | |
| 270 | # side and ignored for comparison. | |
| 271 | # | |
| 272 | # The strict ``^v?X.Y.Z$`` filter this replaces matched nothing for every | |
| 273 | # suffix-tagged repository (NGC, GHCR Slurm, CUDA, PyTorch), which — under | |
| 274 | # ``pipefail`` — surfaced as a permanent "tag lookup failed" every month. | |
| 275 | # | |
| 276 | # Numeric components of five or more digits are treated as build/date | |
| 277 | # identifiers, not release numbers, and disqualify a candidate unless the | |
| 278 | # current pin itself carries one (a CalVer pin keeps comparing against | |
| 279 | # CalVer tags). Without this, ``alpine:3.21`` was "upgraded" to the | |
| 280 | # ``20260805`` date tag, ``kuberay/operator:v1.6.2`` to a ``9831375`` | |
| 281 | # commit-numbered tag, and ``ray:2.56.1`` to the ``2.57.0.397131`` nightly. | |
| 282 | 10 | newer_same_variant_tag() { |
| 283 | 307 | python3 -c " |
| 284 | import re, sys | |
| 285 | ||
| 286 | WIDE = 10000 # five digits: build number, date stamp, or commit counter | |
| 287 | ||
| 288 | current = sys.argv[1] | |
| 289 | match = re.match(r'^v?(\d+(?:\.\d+)*)(.*)\$', current) | |
| 290 | if not match: | |
| 291 | raise SystemExit(0) | |
| 292 | suffix = match.group(2) | |
| 293 | current_key = [int(part) for part in match.group(1).split('.')] | |
| 294 | current_is_calver = any(part >= WIDE for part in current_key) | |
| 295 | ||
| 296 | pattern = re.compile(r'^v?(\d+(?:\.\d+)*)' + re.escape(suffix) + r'\$') | |
| 297 | best_key = None | |
| 298 | best_tag = None | |
| 299 | for line in sys.stdin: | |
| 300 | tag = line.strip() | |
| 301 | if not tag: | |
| 302 | continue | |
| 303 | candidate = pattern.match(tag) | |
| 304 | if candidate is None: | |
| 305 | continue | |
| 306 | key = [int(part) for part in candidate.group(1).split('.')] | |
| 307 | if not current_is_calver and any(part >= WIDE for part in key): | |
| 308 | continue | |
| 309 | if key <= current_key: | |
| 310 | continue | |
| 311 | if best_key is None or key > best_key: | |
| 312 | best_key, best_tag = key, tag | |
| 313 | if best_tag: | |
| 314 | print(best_tag) | |
| 315 | " "$1" 2>/dev/null | |
| 316 | } | |
| 317 | ||
| 318 | # tag_listed <tag> <raw_tags> | |
| 319 | # | |
| 320 | # Whether <raw_tags> (a newline-separated registry tag list passed as one | |
| 321 | # argument, not on stdin) lists <tag> exactly, accepting a leading ``v`` on | |
| 322 | # either side. Exists because the obvious spelling — | |
| 323 | # ``printf '%s\n' "$raw_tags" | grep -qxF …`` — is wrong under ``pipefail`` | |
| 324 | # for large repositories: ``grep -q`` exits at the first match and closes | |
| 325 | # the pipe, ``printf`` takes SIGPIPE/EPIPE while still writing tag lists | |
| 326 | # bigger than the pipe buffer (python has ~3900 tags, tritonserver ~2600), | |
| 327 | # and the pipeline reports failure for a tag that is in fact listed. That | |
| 328 | # inverted into false "pinned tag is no longer listed" INCOMPLETE findings | |
| 329 | # in the 2026-09 scan. A herestring keeps grep's stdin writer-free, so | |
| 330 | # early exit has nothing to signal. | |
| 331 | 5 | tag_listed() { |
| 332 | 232 | local tag="$1" raw_tags="$2" |
| 333 | 232 | grep -qxF -e "$tag" -e "v${tag#v}" -e "${tag#v}" <<< "$raw_tags" |
| 334 | } | |
| 335 | ||
| 336 | # split_pinned_image_ref <repo:tag@sha256:digest> | |
| 337 | # | |
| 338 | # Validate and decompose a digest-pinned image reference, printing | |
| 339 | # ``repository|tag|digest`` on one line. Returns non-zero with no output for | |
| 340 | # anything that is not exactly the ``repo:tag@sha256:<64 hex>`` shape — | |
| 341 | # tag-only references, digest-only references, and malformed digests all | |
| 342 | # fail, so callers can gate the digest-freshness check on the return code. | |
| 343 | 2 | split_pinned_image_ref() { |
| 344 | 218 | local ref="$1" |
| 345 | 218 | if ! [[ "$ref" =~ ^([^@:]+(:[0-9]+)?(/[^@:]+)*):([^@]+)@(sha256:[0-9a-f]{64})$ ]]; then |
| 346 | 7 | return 1 |
| 347 | fi | |
| 348 | 211 | printf '%s|%s|%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}" |
| 349 | } | |
| 350 | ||
| 351 | # Transient-failure retries for the scan's registry and HTTP lookups. A single | |
| 352 | # dropped connection used to mark the whole monthly scan incomplete (and fail | |
| 353 | # its job): 2026-10-25, one public.ecr.aws manifest read for a tag that | |
| 354 | # resolved fine minutes later. Every lookup now retries before it counts as a | |
| 355 | # failure; a lookup that still fails is reported incomplete exactly as before. | |
| 356 | # | |
| 357 | # SCAN_CURL_RETRY: curl's own retry on timeouts, refused connections, 408, 429 | |
| 358 | # and 5xx (never on a 404 or another definite answer), bounded in total time | |
| 359 | # so a dead host still fails inside the job's budget. | |
| 360 | 852 | SCAN_CURL_RETRY=(--retry 2 --retry-connrefused --retry-max-time 30) |
| 361 | # SCAN_SKOPEO_RETRY: skopeo's built-in retry for transient registry errors. | |
| 362 | 852 | SCAN_SKOPEO_RETRY=(--retry-times 3) |
| 363 | ||
| 364 | # with_retries <attempts> <command...> | |
| 365 | # | |
| 366 | # Run <command> up to <attempts> times, stopping at the first success, with | |
| 367 | # DEPS_SCAN_RETRY_DELAY_SECONDS (default 3) between attempts. For tools that | |
| 368 | # have no retry option of their own (helm). The command's stdout passes | |
| 369 | # through only from the attempt that succeeded; a failed attempt's partial | |
| 370 | # output is discarded so a caller parsing it never sees a mix. Returns the | |
| 371 | # last attempt's exit status. | |
| 372 | with_retries() { | |
| 373 | 184 | local attempts="$1" attempt=1 status=0 output |
| 374 | 184 | shift |
| 375 | 197 | while :; do |
| 376 | 394 | if output="$("$@")"; then |
| 377 | 300 | [ -n "$output" ] && printf '%s\n' "$output" |
| 378 | 178 | return 0 |
| 379 | else | |
| 380 | 19 | status=$? |
| 381 | fi | |
| 382 | 25 | [ "$attempt" -ge "$attempts" ] && return "$status" |
| 383 | 13 | attempt=$((attempt + 1)) |
| 384 | 13 | sleep "${DEPS_SCAN_RETRY_DELAY_SECONDS:-3}" |
| 385 | done | |
| 386 | } | |
| 387 | ||
| 388 | # published_manifest_digest <repository:tag> | |
| 389 | # | |
| 390 | # Print the tag's current top-level manifest digest (``sha256:<64 hex>``) as | |
| 391 | # published by the registry. Hashes the raw manifest bytes rather than | |
| 392 | # selecting a platform child, because committed pins are multi-architecture | |
| 393 | # manifest-list digests. Returns non-zero with no output on transport | |
| 394 | # failure or an implausible hash, so callers distinguish "registry | |
| 395 | # unreachable" from "digest moved". | |
| 396 | published_manifest_digest() { | |
| 397 | 143 | local tagged_image="$1" manifest digest |
| 398 | 286 | manifest="$(mktemp)" |
| 399 | 143 | if ! skopeo inspect "${SCAN_SKOPEO_RETRY[@]}" --raw "docker://${tagged_image}" > "$manifest" 2>/dev/null; then |
| 400 | 6 | rm -f "$manifest" |
| 401 | 6 | return 1 |
| 402 | fi | |
| 403 | 411 | digest="sha256:$(sha256sum "$manifest" | awk '{print $1}')" |
| 404 | 137 | rm -f "$manifest" |
| 405 | 137 | if ! [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then |
| 406 | 1 | return 1 |
| 407 | fi | |
| 408 | 136 | printf '%s\n' "$digest" |
| 409 | } | |
| 410 | ||
| 411 | # parse_accelerator_drift_count <json-summary-file> | |
| 412 | # | |
| 413 | # Validates the machine-readable output from | |
| 414 | # ``scripts/accelerator_catalog.py check-online --json-summary`` and prints | |
| 415 | # its exact non-negative ``drift_count``. The status/count relationship is | |
| 416 | # checked as well: ``current`` means zero and ``drift`` means one or more. | |
| 417 | # | |
| 418 | # Returns non-zero with no output for a missing file, malformed JSON, a | |
| 419 | # missing/invalid count, or an inconsistent status. The dependency-scan | |
| 420 | # driver turns any such parser failure into one operational finding so a | |
| 421 | # broken online scan can never be reported as clean. | |
| 422 | parse_accelerator_drift_count() { | |
| 423 | 34 | local summary_file="$1" |
| 424 | 34 | [ -f "$summary_file" ] || return 1 |
| 425 | 34 | python3 -c " |
| 426 | import json, sys | |
| 427 | try: | |
| 428 | with open(sys.argv[1]) as handle: | |
| 429 | summary = json.load(handle) | |
| 430 | except (OSError, json.JSONDecodeError): | |
| 431 | raise SystemExit(1) | |
| 432 | if not isinstance(summary, dict): | |
| 433 | raise SystemExit(1) | |
| 434 | count = summary.get('drift_count') | |
| 435 | status = summary.get('status') | |
| 436 | if isinstance(count, bool) or not isinstance(count, int) or count < 0: | |
| 437 | raise SystemExit(1) | |
| 438 | if status not in {'current', 'drift'}: | |
| 439 | raise SystemExit(1) | |
| 440 | if (status == 'current') != (count == 0): | |
| 441 | raise SystemExit(1) | |
| 442 | print(count) | |
| 443 | " "$summary_file" 2>/dev/null | |
| 444 | } | |
| 445 | ||
| 446 | # extract_aurora_versions <file> | |
| 447 | # | |
| 448 | # Prints the pinned Aurora PostgreSQL engine version (``major.minor``) from | |
| 449 | # the constants module, importing it when possible and falling back to a | |
| 450 | # direct regex over ``constants.py``. The pin is a plain version string | |
| 451 | # applied through ``rds.AuroraPostgresEngineVersion.of()`` — there is no CDK | |
| 452 | # enum member to scan for. Output is validated to ``X.Y[.Z]`` shape so a | |
| 453 | # refactor of the constant can never leak a non-version into the RDS query. | |
| 454 | 23 | extract_aurora_versions() { |
| 455 | 82 | local file="${1:-gco/stacks/regional_stack.py}" |
| 456 | 82 | python3 -c " |
| 457 | import re, sys | |
| 458 | value = None | |
| 459 | try: | |
| 460 | from gco.stacks.constants import AURORA_POSTGRES_VERSION | |
| 461 | value = AURORA_POSTGRES_VERSION | |
| 462 | except ImportError: | |
| 463 | import os | |
| 464 | constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py') | |
| 465 | if os.path.exists(constants_path): | |
| 466 | with open(constants_path) as f: | |
| 467 | text = f.read() | |
| 468 | m = re.search(r'^AURORA_POSTGRES_VERSION\s*=\s*\"([^\"]+)\"', text, re.M) | |
| 469 | if m: | |
| 470 | value = m.group(1) | |
| 471 | if value and re.fullmatch(r'\d+\.\d+(?:\.\d+)?', value): | |
| 472 | print(value) | |
| 473 | " "$file" 2>/dev/null | sort -V | |
| 474 | } | |
| 475 | ||
| 476 | # extract_eks_addons <file> | |
| 477 | # | |
| 478 | # Extracts EKS addon name|version pairs from the constants module. | |
| 479 | # Falls back to reading constants.py directly if the module can't be imported. | |
| 480 | # Prints one "addon_name|addon_version" per line. | |
| 481 | 23 | extract_eks_addons() { |
| 482 | 80 | local file="${1:-gco/stacks/regional_stack.py}" |
| 483 | 82 | python3 -c " |
| 484 | import sys | |
| 485 | try: | |
| 486 | from gco.stacks.constants import ( | |
| 487 | EKS_ADDON_POD_IDENTITY_AGENT, | |
| 488 | EKS_ADDON_METRICS_SERVER, | |
| 489 | EKS_ADDON_EFS_CSI_DRIVER, | |
| 490 | EKS_ADDON_CLOUDWATCH_OBSERVABILITY, | |
| 491 | EKS_ADDON_FSX_CSI_DRIVER, | |
| 492 | ) | |
| 493 | addons = [ | |
| 494 | ('eks-pod-identity-agent', EKS_ADDON_POD_IDENTITY_AGENT), | |
| 495 | ('metrics-server', EKS_ADDON_METRICS_SERVER), | |
| 496 | ('aws-efs-csi-driver', EKS_ADDON_EFS_CSI_DRIVER), | |
| 497 | ('amazon-cloudwatch-observability', EKS_ADDON_CLOUDWATCH_OBSERVABILITY), | |
| 498 | ('aws-fsx-csi-driver', EKS_ADDON_FSX_CSI_DRIVER), | |
| 499 | ] | |
| 500 | for name, version in addons: | |
| 501 | print(f'{name}|{version}') | |
| 502 | except ImportError: | |
| 503 | # Fallback: read constants.py directly | |
| 504 | import re, os | |
| 505 | constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py') | |
| 506 | if os.path.exists(constants_path): | |
| 507 | with open(constants_path) as f: | |
| 508 | text = f.read() | |
| 509 | # Map constant names to addon names | |
| 510 | mapping = { | |
| 511 | 'EKS_ADDON_POD_IDENTITY_AGENT': 'eks-pod-identity-agent', | |
| 512 | 'EKS_ADDON_METRICS_SERVER': 'metrics-server', | |
| 513 | 'EKS_ADDON_EFS_CSI_DRIVER': 'aws-efs-csi-driver', | |
| 514 | 'EKS_ADDON_CLOUDWATCH_OBSERVABILITY': 'amazon-cloudwatch-observability', | |
| 515 | 'EKS_ADDON_FSX_CSI_DRIVER': 'aws-fsx-csi-driver', | |
| 516 | } | |
| 517 | for const_name, addon_name in mapping.items(): | |
| 518 | m = re.search(const_name + r'\s*=\s*\"([^\"]+)\"', text) | |
| 519 | if m: | |
| 520 | print(f'{addon_name}|{m.group(1)}') | |
| 521 | else: | |
| 522 | # Last resort: scan the file for inline addon_name/addon_version pairs | |
| 523 | with open(sys.argv[1]) as f: | |
| 524 | text = f.read() | |
| 525 | for m in re.finditer(r'addon_name=\"([^\"]+)\".*?addon_version=\"([^\"]+)\"', text, re.DOTALL): | |
| 526 | print(f'{m.group(1)}|{m.group(2)}') | |
| 527 | " "$file" 2>/dev/null | |
| 528 | } | |
| 529 | ||
| 530 | # extract_helm_charts [charts_yaml_path] | |
| 531 | # | |
| 532 | # Reads ``lambda/helm-installer/charts.yaml`` and prints one JSON object per | |
| 533 | # chart entry: ``{name, repo_url, chart, version, use_oci}``. Extracted from | |
| 534 | # dependency-scan.sh so BATS can exercise the real charts.yaml parse — the | |
| 535 | # driver sources this helper and pipes its output into the version-drift loop | |
| 536 | # (``helm search repo`` / ``helm show chart`` per entry). Because it iterates | |
| 537 | # every entry, a newly-added chart (e.g. kube-prometheus-stack) is picked up | |
| 538 | # automatically with no change here. | |
| 539 | # | |
| 540 | # Prints nothing (exit 0) when the file is missing or unparseable, matching the | |
| 541 | # other extractors in this file so the caller treats empty as "skip". | |
| 542 | 23 | extract_helm_charts() { |
| 543 | 86 | local file="${1:-lambda/helm-installer/charts.yaml}" |
| 544 | 89 | [ -f "$file" ] || return 0 |
| 545 | 83 | python3 -c " |
| 546 | import json, sys, yaml | |
| 547 | try: | |
| 548 | with open(sys.argv[1]) as f: | |
| 549 | data = yaml.safe_load(f) | |
| 550 | except Exception: | |
| 551 | sys.exit(0) | |
| 552 | for name, cfg in (data or {}).get('charts', {}).items(): | |
| 553 | cfg = cfg or {} | |
| 554 | print(json.dumps({ | |
| 555 | 'name': name, | |
| 556 | 'repo_url': cfg.get('repo_url', ''), | |
| 557 | 'chart': cfg.get('chart', ''), | |
| 558 | 'version': cfg.get('version', ''), | |
| 559 | 'use_oci': cfg.get('use_oci', False), | |
| 560 | })) | |
| 561 | " "$file" 2>/dev/null | |
| 562 | } | |
| 563 | ||
| 564 | # extract_k8s_version [cdk_json_path] | |
| 565 | # | |
| 566 | # Reads the kubernetes_version from cdk.json. Falls back to "1.37". | |
| 567 | 24 | extract_k8s_version() { |
| 568 | 182 | local cdk="${1:-cdk.json}" |
| 569 | 188 | python3 -c "import json; print(json.load(open('$cdk'))['context']['kubernetes_version'])" 2>/dev/null || echo "1.37" |
| 570 | } | |
| 571 | ||
| 572 | # extract_dockerfile_pins <dockerfile> | |
| 573 | # | |
| 574 | # Parses ``ARG <NAME>=<VALUE>`` lines from the given Dockerfile and emits | |
| 575 | # ``NAME|VALUE`` for each pin we care about. The allowlist below is | |
| 576 | # intentional — random build-time ARGs (e.g. ``BUILD_DATE``) would add | |
| 577 | # noise to the drift report. | |
| 578 | # | |
| 579 | # The line-anchor (``^ARG``) and single-line Python regex avoid matching | |
| 580 | # ``ARG`` appearing inside a comment or a RUN heredoc. Leading whitespace | |
| 581 | # is permitted so a future ``RUN --mount=…`` or multi-stage FROM line | |
| 582 | # doesn't break the scan silently. | |
| 583 | # | |
| 584 | # Example output for Dockerfile.dev: | |
| 585 | # | |
| 586 | # NODE_VERSION|v24.18.0 | |
| 587 | # NPM_VERSION|11.14.1 | |
| 588 | # CDK_VERSION|2.1120.0 | |
| 589 | # KUBECTL_VERSION|v1.37.1 | |
| 590 | # AWSCLI_VERSION|2.34.42 | |
| 591 | # DOCKER_VERSION|29.4.2 | |
| 592 | # BUILDX_VERSION|v0.35.0 | |
| 593 | # SESSION_MANAGER_PLUGIN_VERSION|1.2.896.0 | |
| 594 | 24 | extract_dockerfile_pins() { |
| 595 | 128 | local file="${1:-Dockerfile.dev}" |
| 596 | 131 | [ -f "$file" ] || return 0 |
| 597 | 125 | python3 -c " |
| 598 | import re, sys | |
| 599 | allowlist = { | |
| 600 | 'NODE_VERSION', | |
| 601 | 'NPM_VERSION', | |
| 602 | 'CDK_VERSION', | |
| 603 | 'KUBECTL_VERSION', | |
| 604 | 'AWSCLI_VERSION', | |
| 605 | 'DOCKER_VERSION', | |
| 606 | 'BUILDX_VERSION', | |
| 607 | 'UV_VERSION', | |
| 608 | 'SESSION_MANAGER_PLUGIN_VERSION', | |
| 609 | } | |
| 610 | with open(sys.argv[1]) as f: | |
| 611 | for line in f: | |
| 612 | # Strip trailing inline comments but keep the ARG value itself. | |
| 613 | stripped = line.split('#', 1)[0] | |
| 614 | m = re.match(r'^\s*ARG\s+([A-Z_][A-Z0-9_]*)=(\S+)\s*$', stripped) | |
| 615 | if not m: | |
| 616 | continue | |
| 617 | name, value = m.group(1), m.group(2) | |
| 618 | if name in allowlist: | |
| 619 | print(f'{name}|{value}') | |
| 620 | " "$file" 2>/dev/null | |
| 621 | } | |
| 622 | ||
| 623 | # extract_npm_direct_pins [package_json_path] | |
| 624 | # | |
| 625 | # Prints ``name|version`` for every exactly-pinned direct dependency in one | |
| 626 | # ``package.json`` — ``dependencies`` and ``devDependencies`` both count, since | |
| 627 | # every repository-owned graph pins tooling through devDependencies. Range | |
| 628 | # specifiers (``^``, ``~``, ``>=``, ``*``, tags) are skipped: the npm package | |
| 629 | # management check already fails graphs that carry them, and a range cannot | |
| 630 | # drift in the "pinned copy is behind latest" sense this scan reports. | |
| 631 | # Missing or malformed files print nothing, matching extract_dockerfile_pins. | |
| 632 | 24 | extract_npm_direct_pins() { |
| 633 | 92 | local file="${1:-package.json}" |
| 634 | 93 | [ -f "$file" ] || return 0 |
| 635 | 91 | python3 -c " |
| 636 | import json, re, sys | |
| 637 | ||
| 638 | try: | |
| 639 | with open(sys.argv[1]) as f: | |
| 640 | manifest = json.load(f) | |
| 641 | except (OSError, json.JSONDecodeError): | |
| 642 | sys.exit(0) | |
| 643 | ||
| 644 | exact = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$') | |
| 645 | for section in ('dependencies', 'devDependencies'): | |
| 646 | entries = manifest.get(section) | |
| 647 | if not isinstance(entries, dict): | |
| 648 | continue | |
| 649 | for name, version in sorted(entries.items()): | |
| 650 | if isinstance(version, str) and exact.match(version): | |
| 651 | print(f'{name}|{version}') | |
| 652 | " "$file" 2>/dev/null | |
| 653 | } | |
| 654 | ||
| 655 | # extract_precommit_hooks [config_path] | |
| 656 | # | |
| 657 | # Parses ``.pre-commit-config.yaml`` and emits one ``repo|rev`` pair per | |
| 658 | # hook ``repo:`` block. The repo URL is left intact (it's needed to | |
| 659 | # resolve the upstream releases endpoint), and ``rev`` is the literal | |
| 660 | # string committed to the config — usually a tag like ``v0.15.7`` or | |
| 661 | # ``v1.19.1`` but pre-commit also tolerates plain semver and full SHAs. | |
| 662 | # Local hook stanzas (``repo: local``) and the pre-commit hook | |
| 663 | # meta-stanza (``repo: meta``) are skipped: there's no upstream release | |
| 664 | # to compare against. | |
| 665 | # | |
| 666 | # Falls back silently to an empty list if the file is missing or the | |
| 667 | # YAML can't be parsed — the caller treats that as "skip" rather than | |
| 668 | # "no drift", same pattern as the other extractors in this file. | |
| 669 | 23 | extract_precommit_hooks() { |
| 670 | 86 | local file="${1:-.pre-commit-config.yaml}" |
| 671 | 89 | [ -f "$file" ] || return 0 |
| 672 | 83 | python3 -c " |
| 673 | import sys, yaml | |
| 674 | try: | |
| 675 | with open(sys.argv[1]) as f: | |
| 676 | data = yaml.safe_load(f) | |
| 677 | except Exception: | |
| 678 | sys.exit(0) | |
| 679 | for entry in (data or {}).get('repos', []) or []: | |
| 680 | repo = (entry or {}).get('repo', '') or '' | |
| 681 | rev = (entry or {}).get('rev', '') or '' | |
| 682 | # ``local`` and ``meta`` are pre-commit conventions for hooks | |
| 683 | # that aren't backed by an upstream repo; skip them. | |
| 684 | if not repo or repo in ('local', 'meta'): | |
| 685 | continue | |
| 686 | if not rev: | |
| 687 | continue | |
| 688 | print(f'{repo}|{rev}') | |
| 689 | " "$file" 2>/dev/null | |
| 690 | } | |
| 691 | ||
| 692 | # is_full_git_commit_sha <revision> | |
| 693 | # | |
| 694 | # Returns success only for a complete SHA-1 or SHA-256 object id. Pre-commit | |
| 695 | # accepts branch names and floating labels in ``rev`` too, so merely being | |
| 696 | # non-semver is not enough to call a ref immutable. | |
| 697 | 6 | is_full_git_commit_sha() { |
| 698 | 162 | [[ "$1" =~ ^[0-9a-fA-F]{40}$ || "$1" =~ ^[0-9a-fA-F]{64}$ ]] |
| 699 | } | |
| 700 | ||
| 701 | # extract_emr_versions <file> | |
| 702 | # | |
| 703 | # Extracts the pinned EMR Serverless release label from the constants module. | |
| 704 | # Prints the label (e.g. ``emr-7.13.0``) on a single line. Falls back to | |
| 705 | # reading constants.py directly if the module can't be imported. | |
| 706 | 23 | extract_emr_versions() { |
| 707 | 80 | local file="${1:-gco/stacks/constants.py}" |
| 708 | 80 | python3 -c " |
| 709 | import sys | |
| 710 | try: | |
| 711 | from gco.stacks.constants import EMR_SERVERLESS_RELEASE_LABEL | |
| 712 | print(EMR_SERVERLESS_RELEASE_LABEL) | |
| 713 | except ImportError: | |
| 714 | import re, os | |
| 715 | constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py') if 'constants.py' not in sys.argv[1] else sys.argv[1] | |
| 716 | if os.path.exists(constants_path): | |
| 717 | with open(constants_path) as f: | |
| 718 | text = f.read() | |
| 719 | m = re.search(r'EMR_SERVERLESS_RELEASE_LABEL\s*=\s*\"([^\"]+)\"', text) | |
| 720 | if m: | |
| 721 | print(m.group(1)) | |
| 722 | " "$file" 2>/dev/null | |
| 723 | } | |
| 724 | ||
| 725 | # list_emr_release_labels <region> | |
| 726 | # | |
| 727 | # Prints every release label ``emr list-release-labels`` knows in <region>, | |
| 728 | # one per line, following ``NextToken`` through every page. Fails (non-zero, | |
| 729 | # nothing printed) when a page cannot be fetched or parsed. | |
| 730 | # | |
| 731 | # Two things about this API decide the shape of the call. It pages at 100 | |
| 732 | # labels and the AWS CLI does not paginate it on its own, so a single call | |
| 733 | # sees the first page only. And EMR Serverless' current major line is not | |
| 734 | # named ``emr-8.x.y``: it is the "AWS runtime for Apache Spark" line, | |
| 735 | # ``emr-spark-8.x.y`` (``emr-spark-8.0.0`` GA, then ``emr-spark-8.1.0``), | |
| 736 | # and the service lists those labels *after* every classic ``emr-N.x.y`` | |
| 737 | # label — on the last page. The one-page, ``^emr-N.N.N$`` scan that this | |
| 738 | # replaces therefore reported the ``emr-7.14.0`` pin as up to date while a | |
| 739 | # newer major had been generally available for months. The application GCO | |
| 740 | # creates is ``type: SPARK``, for which both label families are valid. | |
| 741 | list_emr_release_labels() { | |
| 742 | 35 | local region="$1" token="" page |
| 743 | 66 | while :; do |
| 744 | 66 | if [ -n "$token" ]; then |
| 745 | 63 | page="$(aws emr list-release-labels --region "$region" --next-token "$token" \ |
| 746 | --output json 2>/dev/null)" || return 1 | |
| 747 | else | |
| 748 | 70 | page="$(aws emr list-release-labels --region "$region" --output json 2>/dev/null)" \ |
| 749 | 1 | || return 1 |
| 750 | fi | |
| 751 | 65 | jq -r '.ReleaseLabels[]?' <<< "$page" 2>/dev/null || return 1 |
| 752 | 126 | token="$(jq -r '.NextToken // empty' <<< "$page" 2>/dev/null)" || return 1 |
| 753 | 95 | [ -n "$token" ] || break |
| 754 | done | |
| 755 | } | |
| 756 | ||
| 757 | #: A GA EMR release label in either family: ``emr-7.14.0`` or ``emr-spark-8.1.0``. | |
| 758 | #: Preview, beta and release-candidate labels carry a suffix and do not match. | |
| 759 | 852 | EMR_RELEASE_LABEL_RE='^emr-(spark-)?[0-9]+\.[0-9]+\.[0-9]+$' |
| 760 | ||
| 761 | # emr_label_version <label> | |
| 762 | # | |
| 763 | # The numeric version of a release label in either family: | |
| 764 | # ``emr-7.14.0`` -> ``7.14.0``, ``emr-spark-8.1.0`` -> ``8.1.0``. | |
| 765 | 27 | emr_label_version() { |
| 766 | 233 | local version="${1#emr-}" |
| 767 | 233 | echo "${version#spark-}" |
| 768 | } | |
| 769 | ||
| 770 | # newest_emr_label [major] | |
| 771 | # | |
| 772 | # Reads release labels on stdin (one per line) and prints the one with the | |
| 773 | # highest version among the GA labels of both families — restricted to the | |
| 774 | # given major line when <major> is given. Prints nothing when no label | |
| 775 | # qualifies. Ordering is by version, not by label text, so | |
| 776 | # ``emr-spark-8.1.0`` ranks above ``emr-7.14.0`` even though ``sort -V`` on | |
| 777 | # the labels themselves would put every ``emr-spark-`` label first. | |
| 778 | 9 | newest_emr_label() { |
| 779 | 78 | local major="${1:-}" label version |
| 780 | 663 | while IFS= read -r label; do |
| 781 | 362 | [[ "$label" =~ $EMR_RELEASE_LABEL_RE ]] || continue |
| 782 | 380 | version="$(emr_label_version "$label")" |
| 783 | 350 | [ -z "$major" ] || [ "${version%%.*}" = "$major" ] || continue |
| 784 | 132 | printf '%s %s\n' "$version" "$label" |
| 785 | 234 | done | sort -V | tail -1 | cut -d' ' -f2- |
| 786 | 234 | } |
| 787 | 234 | |
| 788 | 234 | # extract_constant_value <name> [constants_path] |
| 789 | 234 | # |
| 790 | 234 | # Reads a single string-valued top-level constant from the constants |
| 791 | 234 | # module by regex (does *not* import ``gco.stacks``, which would pull |
| 792 | 234 | # in the full CDK stack package). Used by the CDK-enum drift checks |
| 793 | 234 | # below to look up ``LAMBDA_PYTHON_RUNTIME`` and ``AURORA_POSTGRES_VERSION`` |
| 794 | 234 | # without assuming the rest of the project is installable. |
| 795 | 234 | # |
| 796 | 234 | # Example: |
| 797 | 234 | # extract_constant_value LAMBDA_PYTHON_RUNTIME |
| 798 | 234 | # # → PYTHON_3_14 |
| 799 | 69 | extract_constant_value() { |
| 800 | 215 | local name="$1" |
| 801 | 215 | local file="${2:-gco/stacks/constants.py}" |
| 802 | 226 | [ -f "$file" ] || return 0 |
| 803 | 234 | python3 -c " |
| 804 | import re, sys | |
| 805 | name = sys.argv[1] | |
| 806 | with open(sys.argv[2]) as f: | |
| 807 | text = f.read() | |
| 808 | m = re.search(r'^' + re.escape(name) + r'\s*=\s*\"([^\"]+)\"', text, re.MULTILINE) | |
| 809 | if m: | |
| 810 | print(m.group(1)) | |
| 811 | " "$name" "$file" 2>/dev/null | |
| 812 | } | |
| 813 | ||
| 814 | # extract_python_string_constant <name> <python_path> | |
| 815 | # | |
| 816 | # Tokenizes the source without importing or executing it, then literal-evaluates | |
| 817 | # only the requested top-level assignment. Parsing only that expression keeps | |
| 818 | # extraction compatible when the rest of the module uses syntax newer than the | |
| 819 | # scanner's Python interpreter. Adjacent/parenthesized string literals remain | |
| 820 | # supported, while names, calls, and other executable expressions are rejected. | |
| 821 | 70 | extract_python_string_constant() { |
| 822 | 216 | local name="$1" |
| 823 | 216 | local file="$2" |
| 824 | 225 | [ -f "$file" ] || return 0 |
| 825 | 207 | python3 -c " |
| 826 | import ast, io, sys, tokenize | |
| 827 | try: | |
| 828 | with open(sys.argv[2], encoding='utf-8') as handle: | |
| 829 | source = handle.read() | |
| 830 | except OSError: | |
| 831 | raise SystemExit(0) | |
| 832 | tokens = tokenize.generate_tokens(io.StringIO(source).readline) | |
| 833 | try: | |
| 834 | for token in tokens: | |
| 835 | if not ( | |
| 836 | token.type == tokenize.NAME | |
| 837 | and token.string == sys.argv[1] | |
| 838 | and token.start[1] == 0 | |
| 839 | ): | |
| 840 | continue | |
| 841 | for token in tokens: | |
| 842 | if token.type not in (tokenize.NL, tokenize.COMMENT): | |
| 843 | break | |
| 844 | if token.type != tokenize.OP or token.string != '=': | |
| 845 | continue | |
| 846 | expression = [] | |
| 847 | depth = 0 | |
| 848 | for token in tokens: | |
| 849 | if token.type == tokenize.OP: | |
| 850 | if token.string in '([{': | |
| 851 | depth += 1 | |
| 852 | elif token.string in ')]}': | |
| 853 | depth -= 1 | |
| 854 | if token.type in (tokenize.NEWLINE, tokenize.ENDMARKER) and depth == 0: | |
| 855 | break | |
| 856 | expression.append((token.type, token.string)) | |
| 857 | try: | |
| 858 | value = ast.literal_eval(tokenize.untokenize(expression)) | |
| 859 | except (SyntaxError, ValueError): | |
| 860 | break | |
| 861 | if isinstance(value, str): | |
| 862 | print(value) | |
| 863 | break | |
| 864 | except tokenize.TokenError: | |
| 865 | pass | |
| 866 | " "$name" "$file" 2>/dev/null | |
| 867 | } | |
| 868 | ||
| 869 | # get_latest_lambda_python_runtime | |
| 870 | # | |
| 871 | # Imports ``aws_cdk.aws_lambda`` and prints the highest ``PYTHON_X_Y`` | |
| 872 | # enum member of ``Runtime`` (e.g. ``PYTHON_3_14``). Empty output when | |
| 873 | # aws-cdk-lib isn't importable — callers treat this as "skip". | |
| 874 | # | |
| 875 | # Used by the CDK-enum drift check in ``dependency-scan.sh`` to compare | |
| 876 | # the ``LAMBDA_PYTHON_RUNTIME`` constant against the newest Lambda | |
| 877 | # Python runtime the installed CDK can construct. The dep-scan workflow | |
| 878 | # installs the latest ``aws-cdk-lib`` for this; locally the helper just | |
| 879 | # reflects whatever is on the active interpreter. | |
| 880 | # | |
| 881 | # Suffixed members (``PYTHON_3_14_PROVIDED`` if it ever exists) are | |
| 882 | # ignored — the regex anchor on ``$`` keeps the result aligned with the | |
| 883 | # canonical "X.Y" runtime CDK exposes today. | |
| 884 | get_latest_lambda_python_runtime() { | |
| 885 | 34 | python3 -c " |
| 886 | import re | |
| 887 | try: | |
| 888 | from aws_cdk import aws_lambda | |
| 889 | except Exception: | |
| 890 | raise SystemExit(0) | |
| 891 | versions = [] | |
| 892 | for name in dir(aws_lambda.Runtime): | |
| 893 | m = re.match(r'^PYTHON_(\d+)_(\d+)$', name) | |
| 894 | if m: | |
| 895 | versions.append((int(m.group(1)), int(m.group(2)), name)) | |
| 896 | if versions: | |
| 897 | print(max(versions)[2]) | |
| 898 | " 2>/dev/null | |
| 899 | } | |
| 900 | ||
| 901 | # get_latest_lambda_nodejs_runtime | |
| 902 | # | |
| 903 | # Prints the highest canonical ``NODEJS_<major>_X`` member exposed by the | |
| 904 | # installed aws-cdk-lib. This is the Node equivalent of the Python helper | |
| 905 | # above and feeds the managed Lambda runtime drift check. | |
| 906 | get_latest_lambda_nodejs_runtime() { | |
| 907 | 34 | python3 -c " |
| 908 | import re | |
| 909 | try: | |
| 910 | from aws_cdk import aws_lambda | |
| 911 | except Exception: | |
| 912 | raise SystemExit(0) | |
| 913 | versions = [] | |
| 914 | for name in dir(aws_lambda.Runtime): | |
| 915 | m = re.match(r'^NODEJS_(\d+)_X$', name) | |
| 916 | if m: | |
| 917 | versions.append((int(m.group(1)), name)) | |
| 918 | if versions: | |
| 919 | print(max(versions)[1]) | |
| 920 | " 2>/dev/null | |
| 921 | } | |
| 922 | ||
| 923 | # get_latest_endoflife_cycle <product> | |
| 924 | # | |
| 925 | # Queries https://endoflife.date/api/<product>.json and prints the | |
| 926 | # highest ``cycle`` (e.g. ``3.14``) that's already shipped and still | |
| 927 | # under standard support. Empty output on network failure or schema | |
| 928 | # change — callers treat this as "skip" rather than as drift. | |
| 929 | # | |
| 930 | # We pick endoflife.date because it's a clean, unauthenticated JSON | |
| 931 | # endpoint that already filters out prerelease/EOL cycles via its | |
| 932 | # ``releaseDate`` and ``eol`` fields. Going through python.org, ruby-lang.org | |
| 933 | # or the upstream GitHub APIs would either rate-limit (no token) or | |
| 934 | # require us to hand-roll prerelease-tag filtering. | |
| 935 | # | |
| 936 | # Interpreters are pinned by *series* here (``3.14``, ``4.0``), not by patch, | |
| 937 | # because that's how .python-version and .ruby-version pin them: the patch is | |
| 938 | # whatever the runner or the prebuilt toolchain resolves to. | |
| 939 | get_latest_endoflife_cycle() { | |
| 940 | 75 | local product="$1" |
| 941 | 75 | curl -fsSL --max-time 15 "${SCAN_CURL_RETRY[@]}" "https://endoflife.date/api/${product}.json" 2>/dev/null \ |
| 942 | 75 | | python3 -c " |
| 943 | import datetime, json, sys | |
| 944 | try: | |
| 945 | data = json.load(sys.stdin) | |
| 946 | except Exception: | |
| 947 | sys.exit(0) | |
| 948 | today = datetime.date.today().isoformat() | |
| 949 | candidates = [] | |
| 950 | for entry in data: | |
| 951 | cycle = entry.get('cycle', '') | |
| 952 | release = entry.get('releaseDate', '') or '' | |
| 953 | eol = entry.get('eol', '') | |
| 954 | if not cycle or '.' not in cycle: | |
| 955 | continue | |
| 956 | # Skip prereleases (release date in the future). | |
| 957 | if isinstance(release, str) and release > today: | |
| 958 | continue | |
| 959 | # Skip end-of-life cycles. ``eol`` may be a string date or False | |
| 960 | # when EOL hasn't been announced yet — treat False/empty as still | |
| 961 | # supported. | |
| 962 | if isinstance(eol, str) and eol and eol < today: | |
| 963 | continue | |
| 964 | try: | |
| 965 | parts = tuple(int(p) for p in cycle.split('.')) | |
| 966 | except ValueError: | |
| 967 | continue | |
| 968 | candidates.append((parts, cycle)) | |
| 969 | if candidates: | |
| 970 | print(max(candidates)[1]) | |
| 971 | " 2>/dev/null | |
| 972 | } | |
| 973 | ||
| 974 | # get_latest_python_release | |
| 975 | # | |
| 976 | # The supported Python series, for the Lambda runtime comparison. | |
| 977 | get_latest_python_release() { | |
| 978 | 36 | get_latest_endoflife_cycle python |
| 979 | } | |
| 980 | ||
| 981 | # get_latest_ruby_release | |
| 982 | # | |
| 983 | # The supported Ruby series, for the .ruby-version comparison. Ruby is a | |
| 984 | # CI-only dependency (bashcov measures BATS shell coverage — see the Gemfile), | |
| 985 | # but it is pinned like every other toolchain, so it gets the same monthly | |
| 986 | # "is the pin still the current series?" check .python-version gets. | |
| 987 | get_latest_ruby_release() { | |
| 988 | 36 | get_latest_endoflife_cycle ruby |
| 989 | } | |
| 990 | ||
| 991 | # ubuntu_variant_release <image_tag> | |
| 992 | # | |
| 993 | # Prints the Ubuntu release an ``-ubuntuXX.YY`` image-tag variant names | |
| 994 | # (``26.05.5-ubuntu24.04`` → ``24.04``), nothing for any other tag. Slinky | |
| 995 | # publishes every Slurm image per base distribution, so the Ubuntu release is | |
| 996 | # the part of ``SLURM_IMAGE_TAG`` the LTS check in dependency-scan.sh reasons | |
| 997 | # about; a Rocky-based or bare tag is "not an Ubuntu variant", not an error. | |
| 998 | 23 | ubuntu_variant_release() { |
| 999 | 178 | printf '%s\n' "${1:-}" | sed -nE 's/^.*-ubuntu([0-9]{2}\.[0-9]{2})$/\1/p' |
| 1000 | } | |
| 1001 | ||
| 1002 | # get_latest_ubuntu_lts | |
| 1003 | # | |
| 1004 | # Prints the newest Ubuntu LTS cycle (``26.04``) that has shipped its first | |
| 1005 | # point release and is still supported, from endoflife.date. Interim | |
| 1006 | # (non-LTS) releases are never candidates, and a brand-new LTS stays out | |
| 1007 | # until its ``.1`` point release: that is the conventional "ready to adopt" | |
| 1008 | # marker (it is also when ``do-release-upgrade`` starts offering LTS→LTS), | |
| 1009 | # so the scan does not nag about a base the ecosystem has not settled on. | |
| 1010 | # Empty output on network failure or schema change — the caller treats that | |
| 1011 | # as a skip, not drift. | |
| 1012 | get_latest_ubuntu_lts() { | |
| 1013 | 38 | curl -fsSL --max-time 15 "${SCAN_CURL_RETRY[@]}" "https://endoflife.date/api/ubuntu.json" 2>/dev/null \ |
| 1014 | 38 | | python3 -c " |
| 1015 | import datetime, json, sys | |
| 1016 | try: | |
| 1017 | data = json.load(sys.stdin) | |
| 1018 | except Exception: | |
| 1019 | sys.exit(0) | |
| 1020 | today = datetime.date.today().isoformat() | |
| 1021 | candidates = [] | |
| 1022 | for entry in data: | |
| 1023 | cycle = str(entry.get('cycle', '') or '') | |
| 1024 | latest = str(entry.get('latest', '') or '') | |
| 1025 | release = entry.get('releaseDate', '') or '' | |
| 1026 | eol = entry.get('eol', '') | |
| 1027 | if entry.get('lts') is not True or '.' not in cycle: | |
| 1028 | continue | |
| 1029 | if isinstance(release, str) and release > today: | |
| 1030 | continue | |
| 1031 | if isinstance(eol, str) and eol and eol < today: | |
| 1032 | continue | |
| 1033 | # First point release out: ``latest`` reads ``<cycle>.N``. | |
| 1034 | if not latest.startswith(cycle + '.'): | |
| 1035 | continue | |
| 1036 | try: | |
| 1037 | parts = tuple(int(p) for p in cycle.split('.')) | |
| 1038 | except ValueError: | |
| 1039 | continue | |
| 1040 | candidates.append((parts, cycle)) | |
| 1041 | if candidates: | |
| 1042 | print(max(candidates)[1]) | |
| 1043 | " 2>/dev/null | |
| 1044 | } | |
| 1045 | ||
| 1046 | # read_ruby_version_pin [version_file] | |
| 1047 | # | |
| 1048 | # Prints the committed Ruby series from .ruby-version (comments and blank | |
| 1049 | # lines ignored), or nothing when the file is missing or holds no version. | |
| 1050 | # Empty output makes the caller skip rather than report false drift. | |
| 1051 | 23 | read_ruby_version_pin() { |
| 1052 | 86 | local version_file="${1:-.ruby-version}" |
| 1053 | 90 | [ -f "$version_file" ] || return 0 |
| 1054 | 82 | grep -vE '^[[:space:]]*(#|$)' "$version_file" 2>/dev/null \ |
| 1055 | 82 | | tr -d '[:space:]' \ |
| 1056 | 82 | | grep -oE '^(ruby-)?[0-9]+\.[0-9]+(\.[0-9]+)?$' \ |
| 1057 | 82 | | sed -E 's/^ruby-//' \ |
| 1058 | 82 | | head -1 |
| 1059 | } | |
| 1060 | ||
| 1061 | # get_latest_precommit_hook_release <repo_url> | |
| 1062 | # | |
| 1063 | # Given the ``repo:`` URL committed to ``.pre-commit-config.yaml``, | |
| 1064 | # prints the latest semver-shaped tag from the upstream Git host so | |
| 1065 | # the dep-scan can compare it against the pinned ``rev:``. Empty | |
| 1066 | # output on network failure, an unsupported host, or when no tag | |
| 1067 | # matches — callers treat that as "skip" rather than as drift. | |
| 1068 | # | |
| 1069 | # Today only GitHub repos are supported. Every hook in the project's | |
| 1070 | # ``.pre-commit-config.yaml`` is hosted there, and the pre-commit | |
| 1071 | # ecosystem is overwhelmingly GitHub-based. If a future hook lives | |
| 1072 | # elsewhere (GitLab, Codeberg) the helper will return empty and the | |
| 1073 | # scan logs a one-line skip note for that hook — no false drift. | |
| 1074 | # | |
| 1075 | # We use ``GET /repos/{owner}/{repo}/tags`` rather than | |
| 1076 | # ``releases/latest`` because pre-commit pins ``rev:`` to a Git tag, | |
| 1077 | # not a GitHub Release — and several hooks (yamllint, mirrors-mypy, | |
| 1078 | # markdownlint-cli2) tag without ever cutting a Release. The tags | |
| 1079 | # endpoint returns newest-first, so we filter to ``vX.Y.Z`` / | |
| 1080 | # ``X.Y.Z`` / ``X.Y`` shapes, drop pre-release suffixes (``-rc1``, | |
| 1081 | # ``-beta``), and take the highest by semver. | |
| 1082 | # | |
| 1083 | # Goes through ``github_api_get``, so the call is authenticated whenever | |
| 1084 | # ``GITHUB_TOKEN`` is set. The scan only makes a handful of these calls, | |
| 1085 | # but the anonymous 60 req/h bucket is keyed on the runner's *shared* | |
| 1086 | # egress IP, and a run that starts with that bucket empty loses every | |
| 1087 | # GitHub lookup at once (see github_api_get). | |
| 1088 | get_latest_precommit_hook_release() { | |
| 1089 | 80 | local repo_url="$1" |
| 1090 | 81 | [ -n "$repo_url" ] || return 0 |
| 1091 | ||
| 1092 | # Only GitHub is supported today. Strip any trailing ``.git`` or | |
| 1093 | # ``/`` so the owner/repo extraction works for both forms commonly | |
| 1094 | # seen in pre-commit configs. | |
| 1095 | 79 | local cleaned="${repo_url%.git}" |
| 1096 | 79 | cleaned="${cleaned%/}" |
| 1097 | 79 | case "$cleaned" in |
| 1098 | https://github.com/*) ;; | |
| 1099 | 3 | *) return 0 ;; |
| 1100 | esac | |
| 1101 | ||
| 1102 | 76 | local owner_repo="${cleaned#https://github.com/}" |
| 1103 | # Reject anything that isn't ``owner/repo`` (no extra path segments). | |
| 1104 | 224 | case "$owner_repo" in |
| 1105 | 1 | */*/*) return 0 ;; |
| 1106 | */*) ;; | |
| 1107 | 1 | *) return 0 ;; |
| 1108 | esac | |
| 1109 | ||
| 1110 | 74 | github_api_get "https://api.github.com/repos/${owner_repo}/tags?per_page=100" \ |
| 1111 | 74 | | python3 -c " |
| 1112 | import json, re, sys | |
| 1113 | try: | |
| 1114 | data = json.load(sys.stdin) | |
| 1115 | except Exception: | |
| 1116 | sys.exit(0) | |
| 1117 | # pre-commit's ``rev:`` accepts ``vX.Y[.Z]``, ``X.Y[.Z]``, or full | |
| 1118 | # SHAs. We compare on the semver-shaped ones; SHA-pinned hooks are | |
| 1119 | # left alone (the helper returns empty and the caller skips them). | |
| 1120 | pat = re.compile(r'^v?\d+\.\d+(?:\.\d+)?$') | |
| 1121 | candidates = [] | |
| 1122 | for entry in data or []: | |
| 1123 | name = (entry or {}).get('name', '') | |
| 1124 | if not pat.match(name): | |
| 1125 | continue | |
| 1126 | stripped = name.lstrip('v') | |
| 1127 | parts = stripped.split('.') | |
| 1128 | try: | |
| 1129 | nums = tuple(int(p) for p in parts) | |
| 1130 | except ValueError: | |
| 1131 | continue | |
| 1132 | candidates.append((nums, name)) | |
| 1133 | if candidates: | |
| 1134 | print(max(candidates)[1]) | |
| 1135 | " 2>/dev/null | |
| 1136 | } | |
| 1137 | ||
| 1138 | ||
| 1139 | # extract_mooncake_default_image [images_py_path] | |
| 1140 | # | |
| 1141 | # Prints the default upstream Mooncake vLLM image reference (``repo:tag``) | |
| 1142 | # pinned in ``cli/images.py`` as ``_DISAGGREGATED_DEFAULT_IMAGE`` — the image | |
| 1143 | # GCO's disaggregated/store/both inference deploys pull when the operator | |
| 1144 | # passes no ``--image``. | |
| 1145 | # | |
| 1146 | # This image lives in a Python constant, not a Dockerfile ``FROM`` or a K8s | |
| 1147 | # manifest, so neither Dependabot (docker ecosystem) nor the manifest/workflow | |
| 1148 | # image sweep in dependency-scan.sh sees it. This extractor feeds it into the | |
| 1149 | # Docker-image drift check so a newer vLLM release is surfaced in the monthly | |
| 1150 | # report — the cue to validate and bump the pin (the ``mooncake-image`` | |
| 1151 | # workflow re-runs the image contract tests against the new tag). | |
| 1152 | # | |
| 1153 | # Prints nothing if the file or constant is absent — the caller treats an | |
| 1154 | # empty result as "skip", same as the other extractors here. | |
| 1155 | 23 | extract_mooncake_default_image() { |
| 1156 | 85 | local file="${1:-cli/images.py}" |
| 1157 | 89 | [ -f "$file" ] || return 0 |
| 1158 | 81 | python3 -c " |
| 1159 | import re, sys | |
| 1160 | with open(sys.argv[1]) as f: | |
| 1161 | text = f.read() | |
| 1162 | m = re.search(r'^_DISAGGREGATED_DEFAULT_IMAGE\s*=\s*\"([^\"]+)\"', text, re.MULTILINE) | |
| 1163 | if m: | |
| 1164 | print(m.group(1)) | |
| 1165 | " "$file" 2>/dev/null | |
| 1166 | } | |
| 1167 | ||
| 1168 | # extract_chart_value_images [charts_yaml_path] | |
| 1169 | # | |
| 1170 | # Walks every ``values:`` block in charts.yaml and prints one fully-qualified | |
| 1171 | # ``image:tag`` per line for every mapping that pins BOTH a repository and a | |
| 1172 | # tag. Handles the two image-pin shapes the file uses: | |
| 1173 | # | |
| 1174 | # image: image: | |
| 1175 | # repository: example/app registry: ghcr.io | |
| 1176 | # tag: "1.2.3" repository: org/sub/app | |
| 1177 | # tag: "v1.2.3" | |
| 1178 | # | |
| 1179 | # The ``registry`` sibling must join the emitted reference: without it a | |
| 1180 | # ghcr-hosted repository is emitted bare and the tag sweep resolves it | |
| 1181 | # against docker.io — for multi-segment repositories that cannot exist | |
| 1182 | # there, turning every monthly scan into a false INCOMPLETE (first hit by | |
| 1183 | # kubeflow-trainer's controller pin, the first registry-split image with an | |
| 1184 | # explicit tag; KEDA's registry-split blocks are tag-less and never emit). | |
| 1185 | # | |
| 1186 | # Tag-less pins stay un-emitted on purpose: their images follow the chart's | |
| 1187 | # appVersion, which the Helm chart version sweep already reports. Registry- | |
| 1188 | # less single-segment repositories are skipped as before (ambiguous | |
| 1189 | # namespace). Prints nothing on a missing or unparseable file — the caller | |
| 1190 | # treats empty output as a broken parse, matching the smoke-manifest | |
| 1191 | # precedent (charts.yaml always carries pinned values images). | |
| 1192 | 23 | extract_chart_value_images() { |
| 1193 | 85 | local file="${1:-lambda/helm-installer/charts.yaml}" |
| 1194 | 89 | [ -f "$file" ] || return 0 |
| 1195 | 81 | python3 -c " |
| 1196 | import sys | |
| 1197 | import yaml | |
| 1198 | try: | |
| 1199 | with open(sys.argv[1]) as handle: | |
| 1200 | data = yaml.safe_load(handle) | |
| 1201 | except Exception: | |
| 1202 | sys.exit(0) | |
| 1203 | ||
| 1204 | ||
| 1205 | def find_images(node): | |
| 1206 | if isinstance(node, dict): | |
| 1207 | registry = node.get('registry', '') | |
| 1208 | repository = node.get('repository', '') | |
| 1209 | tag = node.get('tag', '') | |
| 1210 | if repository and tag: | |
| 1211 | if registry: | |
| 1212 | print(f'{registry}/{repository}:{tag}') | |
| 1213 | elif '/' in repository: | |
| 1214 | print(f'{repository}:{tag}') | |
| 1215 | for value in node.values(): | |
| 1216 | find_images(value) | |
| 1217 | elif isinstance(node, list): | |
| 1218 | for item in node: | |
| 1219 | find_images(item) | |
| 1220 | ||
| 1221 | ||
| 1222 | for _name, cfg in ((data or {}).get('charts') or {}).items(): | |
| 1223 | if isinstance(cfg, dict): | |
| 1224 | find_images(cfg.get('values') or {}) | |
| 1225 | " "$file" 2>/dev/null | |
| 1226 | } | |
| 1227 | ||
| 1228 | # extract_default_bedrock_model [cdk_json_path] [leaf_key] | |
| 1229 | # | |
| 1230 | # Prints a configured Bedrock model id from ``cdk.json`` | |
| 1231 | # ``context.bedrock.<leaf_key>`` (default leaf: ``mission_default_model_id``). | |
| 1232 | # The managed generation leaves are ``mission_default_model_id`` — what | |
| 1233 | # Mission sampling resolves through ``gco.bedrock`` when no explicit override | |
| 1234 | # is supplied — ``capacity_advisor_default_model_id`` — the capacity | |
| 1235 | # advisor's equivalent — and ``claude_code_default_model_id``, the session | |
| 1236 | # model ``gco autopilot`` hands to Claude Code. The keys are deliberately | |
| 1237 | # independent knobs. | |
| 1238 | # | |
| 1239 | # These values feed the Bedrock-model drift check in dependency-scan.sh, which | |
| 1240 | # compares each against the newest profile in the same model family | |
| 1241 | # (get_latest_bedrock_model). A newer release is the cue to update cdk.json | |
| 1242 | # and, for the Mission key, re-capture the scaffold fixture under | |
| 1243 | # tests/fixtures/scaffold_responses/. | |
| 1244 | # | |
| 1245 | # Prints nothing if the file is absent, malformed, or does not contain a | |
| 1246 | # non-empty string at the expected path. The caller treats empty output as a | |
| 1247 | # skip, matching the other extractors in this library. | |
| 1248 | 161 | extract_default_bedrock_model() { |
| 1249 | 563 | local file="${1:-cdk.json}" |
| 1250 | 563 | local leaf="${2:-mission_default_model_id}" |
| 1251 | # Optional third argument selects the context block (default: bedrock). | |
| 1252 | # The vector-store feature keeps its own independent embedding model at | |
| 1253 | # ``context.vector_store.embedding_model_id``; passing ``vector_store`` | |
| 1254 | # here lets the same extractor and drift plumbing manage it. | |
| 1255 | 563 | local block="${3:-bedrock}" |
| 1256 | 585 | [ -f "$file" ] || return 0 |
| 1257 | 541 | python3 -c " |
| 1258 | import json, sys | |
| 1259 | try: | |
| 1260 | with open(sys.argv[1]) as handle: | |
| 1261 | data = json.load(handle) | |
| 1262 | value = data.get('context', {}).get(sys.argv[3], {}).get(sys.argv[2]) | |
| 1263 | except Exception: | |
| 1264 | value = None | |
| 1265 | if isinstance(value, str) and value.strip(): | |
| 1266 | print(value.strip()) | |
| 1267 | " "$file" "$leaf" "$block" 2>/dev/null | |
| 1268 | } | |
| 1269 | ||
| 1270 | # bedrock_model_family <inference_profile_id> | |
| 1271 | # | |
| 1272 | # Prints the "model family" key for a Bedrock system-defined inference | |
| 1273 | # profile id so two releases of the same model line compare equal on | |
| 1274 | # family and differ only on version. The family is the geography + | |
| 1275 | # provider + the non-version tokens of the model name, with numeric model | |
| 1276 | # version/generation/date tokens dropped. Numeric tokens may be integers or | |
| 1277 | # dotted versions embedded between hyphens, or a single-letter generation | |
| 1278 | # marker such as Moonshot's ``k2.5`` / ``k3`` (a letter followed by the | |
| 1279 | # digits) when it follows the model's name word: | |
| 1280 | # | |
| 1281 | # us.amazon.nova-pro-v1:0 -> us.amazon.nova-pro | |
| 1282 | # global.amazon.nova-3-lite-v1:0 -> global.amazon.nova-lite | |
| 1283 | # us.anthropic.claude-sonnet-4-5-20250929-v1:0 -> us.anthropic.claude-sonnet | |
| 1284 | # global.anthropic.claude-opus-4-6-v1 -> global.anthropic.claude-opus | |
| 1285 | # global.anthropic.claude-opus-9 -> global.anthropic.claude-opus | |
| 1286 | # global.openai.gpt-5.7-sol -> global.openai.gpt-sol | |
| 1287 | # global.moonshotai.kimi-k4 -> global.moonshotai.kimi | |
| 1288 | # | |
| 1289 | # The trailing revision appears in three shapes across live profiles: | |
| 1290 | # ``-vMAJOR:MINOR``, ``-vMAJOR`` alone (newer Anthropic profiles), and | |
| 1291 | # absent entirely. All three are stripped, so one model line stays one | |
| 1292 | # family; matching only the ``:MINOR`` form would file | |
| 1293 | # ``claude-opus-4-6-v1`` under a phantom ``claude-opus-v1`` family and | |
| 1294 | # silently stop reporting drift against ``claude-opus-5``. | |
| 1295 | # | |
| 1296 | # Folding the numeric generation token into the version key (rather | |
| 1297 | # than the family) is deliberate: it keeps "Nova 1 Pro" and a future | |
| 1298 | # "Nova 2 Pro" in the same family so a generation bump is reported as | |
| 1299 | # drift, while different tiers (nova-pro vs nova-lite) and providers | |
| 1300 | # stay in separate families and are never cross-suggested. The | |
| 1301 | # single-letter marker rule exists for the same reason: without it | |
| 1302 | # ``kimi-k3`` would sit in its own ``kimi-k3`` family and a ``kimi-k4`` | |
| 1303 | # release would never be reported. The marker is only folded when it | |
| 1304 | # follows a name word, so ids whose entire name IS the marker | |
| 1305 | # (``deepseek.r1`` vs ``deepseek.v3``) keep separate families instead of | |
| 1306 | # collapsing into a provider-wide one. | |
| 1307 | 23 | bedrock_model_family() { |
| 1308 | 46 | python3 -c " |
| 1309 | import re, sys | |
| 1310 | mid = sys.argv[1] | |
| 1311 | core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid) | |
| 1312 | parts = core.split('.') | |
| 1313 | if len(parts) >= 3: | |
| 1314 | geo, provider, name = parts[0], parts[1], '.'.join(parts[2:]) | |
| 1315 | elif len(parts) == 2: | |
| 1316 | geo, provider, name = '', parts[0], parts[1] | |
| 1317 | else: | |
| 1318 | geo, provider, name = '', '', core | |
| 1319 | words = [t for t in name.split('-') if t] | |
| 1320 | tokens = [ | |
| 1321 | t for i, t in enumerate(words) | |
| 1322 | if not re.fullmatch(r'\d+(?:\.\d+)*', t) | |
| 1323 | and not (i > 0 and re.fullmatch(r'[A-Za-z]\d+(?:\.\d+)*', t)) | |
| 1324 | ] | |
| 1325 | prefix = '.'.join([p for p in (geo, provider) if p]) | |
| 1326 | print(prefix + ('.' + '-'.join(tokens) if tokens else '')) | |
| 1327 | " "$1" 2>/dev/null | |
| 1328 | } | |
| 1329 | ||
| 1330 | # compare_bedrock_model <current_id> <candidate_id> | |
| 1331 | # | |
| 1332 | # Prints "newer" when candidate is a newer release than current, | |
| 1333 | # "same" when equal, "older" otherwise — mirroring compare_semver's | |
| 1334 | # contract so the drift check reads the same way. The comparison key | |
| 1335 | # is the tuple of every integer in the id, left to right (model | |
| 1336 | # version, generation, date, and the trailing ``vMAJOR:MINOR``), so | |
| 1337 | # nova-pro-v1:0 (1,0) is older than a hypothetical nova-pro-v2:0 (2,0) | |
| 1338 | # and claude-sonnet-4-5-...-v1:0 (4,5,...) is older than a | |
| 1339 | # claude-sonnet-4-6-...-v1:0. Callers scope to one family first (see | |
| 1340 | # bedrock_model_family); this helper only looks at the integer key. | |
| 1341 | 12 | compare_bedrock_model() { |
| 1342 | 73 | python3 -c " |
| 1343 | import re, sys | |
| 1344 | def key(mid): | |
| 1345 | return [int(n) for n in re.findall(r'\d+', mid)] | |
| 1346 | a, b = key(sys.argv[1]), key(sys.argv[2]) | |
| 1347 | print('same' if a == b else ('newer' if b > a else 'older')) | |
| 1348 | " "$1" "$2" 2>/dev/null | |
| 1349 | } | |
| 1350 | ||
| 1351 | # get_latest_bedrock_model <current_id> [region] | |
| 1352 | # | |
| 1353 | # Prints the newest system-defined inference-profile id in the same | |
| 1354 | # model family as <current_id> (see bedrock_model_family), as reported | |
| 1355 | # by ``aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED``. | |
| 1356 | # Used by the Bedrock-model drift check to tell whether a pinned | |
| 1357 | # generation-model default has a newer release available. | |
| 1358 | # | |
| 1359 | # Family scoping keeps the comparison apples-to-apples: a newer Nova | |
| 1360 | # Pro is reported against a pinned Nova Pro, but a different tier (Nova | |
| 1361 | # Lite, a Claude model, ...) is never suggested as a "newer" default — | |
| 1362 | # switching tier/provider is a human decision, not drift. | |
| 1363 | # | |
| 1364 | # Region defaults to us-east-1 (matches the advisor + Mission sampling | |
| 1365 | # default region) regardless of the workflow's configured region, so both | |
| 1366 | # global.* and geography-scoped cross-Region profiles resolve consistently. | |
| 1367 | # Profiles without a numeric version key are ignored because they cannot be | |
| 1368 | # ordered safely. Empty output on any failure (no creds, throttling, schema | |
| 1369 | # change, or no comparable profile) tells the caller to mark the check skipped. | |
| 1370 | # | |
| 1371 | # IAM action: bedrock:ListInferenceProfiles. | |
| 1372 | get_latest_bedrock_model() { | |
| 1373 | 165 | local current="$1" |
| 1374 | 165 | local region="${2:-us-east-1}" |
| 1375 | 329 | if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then |
| 1376 | 1 | return 0 |
| 1377 | fi | |
| 1378 | 164 | aws bedrock list-inference-profiles \ |
| 1379 | --type-equals SYSTEM_DEFINED \ | |
| 1380 | --region "$region" \ | |
| 1381 | --output json 2>/dev/null \ | |
| 1382 | 164 | | python3 -c " |
| 1383 | import json, re, sys | |
| 1384 | current = sys.argv[1] | |
| 1385 | def family(mid): | |
| 1386 | # Keep in lockstep with bedrock_model_family above: the revision | |
| 1387 | # suffix is optional and its ``:MINOR`` half is too, and a | |
| 1388 | # single-letter generation marker (kimi-k3) folds like a number. | |
| 1389 | core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid) | |
| 1390 | parts = core.split('.') | |
| 1391 | if len(parts) >= 3: | |
| 1392 | geo, provider, name = parts[0], parts[1], '.'.join(parts[2:]) | |
| 1393 | elif len(parts) == 2: | |
| 1394 | geo, provider, name = '', parts[0], parts[1] | |
| 1395 | else: | |
| 1396 | geo, provider, name = '', '', core | |
| 1397 | words = [t for t in name.split('-') if t] | |
| 1398 | tokens = [ | |
| 1399 | t for i, t in enumerate(words) | |
| 1400 | if not re.fullmatch(r'\d+(?:\.\d+)*', t) | |
| 1401 | and not (i > 0 and re.fullmatch(r'[A-Za-z]\d+(?:\.\d+)*', t)) | |
| 1402 | ] | |
| 1403 | prefix = '.'.join([p for p in (geo, provider) if p]) | |
| 1404 | return prefix + ('.' + '-'.join(tokens) if tokens else '') | |
| 1405 | def key(mid): | |
| 1406 | return [int(n) for n in re.findall(r'\d+', mid)] | |
| 1407 | try: | |
| 1408 | data = json.load(sys.stdin) | |
| 1409 | except Exception: | |
| 1410 | sys.exit(0) | |
| 1411 | target = family(current) | |
| 1412 | cands = [] | |
| 1413 | for prof in data.get('inferenceProfileSummaries', []) or []: | |
| 1414 | pid = prof.get('inferenceProfileId', '') or '' | |
| 1415 | if (prof.get('status') or 'ACTIVE') != 'ACTIVE': | |
| 1416 | continue | |
| 1417 | if pid and key(pid) and family(pid) == target: | |
| 1418 | cands.append(pid) | |
| 1419 | if cands: | |
| 1420 | cands.sort(key=key) | |
| 1421 | print(cands[-1]) | |
| 1422 | " "$current" 2>/dev/null | |
| 1423 | } | |
| 1424 | ||
| 1425 | # get_latest_bedrock_embedding_model <current_id> [region] | |
| 1426 | # | |
| 1427 | # Prints the newest ACTIVE text-embedding foundation model in the same | |
| 1428 | # model family as <current_id> (see bedrock_model_family), as reported by | |
| 1429 | # ``aws bedrock list-foundation-models --by-output-modality EMBEDDING``. | |
| 1430 | # Used by the Bedrock-model drift check for | |
| 1431 | # ``context.bedrock.embedding_model_id`` — Mission memory's embedding | |
| 1432 | # default, resolved at runtime through | |
| 1433 | # ``gco.bedrock.get_default_embedding_model_id()``. | |
| 1434 | # | |
| 1435 | # Embedding models are plain foundation models, not system-defined | |
| 1436 | # inference profiles, so this is a separate lookup from | |
| 1437 | # get_latest_bedrock_model; the family scoping and integer-tuple version | |
| 1438 | # ranking are deliberately identical. A newer same-family release (e.g. a | |
| 1439 | # Titan Text Embeddings v3) is reported as drift; a different provider or | |
| 1440 | # model line never is. Embedding drift is advisory-with-a-caveat: stored | |
| 1441 | # vectors are only comparable to vectors from the same model, so adopting | |
| 1442 | # a newer embedding model means re-embedding or segregating existing | |
| 1443 | # data, not just bumping the pin (the drift report's remediation text | |
| 1444 | # says so). | |
| 1445 | # | |
| 1446 | # Region defaults to us-east-1 (the Mission memory default region). | |
| 1447 | # Models without a numeric version key are ignored because they cannot be | |
| 1448 | # ordered safely; non-ACTIVE lifecycles (e.g. LEGACY) are skipped. Empty | |
| 1449 | # output on any failure tells the caller to mark the check skipped. | |
| 1450 | # | |
| 1451 | # IAM action: bedrock:ListFoundationModels (already granted to the scan | |
| 1452 | # role alongside bedrock:ListInferenceProfiles). | |
| 1453 | get_latest_bedrock_embedding_model() { | |
| 1454 | 67 | local current="$1" |
| 1455 | 67 | local region="${2:-us-east-1}" |
| 1456 | 133 | if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then |
| 1457 | 1 | return 0 |
| 1458 | fi | |
| 1459 | 66 | aws bedrock list-foundation-models \ |
| 1460 | --by-output-modality EMBEDDING \ | |
| 1461 | --region "$region" \ | |
| 1462 | --output json 2>/dev/null \ | |
| 1463 | 66 | | python3 -c " |
| 1464 | import json, re, sys | |
| 1465 | current = sys.argv[1] | |
| 1466 | def family(mid): | |
| 1467 | # Keep in lockstep with bedrock_model_family above: the revision | |
| 1468 | # suffix is optional and its ``:MINOR`` half is too, and a | |
| 1469 | # single-letter generation marker (kimi-k3) folds like a number. | |
| 1470 | core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid) | |
| 1471 | parts = core.split('.') | |
| 1472 | if len(parts) >= 3: | |
| 1473 | geo, provider, name = parts[0], parts[1], '.'.join(parts[2:]) | |
| 1474 | elif len(parts) == 2: | |
| 1475 | geo, provider, name = '', parts[0], parts[1] | |
| 1476 | else: | |
| 1477 | geo, provider, name = '', '', core | |
| 1478 | words = [t for t in name.split('-') if t] | |
| 1479 | tokens = [ | |
| 1480 | t for i, t in enumerate(words) | |
| 1481 | if not re.fullmatch(r'\d+(?:\.\d+)*', t) | |
| 1482 | and not (i > 0 and re.fullmatch(r'[A-Za-z]\d+(?:\.\d+)*', t)) | |
| 1483 | ] | |
| 1484 | prefix = '.'.join([p for p in (geo, provider) if p]) | |
| 1485 | return prefix + ('.' + '-'.join(tokens) if tokens else '') | |
| 1486 | def key(mid): | |
| 1487 | return [int(n) for n in re.findall(r'\d+', mid)] | |
| 1488 | try: | |
| 1489 | data = json.load(sys.stdin) | |
| 1490 | except Exception: | |
| 1491 | sys.exit(0) | |
| 1492 | target = family(current) | |
| 1493 | cands = [] | |
| 1494 | for model in data.get('modelSummaries', []) or []: | |
| 1495 | mid = model.get('modelId', '') or '' | |
| 1496 | lifecycle = (model.get('modelLifecycle') or {}).get('status') or 'ACTIVE' | |
| 1497 | if lifecycle != 'ACTIVE': | |
| 1498 | continue | |
| 1499 | if mid and key(mid) and family(mid) == target: | |
| 1500 | cands.append(mid) | |
| 1501 | if cands: | |
| 1502 | cands.sort(key=key) | |
| 1503 | print(cands[-1]) | |
| 1504 | " "$current" 2>/dev/null | |
| 1505 | } | |
| 1506 | ||
| 1507 | # ============================================================================= | |
| 1508 | # Expanded coverage helpers | |
| 1509 | # ============================================================================= | |
| 1510 | # The functions below extend the scan to surfaces that live outside the | |
| 1511 | # original twelve: CI tooling pins the workflows install by hand, version | |
| 1512 | # pins that must move in lockstep across several files, and recurring | |
| 1513 | # hygiene checks (suppression expiry, lockfile freshness, base-image | |
| 1514 | # security epochs). | |
| 1515 | # | |
| 1516 | # Every one keeps the same contract as the extractors above: print one | |
| 1517 | # record per line to stdout, and print nothing (exit 0) on missing/malformed | |
| 1518 | # input so the caller treats an empty result as "skip", never as drift. | |
| 1519 | # ============================================================================= | |
| 1520 | ||
| 1521 | # github_api_get <url> | |
| 1522 | # | |
| 1523 | # The one code path for every ``api.github.com`` read the scan makes | |
| 1524 | # (release and tag lookups for the CI tooling, the pre-commit hooks, and the | |
| 1525 | # Dockerfile.dev pins). Prints the response body on success and nothing on | |
| 1526 | # failure, like the ``curl -fsSL`` calls it replaces, so callers keep treating | |
| 1527 | # empty as "skip". | |
| 1528 | # | |
| 1529 | # Authentication is the point. Unauthenticated requests draw from a 60 req/h | |
| 1530 | # bucket keyed on the *client IP*, and on a GitHub-hosted runner that IP is | |
| 1531 | # shared with every other tenant's job on the same host — so a scan can start | |
| 1532 | # with the bucket already empty and every GitHub lookup fails at once while | |
| 1533 | # PyPI, npm and the OCI registries (different hosts, different limits) all | |
| 1534 | # succeed. That is exactly the failure shape of a partial monthly report. When | |
| 1535 | # ``GITHUB_TOKEN`` (the workflow passes the job's own token) or ``GH_TOKEN`` | |
| 1536 | # (what a developer running ``gco deps scan`` has for the gh CLI) is set, the | |
| 1537 | # call is sent with a bearer header and lands in the 5 000 req/h per-token | |
| 1538 | # bucket instead; ``contents: read`` is enough because everything read here is | |
| 1539 | # public. With neither set the call stays anonymous, so the scan still works | |
| 1540 | # from a laptop with no token at all. | |
| 1541 | # | |
| 1542 | # A refused token falls back to an anonymous read. Some organisations block | |
| 1543 | # the GitHub Actions app, and then a workflow's token gets 401/403 on their | |
| 1544 | # *public* repositories while an anonymous request to the same URL returns | |
| 1545 | # 200 — aquasecurity, whose Trivy releases this scan reads, behaves this way | |
| 1546 | # (``verify_action_pins.py`` carries the same fallback for the same reason). | |
| 1547 | # A refusal is told apart from a rate limit by the quota headers: a limit | |
| 1548 | # leaves ``x-ratelimit-remaining: 0`` (or answers 429, or sets | |
| 1549 | # ``retry-after``); a refusal leaves quota on the table. | |
| 1550 | # | |
| 1551 | # Failure diagnostics: the scan used to hide *why* a GitHub call failed | |
| 1552 | # (``-f ... 2>/dev/null``), which made a rate-limited run indistinguishable | |
| 1553 | # from an upstream outage in the report. The cause of the last failed call is | |
| 1554 | # written to the file named by ``GITHUB_API_FAILURE_FILE`` so | |
| 1555 | # ``github_api_failure_hint`` can put it in the incomplete-lookup line. A | |
| 1556 | # file rather than a variable because every caller runs this helper inside a | |
| 1557 | # ``$(...)`` capture, where a shell variable would die with the subshell (the | |
| 1558 | # same reason ``mark_scan_incomplete`` appends to a file). A successful call | |
| 1559 | # clears it and the hint consumes it, so it can only ever describe the call | |
| 1560 | # that just failed. | |
| 1561 | 1704 | GITHUB_API_FAILURE_FILE="${GITHUB_API_FAILURE_FILE:-$(mktemp)}" |
| 1562 | ||
| 1563 | _github_api_record_failure() { | |
| 1564 | 76 | printf '%s\n' "$1" > "$GITHUB_API_FAILURE_FILE" |
| 1565 | } | |
| 1566 | ||
| 1567 | # _github_api_request <url> <token> | |
| 1568 | # | |
| 1569 | # One request. Prints the body and returns 0 on HTTP 200; otherwise records | |
| 1570 | # the failure and returns 1 (no response), 2 (rate limited), 3 (the token was | |
| 1571 | # refused with quota left, so an anonymous retry is worth making), or 4 (any | |
| 1572 | # other status). ``<token>`` may be empty for an anonymous request. | |
| 1573 | _github_api_request() { | |
| 1574 | 548 | local url="$1" token="$2" |
| 1575 | 548 | local headers body code remaining retry_after |
| 1576 | 1096 | headers="$(mktemp)" |
| 1577 | 1096 | local -a auth=() |
| 1578 | 548 | if [ -n "$token" ]; then |
| 1579 | 52 | auth=(-H "Authorization: Bearer ${token}") |
| 1580 | fi | |
| 1581 | # ``-f`` is deliberately absent: the headers of a failed call are what tell | |
| 1582 | # a rate limit apart from an outage. ``--retry`` covers transport flakes | |
| 1583 | # (timeouts, resets) with curl's own backoff, capped so a ``retry-after`` | |
| 1584 | # of a minute cannot stall the scan; a 403 is a policy answer and is not | |
| 1585 | # retried. The empty-array expansion is written the bash 3.2 way because | |
| 1586 | # ``gco deps scan`` runs this under whatever ``bash`` is on PATH, which on | |
| 1587 | # macOS is 3.2 with ``set -u`` active. | |
| 1588 | 1096 | if ! body="$(curl -sSL --max-time 15 --retry 2 --retry-connrefused --retry-max-time 30 \ |
| 1589 | -D "$headers" \ | |
| 1590 | -H "Accept: application/vnd.github+json" \ | |
| 1591 | -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| 1592 | ${auth[@]+"${auth[@]}"} \ | |
| 1593 | "$url" 2>/dev/null)"; then | |
| 1594 | 17 | rm -f "$headers" |
| 1595 | 17 | _github_api_record_failure "transport error" |
| 1596 | 17 | return 1 |
| 1597 | fi | |
| 1598 | # With ``-L`` the header dump holds one block per hop; the final status is | |
| 1599 | # the last one. Header names are case-insensitive and lines end in CRLF. | |
| 1600 | 1062 | code="$(awk 'toupper($1) ~ /^HTTP\// { code = $2 } END { print code }' "$headers")" |
| 1601 | 1062 | remaining="$(awk 'tolower($1) == "x-ratelimit-remaining:" { v = $2 } END { gsub(/\r/, "", v); print v }' "$headers")" |
| 1602 | 1062 | retry_after="$(awk 'tolower($1) == "retry-after:" { v = $2 } END { gsub(/\r/, "", v); print v }' "$headers")" |
| 1603 | 531 | rm -f "$headers" |
| 1604 | 531 | if [ "$code" = "200" ]; then |
| 1605 | 474 | : > "$GITHUB_API_FAILURE_FILE" |
| 1606 | 474 | printf '%s' "$body" |
| 1607 | 474 | return 0 |
| 1608 | fi | |
| 1609 | 57 | if [ "$code" = "429" ] \ |
| 1610 | 129 | || { [ "$code" = "403" ] && { [ "${remaining:-}" = "0" ] || [ -n "$retry_after" ]; }; }; then |
| 1611 | 35 | if [ -n "$token" ]; then |
| 1612 | 16 | _github_api_record_failure "rate limit exceeded for the authenticated token" |
| 1613 | else | |
| 1614 | 19 | _github_api_record_failure "rate limit exceeded on the anonymous 60 req/h bucket, set GITHUB_TOKEN" |
| 1615 | fi | |
| 1616 | 35 | return 2 |
| 1617 | fi | |
| 1618 | 59 | if [ -n "$token" ] && { [ "$code" = "401" ] || [ "$code" = "403" ]; }; then |
| 1619 | 19 | _github_api_record_failure "HTTP ${code}, token refused" |
| 1620 | 19 | return 3 |
| 1621 | fi | |
| 1622 | 3 | _github_api_record_failure "HTTP ${code:-000}" |
| 1623 | 3 | return 4 |
| 1624 | } | |
| 1625 | ||
| 1626 | github_api_get() { | |
| 1627 | 530 | local url="$1" |
| 1628 | 531 | [ -n "$url" ] || return 1 |
| 1629 | 529 | local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" |
| 1630 | 529 | local rc=0 refused |
| 1631 | 601 | _github_api_request "$url" "$token" || rc=$? |
| 1632 | 529 | if [ "$rc" -ne 3 ]; then |
| 1633 | 510 | return "$rc" |
| 1634 | fi | |
| 1635 | 19 | refused="$(< "$GITHUB_API_FAILURE_FILE")" |
| 1636 | 19 | rc=0 |
| 1637 | 21 | _github_api_request "$url" "" || rc=$? |
| 1638 | 19 | if [ "$rc" -ne 0 ]; then |
| 1639 | 2 | _github_api_record_failure "${refused}, then anonymous retry: $(< "$GITHUB_API_FAILURE_FILE")" |
| 1640 | fi | |
| 1641 | 19 | return "$rc" |
| 1642 | } | |
| 1643 | ||
| 1644 | # github_api_failure_hint | |
| 1645 | # | |
| 1646 | # Prints a parenthesised suffix for an incomplete-lookup reason describing | |
| 1647 | # the last ``github_api_get`` failure, or nothing when the last call | |
| 1648 | # succeeded or none was made, and clears the record so a later reason for an | |
| 1649 | # unrelated lookup can never inherit it. The rate-limit text says which | |
| 1650 | # bucket ran dry, which is the one-line remediation (export ``GITHUB_TOKEN``). | |
| 1651 | github_api_failure_hint() { | |
| 1652 | 59 | local failure="" |
| 1653 | 113 | [ -s "$GITHUB_API_FAILURE_FILE" ] && failure="$(< "$GITHUB_API_FAILURE_FILE")" |
| 1654 | 59 | : > "$GITHUB_API_FAILURE_FILE" |
| 1655 | 64 | [ -n "$failure" ] || return 0 |
| 1656 | 54 | printf ' (GitHub API: %s)' "$failure" |
| 1657 | } | |
| 1658 | ||
| 1659 | # get_latest_github_release_tag <owner/repo> | |
| 1660 | # | |
| 1661 | # Prints the ``tag_name`` of the latest non-prerelease GitHub Release for | |
| 1662 | # ``<owner/repo>`` (e.g. ``v0.70.0``). Generalises the inline release lookups | |
| 1663 | # the Dockerfile.dev section already does for moby/moby and docker/buildx so | |
| 1664 | # the CI-tooling drift check (Trivy, Helm, kind) can share one code path. | |
| 1665 | # | |
| 1666 | # Empty output on network failure, a non ``owner/repo`` argument, or a repo | |
| 1667 | # with no published Release — callers treat empty as "skip", same as the | |
| 1668 | # other lookups here. Goes through ``github_api_get`` so the call is | |
| 1669 | # authenticated whenever ``GITHUB_TOKEN`` is set (see that helper for why the | |
| 1670 | # anonymous bucket is not enough on a shared runner). | |
| 1671 | get_latest_github_release_tag() { | |
| 1672 | 276 | local owner_repo="$1" |
| 1673 | 277 | [ -n "$owner_repo" ] || return 0 |
| 1674 | # Reject anything that isn't exactly ``owner/repo`` (mirrors the guard in | |
| 1675 | # get_latest_precommit_hook_release) so a stray URL or path can't 404. | |
| 1676 | 821 | case "$owner_repo" in |
| 1677 | 1 | */*/*) return 0 ;; |
| 1678 | */*) ;; | |
| 1679 | 1 | *) return 0 ;; |
| 1680 | esac | |
| 1681 | 273 | github_api_get "https://api.github.com/repos/${owner_repo}/releases/latest" \ |
| 1682 | 273 | | jq -r '.tag_name // empty' 2>/dev/null |
| 1683 | } | |
| 1684 | ||
| 1685 | # extract_workflow_env_pin <VAR_NAME> [workflows_dir] | |
| 1686 | # | |
| 1687 | # Prints the unique value(s) of a ``<VAR_NAME>: "<value>"`` env assignment | |
| 1688 | # found across the workflow YAML under <workflows_dir> (default | |
| 1689 | # ``.github/workflows``). Used by the CI-tooling drift check to read the | |
| 1690 | # pinned ``KUBECONFORM_VERSION`` / ``CALICO_VERSION`` / ``METRICS_SERVER_VERSION`` the | |
| 1691 | # workflows install their own tooling from — pins Dependabot doesn't watch | |
| 1692 | # (they're plain env strings, not ``uses:`` refs or Dockerfile ``FROM`` | |
| 1693 | # lines). | |
| 1694 | # | |
| 1695 | # Prints one value per line, de-duplicated and sorted. More than one line | |
| 1696 | # means the same tool is pinned to *different* values across files — a | |
| 1697 | # lockstep-drift bug the Version Consistency section reports. Empty output | |
| 1698 | # when the dir is absent or the var is unset anywhere. | |
| 1699 | 92 | extract_workflow_env_pin() { |
| 1700 | 543 | local var="$1" |
| 1701 | 543 | local dir="${2:-.github/workflows}" |
| 1702 | 543 | [ -n "$var" ] || return 0 |
| 1703 | 560 | [ -d "$dir" ] || return 0 |
| 1704 | 526 | grep -rhoE "^[[:space:]]*${var}:[[:space:]]*\"?[A-Za-z0-9._+-]+\"?" "$dir" 2>/dev/null \ |
| 1705 | 526 | | sed -E "s/^[[:space:]]*${var}:[[:space:]]*//" \ |
| 1706 | 526 | | tr -d '"' \ |
| 1707 | 526 | | sort -u |
| 1708 | } | |
| 1709 | ||
| 1710 | # extract_crossplane_function_packages <manifest> <function-name> | |
| 1711 | # | |
| 1712 | # Prints every Crossplane package reference (``spec.package``) in <manifest> | |
| 1713 | # whose repository is ``<registry>/<org>/<function-name>``, exactly as written, | |
| 1714 | # one per line, de-duplicated and sorted. post-helm-crossplane.yaml pins | |
| 1715 | # function-go-templating as ``<repo>:<tag>@sha256:<index digest>``. The | |
| 1716 | # reference is an xpkg OCI package, not a container ``image:``, so neither the | |
| 1717 | # manifest image sweep nor Dependabot sees it. The manifest is parsed as YAML, | |
| 1718 | # not grepped, because the digest-pinned reference is too long for the | |
| 1719 | # ``package:`` line and sits on the next one. GCO's ``{{TOKEN}}`` placeholders | |
| 1720 | # are neutralised first, so an unrendered template still parses. Empty output | |
| 1721 | # when the manifest is absent or unparseable, the name is blank, or no such | |
| 1722 | # package is named. | |
| 1723 | 46 | extract_crossplane_function_packages() { |
| 1724 | 175 | local manifest="$1" name="$2" |
| 1725 | 177 | [ -n "$name" ] || return 0 |
| 1726 | 181 | [ -f "$manifest" ] || return 0 |
| 1727 | 165 | python3 -c " |
| 1728 | import re, sys, yaml | |
| 1729 | try: | |
| 1730 | with open(sys.argv[1], encoding='utf-8') as f: | |
| 1731 | text = re.sub(r'\{\{[A-Za-z0-9_]+\}\}', 'placeholder', f.read()) | |
| 1732 | documents = list(yaml.safe_load_all(text)) | |
| 1733 | except Exception: | |
| 1734 | sys.exit(0) | |
| 1735 | found = set() | |
| 1736 | for document in documents: | |
| 1737 | spec = document.get('spec') if isinstance(document, dict) else None | |
| 1738 | package = spec.get('package') if isinstance(spec, dict) else None | |
| 1739 | if not isinstance(package, str): | |
| 1740 | continue | |
| 1741 | last = package.strip().split('@', 1)[0].rsplit('/', 1)[-1] | |
| 1742 | if last.split(':', 1)[0] == sys.argv[2]: | |
| 1743 | found.add(package.strip()) | |
| 1744 | for package in sorted(found): | |
| 1745 | print(package) | |
| 1746 | " "$manifest" "$name" 2>/dev/null | |
| 1747 | } | |
| 1748 | ||
| 1749 | # extract_crossplane_function_pin <manifest> <function-name> | |
| 1750 | # | |
| 1751 | # Prints the release tag of every package reference | |
| 1752 | # extract_crossplane_function_packages finds, one per line, de-duplicated and | |
| 1753 | # sorted. The ``@sha256:`` digest is dropped, and a digest-only reference has | |
| 1754 | # no tag, so it prints nothing. More than one line means two tags drifted | |
| 1755 | # apart. Empty output under the same conditions as the package extractor. | |
| 1756 | 23 | extract_crossplane_function_pin() { |
| 1757 | 88 | extract_crossplane_function_packages "$1" "$2" \ |
| 1758 | 88 | | sed -nE 's/@sha256:[0-9a-f]{64}$//; s#^.*/[^/:@]+:([A-Za-z0-9._+-]+)$#\1#p' \ |
| 1759 | 88 | | sort -u |
| 1760 | } | |
| 1761 | ||
| 1762 | # extract_install_trivy_pin [action_yml] | |
| 1763 | # | |
| 1764 | # Prints the Trivy version pinned as the ``version`` input default of the | |
| 1765 | # install-trivy composite action — the one place the Trivy pin lives, now | |
| 1766 | # that security.yml and cve-scan.yml carry no copies of their own. Empty | |
| 1767 | # output when the file, the input, or the default is absent. | |
| 1768 | 23 | extract_install_trivy_pin() { |
| 1769 | 84 | local file="${1:-.github/actions/install-trivy/action.yml}" |
| 1770 | 88 | [ -f "$file" ] || return 0 |
| 1771 | 80 | python3 -c " |
| 1772 | import sys, yaml | |
| 1773 | try: | |
| 1774 | with open(sys.argv[1]) as f: | |
| 1775 | data = yaml.safe_load(f) | |
| 1776 | except Exception: | |
| 1777 | sys.exit(0) | |
| 1778 | value = ((((data or {}).get('inputs') or {}).get('version') or {}).get('default') or '') | |
| 1779 | if value: | |
| 1780 | print(value) | |
| 1781 | " "$file" 2>/dev/null | |
| 1782 | } | |
| 1783 | ||
| 1784 | # extract_kind_pins [workflow_file] | |
| 1785 | # | |
| 1786 | # Prints the kind pins configured on the ``helm/kind-action`` step: | |
| 1787 | # kind|<version> e.g. kind|v0.32.0 (the kind binary) | |
| 1788 | # kind-node|<image ref> e.g. kind-node|kindest/node:v1.37.0@sha256:<64 hex> | |
| 1789 | # | |
| 1790 | # The node image is passed through exactly as written. The committed pin is | |
| 1791 | # tag AND digest (the form kind's release notes publish); callers split it | |
| 1792 | # with split_pinned_image_ref and treat a tag-only value as a finding. | |
| 1793 | # | |
| 1794 | # These live in the action's ``with:`` block, not a top-level ``image:`` or | |
| 1795 | # a Dockerfile ``FROM``, so neither the workflow image sweep nor Dependabot's | |
| 1796 | # docker ecosystem sees them. The caller checks the kind binary against | |
| 1797 | # kubernetes-sigs/kind releases, the node image's tag against its own | |
| 1798 | # registry tags within the pinned K8s minor, and its digest against what the | |
| 1799 | # tag currently resolves to. | |
| 1800 | # | |
| 1801 | # Empty output if the file or the kind-action step is absent. | |
| 1802 | 69 | extract_kind_pins() { |
| 1803 | 323 | local file="${1:-.github/workflows/integration-tests.yml}" |
| 1804 | 335 | [ -f "$file" ] || return 0 |
| 1805 | 311 | python3 -c " |
| 1806 | import re, sys, yaml | |
| 1807 | try: | |
| 1808 | with open(sys.argv[1]) as f: | |
| 1809 | data = yaml.safe_load(f) | |
| 1810 | except Exception: | |
| 1811 | sys.exit(0) | |
| 1812 | ||
| 1813 | # The kind-action steps reference the single workflow-level declarations as | |
| 1814 | # \${{ env.KIND_VERSION }} / \${{ env.KIND_NODE_IMAGE }}; resolve those here | |
| 1815 | # so callers keep seeing concrete values. Literal with: values (the | |
| 1816 | # pre-hoist shape) still pass through unchanged. An env reference that does | |
| 1817 | # not resolve prints nothing — the caller's presence checks report the pin | |
| 1818 | # as missing rather than passing a template string to a release lookup. | |
| 1819 | workflow_env = { | |
| 1820 | str(k): str(v) for k, v in ((data or {}).get('env') or {}).items() | |
| 1821 | } | |
| 1822 | _REF = re.compile(r'^\\\$\\{\\{\\s*env\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}\$') | |
| 1823 | ||
| 1824 | def resolve(value, job_env): | |
| 1825 | value = str(value or '') | |
| 1826 | match = _REF.match(value.strip()) | |
| 1827 | if not match: | |
| 1828 | return value | |
| 1829 | name = match.group(1) | |
| 1830 | if name in job_env: | |
| 1831 | return str(job_env[name]) | |
| 1832 | return workflow_env.get(name, '') | |
| 1833 | ||
| 1834 | seen = [] | |
| 1835 | for job in (data or {}).get('jobs', {}).values(): | |
| 1836 | job_env = {str(k): str(v) for k, v in ((job or {}).get('env') or {}).items()} | |
| 1837 | for step in (job or {}).get('steps', []) or []: | |
| 1838 | uses = (step or {}).get('uses', '') or '' | |
| 1839 | if uses.startswith('helm/kind-action'): | |
| 1840 | with_ = (step or {}).get('with', {}) or {} | |
| 1841 | ver = resolve(with_.get('version', ''), job_env) | |
| 1842 | node = resolve(with_.get('node_image', ''), job_env) | |
| 1843 | if ver and ('kind', ver) not in seen: | |
| 1844 | seen.append(('kind', ver)) | |
| 1845 | if node and ('kind-node', node) not in seen: | |
| 1846 | seen.append(('kind-node', node)) | |
| 1847 | # De-duplicated: multiple kind-action steps (cluster-e2e + examples-smoke) | |
| 1848 | # resolving to the SAME versions print once. A key appearing twice therefore | |
| 1849 | # always means the steps drifted apart — the caller's consistency check | |
| 1850 | # reports exactly that. | |
| 1851 | for key, value in seen: | |
| 1852 | print(f'{key}|{value}') | |
| 1853 | " "$file" 2>/dev/null | |
| 1854 | } | |
| 1855 | ||
| 1856 | # extract_ruff_pins [pyproject] [precommit] [lint_workflow] | |
| 1857 | # | |
| 1858 | # Prints the ruff version pinned in each place the project keeps it, one | |
| 1859 | # ``source|version`` per line (version normalised without a leading ``v``): | |
| 1860 | # Example output shape (the values differ when local pins drift): | |
| 1861 | # pyproject|1.2.3 ruff==X in [project.optional-dependencies] | |
| 1862 | # precommit|1.2.4 astral-sh/ruff-pre-commit rev in .pre-commit-config.yaml | |
| 1863 | # lint-action|1.2.3 astral-sh/ruff-action version input in lint.yml | |
| 1864 | # | |
| 1865 | # Ruff is pinned in three spots that must move together (developer install, | |
| 1866 | # the pre-commit hook, and the prebuilt-binary CI lint job). The base Python | |
| 1867 | # deps check already flags ruff drift vs PyPI, but nothing catches the three | |
| 1868 | # local pins silently disagreeing — which is exactly what the Version | |
| 1869 | # Consistency section reports. | |
| 1870 | extract_ruff_pins() { | |
| 1871 | 37 | local pyproject="${1:-pyproject.toml}" |
| 1872 | 37 | local precommit="${2:-.pre-commit-config.yaml}" |
| 1873 | 37 | local lintwf="${3:-.github/workflows/lint.yml}" |
| 1874 | 37 | python3 -c " |
| 1875 | import re, sys | |
| 1876 | pyproject, precommit, lintwf = sys.argv[1], sys.argv[2], sys.argv[3] | |
| 1877 | ||
| 1878 | def norm(v): | |
| 1879 | return v.lstrip('v').strip() | |
| 1880 | ||
| 1881 | # pyproject: first ruff==X.Y.Z anywhere (the lint + diagrams extras pin the | |
| 1882 | # same value; report it once). | |
| 1883 | try: | |
| 1884 | with open(pyproject) as f: | |
| 1885 | m = re.search(r'ruff==([0-9.]+)', f.read()) | |
| 1886 | if m: | |
| 1887 | print(f'pyproject|{norm(m.group(1))}') | |
| 1888 | except OSError: | |
| 1889 | pass | |
| 1890 | ||
| 1891 | # pre-commit: rev of the astral-sh/ruff-pre-commit repo block. | |
| 1892 | try: | |
| 1893 | import yaml | |
| 1894 | with open(precommit) as f: | |
| 1895 | data = yaml.safe_load(f) | |
| 1896 | for entry in (data or {}).get('repos', []) or []: | |
| 1897 | if 'astral-sh/ruff-pre-commit' in ((entry or {}).get('repo', '') or ''): | |
| 1898 | rev = (entry or {}).get('rev', '') | |
| 1899 | if rev: | |
| 1900 | print(f'precommit|{norm(rev)}') | |
| 1901 | break | |
| 1902 | except Exception: | |
| 1903 | pass | |
| 1904 | ||
| 1905 | # lint workflow: version input on the first astral-sh/ruff-action step (the | |
| 1906 | # two steps pin the same value). Parse the YAML rather than regex the raw | |
| 1907 | # text so a nearby ``python-version:`` can't be mistaken for the action's | |
| 1908 | # own ``version:`` input. | |
| 1909 | try: | |
| 1910 | import yaml | |
| 1911 | with open(lintwf) as f: | |
| 1912 | wf = yaml.safe_load(f) | |
| 1913 | found = '' | |
| 1914 | for job in (wf or {}).get('jobs', {}).values(): | |
| 1915 | for step in (job or {}).get('steps', []) or []: | |
| 1916 | uses = (step or {}).get('uses', '') or '' | |
| 1917 | if uses.startswith('astral-sh/ruff-action'): | |
| 1918 | found = str(((step or {}).get('with', {}) or {}).get('version', '') or '') | |
| 1919 | if found: | |
| 1920 | break | |
| 1921 | if found: | |
| 1922 | break | |
| 1923 | if found: | |
| 1924 | print(f'lint-action|{norm(found)}') | |
| 1925 | except Exception: | |
| 1926 | pass | |
| 1927 | " "$pyproject" "$precommit" "$lintwf" 2>/dev/null | |
| 1928 | } | |
| 1929 | ||
| 1930 | # extract_python_version_pins [workflows_dir] | |
| 1931 | # | |
| 1932 | # Prints one line per ``python-version: "X.Y"`` occurrence across the | |
| 1933 | # workflow YAML (value only, e.g. ``3.14``). The caller collapses these to | |
| 1934 | # unique values: more than one distinct value, or a value that disagrees | |
| 1935 | # with the project's canonical Python (derived from LAMBDA_PYTHON_RUNTIME), | |
| 1936 | # means the CI matrix drifted from the runtime the Lambdas actually ship on. | |
| 1937 | extract_python_version_pins() { | |
| 1938 | 39 | local dir="${1:-.github/workflows}" |
| 1939 | 39 | local version_file="${2:-.python-version}" |
| 1940 | 41 | [ -d "$dir" ] || return 0 |
| 1941 | # The CI Python lives once, in .python-version (every setup-python step | |
| 1942 | # uses python-version-file). Emit that single pin, PLUS any literal | |
| 1943 | # python-version: leftovers in the workflows — a stray literal is | |
| 1944 | # exactly the drift the consistency check should surface. | |
| 1945 | 37 | if [ -f "$version_file" ]; then |
| 1946 | 72 | grep -oE "^[0-9]+\.[0-9]+" "$version_file" | head -1 |
| 1947 | fi | |
| 1948 | 37 | grep -rhoE "python-version:[[:space:]]*\"?[0-9]+\.[0-9]+\"?" "$dir" 2>/dev/null \ |
| 1949 | 37 | | sed -E "s/python-version:[[:space:]]*//" \ |
| 1950 | 37 | | tr -d '"' |
| 1951 | } | |
| 1952 | ||
| 1953 | # list_npm_package_dirs [root] | |
| 1954 | # | |
| 1955 | # Prints every repository-owned npm package directory. Generated, vendored, | |
| 1956 | # virtual-environment, and CDK assembly trees are excluded so a copied | |
| 1957 | # ``package.json`` never becomes a false dependency surface. | |
| 1958 | 25 | list_npm_package_dirs() { |
| 1959 | 121 | local root="${1:-.}" |
| 1960 | 121 | python3 -c " |
| 1961 | from pathlib import Path | |
| 1962 | import sys | |
| 1963 | ||
| 1964 | root = Path(sys.argv[1]).resolve() | |
| 1965 | excluded = { | |
| 1966 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 1967 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 1968 | } | |
| 1969 | ||
| 1970 | def owned(path): | |
| 1971 | parts = path.relative_to(root).parts[:-1] | |
| 1972 | return not any( | |
| 1973 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 1974 | for part in parts | |
| 1975 | ) | |
| 1976 | ||
| 1977 | for manifest in sorted(root.rglob('package.json')): | |
| 1978 | if not owned(manifest): | |
| 1979 | continue | |
| 1980 | relative = manifest.parent.relative_to(root) | |
| 1981 | print('.' if relative == Path('.') else relative.as_posix()) | |
| 1982 | " "$root" 2>/dev/null | |
| 1983 | } | |
| 1984 | ||
| 1985 | # check_npm_package_management [root] [dependabot_config] | |
| 1986 | # | |
| 1987 | # Emits ``package.json|problem`` for every repository-owned npm graph that is | |
| 1988 | # not fully reproducible and managed. A graph must have a package-lock.json, | |
| 1989 | # an exact npm packageManager pin, exact direct dependency pins, and a matching | |
| 1990 | # Dependabot npm directory entry. The all-package npm-audit CI job treats any | |
| 1991 | # output as a hard failure; the monthly scan also reports it as consistency | |
| 1992 | # drift. | |
| 1993 | check_npm_package_management() { | |
| 1994 | 38 | local root="${1:-.}" |
| 1995 | 38 | local dependabot="${2:-.github/dependabot.yml}" |
| 1996 | 38 | python3 -c " |
| 1997 | import json, re, sys | |
| 1998 | from pathlib import Path | |
| 1999 | ||
| 2000 | root = Path(sys.argv[1]).resolve() | |
| 2001 | dependabot_path = Path(sys.argv[2]) | |
| 2002 | if not dependabot_path.is_absolute(): | |
| 2003 | dependabot_path = root / dependabot_path | |
| 2004 | excluded = { | |
| 2005 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 2006 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 2007 | } | |
| 2008 | exact_version = re.compile(r'^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') | |
| 2009 | ||
| 2010 | def owned(path): | |
| 2011 | parts = path.relative_to(root).parts[:-1] | |
| 2012 | return not any( | |
| 2013 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 2014 | for part in parts | |
| 2015 | ) | |
| 2016 | ||
| 2017 | dependabot_dirs = set() | |
| 2018 | try: | |
| 2019 | text = dependabot_path.read_text(encoding='utf-8') | |
| 2020 | except OSError: | |
| 2021 | text = '' | |
| 2022 | for block in re.split(r'(?m)(?=^\s*-\s+package-ecosystem:)', text): | |
| 2023 | if not re.search(r'(?m)^\s*-\s+package-ecosystem:\s*[\"\']?npm[\"\']?\s*$', block): | |
| 2024 | continue | |
| 2025 | match = re.search(r'(?m)^\s+directory:\s*[\"\']?([^\"\'\s]+)', block) | |
| 2026 | if match: | |
| 2027 | dependabot_dirs.add('/' + match.group(1).strip('/')) | |
| 2028 | ||
| 2029 | for manifest in sorted(root.rglob('package.json')): | |
| 2030 | if not owned(manifest): | |
| 2031 | continue | |
| 2032 | rel_manifest = manifest.relative_to(root).as_posix() | |
| 2033 | rel_dir = manifest.parent.relative_to(root) | |
| 2034 | dependabot_dir = '/' if rel_dir == Path('.') else '/' + rel_dir.as_posix() | |
| 2035 | try: | |
| 2036 | package = json.loads(manifest.read_text(encoding='utf-8')) | |
| 2037 | except (OSError, json.JSONDecodeError) as exc: | |
| 2038 | print(f'{rel_manifest}|invalid JSON: {exc}') | |
| 2039 | continue | |
| 2040 | if not manifest.with_name('package-lock.json').is_file(): | |
| 2041 | print(f'{rel_manifest}|missing package-lock.json') | |
| 2042 | manager = package.get('packageManager', '') | |
| 2043 | if not re.fullmatch(r'npm@\d+\.\d+\.\d+', manager): | |
| 2044 | print(f'{rel_manifest}|packageManager must be an exact npm@X.Y.Z pin') | |
| 2045 | for section in ('dependencies', 'devDependencies', 'optionalDependencies'): | |
| 2046 | for name, version in (package.get(section) or {}).items(): | |
| 2047 | if not isinstance(version, str) or not exact_version.fullmatch(version): | |
| 2048 | print(f'{rel_manifest}|{section}.{name} must use an exact version pin') | |
| 2049 | if dependabot_dir not in dependabot_dirs: | |
| 2050 | print(f'{rel_manifest}|missing Dependabot npm entry for {dependabot_dir}') | |
| 2051 | " "$root" "$dependabot" 2>/dev/null | |
| 2052 | } | |
| 2053 | ||
| 2054 | # extract_node_major_pins [root] [constants] [nvmrc] [dockerfile] | |
| 2055 | # | |
| 2056 | # Emits ``source|major`` for every place that intentionally mirrors the | |
| 2057 | # repository Node major: the Lambda runtime constant, .nvmrc, every owned npm | |
| 2058 | # graph's engine, and Dockerfile.dev. The driver reports missing sources and | |
| 2059 | # disagreement; the CDK enum check separately detects a newer Lambda runtime. | |
| 2060 | extract_node_major_pins() { | |
| 2061 | 38 | local root="${1:-.}" |
| 2062 | 38 | local constants="${2:-gco/stacks/constants.py}" |
| 2063 | 38 | local nvmrc="${3:-.nvmrc}" |
| 2064 | 38 | local dockerfile="${4:-Dockerfile.dev}" |
| 2065 | 38 | python3 -c " |
| 2066 | import json, re, sys | |
| 2067 | from pathlib import Path | |
| 2068 | ||
| 2069 | root = Path(sys.argv[1]).resolve() | |
| 2070 | excluded = { | |
| 2071 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 2072 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 2073 | } | |
| 2074 | ||
| 2075 | def resolve(path): | |
| 2076 | candidate = Path(path) | |
| 2077 | return candidate if candidate.is_absolute() else root / candidate | |
| 2078 | ||
| 2079 | def emit(source, value): | |
| 2080 | match = re.search(r'\d+', str(value)) | |
| 2081 | if match: | |
| 2082 | print(f'{source}|{int(match.group())}') | |
| 2083 | ||
| 2084 | def owned(path): | |
| 2085 | parts = path.relative_to(root).parts[:-1] | |
| 2086 | return not any( | |
| 2087 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 2088 | for part in parts | |
| 2089 | ) | |
| 2090 | ||
| 2091 | try: | |
| 2092 | text = resolve(sys.argv[2]).read_text(encoding='utf-8') | |
| 2093 | match = re.search(r'^LAMBDA_NODEJS_RUNTIME\s*=\s*\"NODEJS_(\d+)_X\"', text, re.MULTILINE) | |
| 2094 | if match: | |
| 2095 | emit('gco/stacks/constants.py', match.group(1)) | |
| 2096 | except OSError: | |
| 2097 | pass | |
| 2098 | try: | |
| 2099 | emit('.nvmrc', resolve(sys.argv[3]).read_text(encoding='utf-8').strip()) | |
| 2100 | except OSError: | |
| 2101 | pass | |
| 2102 | try: | |
| 2103 | text = resolve(sys.argv[4]).read_text(encoding='utf-8') | |
| 2104 | # NODE_VERSION pins the exact release (e.g. ``v24.18.0``); emit() | |
| 2105 | # reduces it to the leading major for the cross-source comparison. | |
| 2106 | match = re.search(r'^\s*ARG\s+NODE_VERSION=(v?\d+(?:\.\d+)*)\s*$', text, re.MULTILINE) | |
| 2107 | if match: | |
| 2108 | emit('Dockerfile.dev', match.group(1)) | |
| 2109 | except OSError: | |
| 2110 | pass | |
| 2111 | for manifest in sorted(root.rglob('package.json')): | |
| 2112 | if not owned(manifest): | |
| 2113 | continue | |
| 2114 | try: | |
| 2115 | package = json.loads(manifest.read_text(encoding='utf-8')) | |
| 2116 | except (OSError, json.JSONDecodeError): | |
| 2117 | continue | |
| 2118 | emit(manifest.relative_to(root).as_posix(), (package.get('engines') or {}).get('node', '')) | |
| 2119 | " "$root" "$constants" "$nvmrc" "$dockerfile" 2>/dev/null | |
| 2120 | } | |
| 2121 | ||
| 2122 | # extract_npm_version_pins [root] [dockerfile] | |
| 2123 | # | |
| 2124 | # Emits every package.json packageManager npm version plus Dockerfile.dev's | |
| 2125 | # NPM_VERSION so Dependabot/tooling updates cannot leave contributor and CI | |
| 2126 | # npm versions disagreeing. | |
| 2127 | extract_npm_version_pins() { | |
| 2128 | 37 | local root="${1:-.}" |
| 2129 | 37 | local dockerfile="${2:-Dockerfile.dev}" |
| 2130 | 37 | python3 -c " |
| 2131 | import json, re, sys | |
| 2132 | from pathlib import Path | |
| 2133 | ||
| 2134 | root = Path(sys.argv[1]).resolve() | |
| 2135 | excluded = { | |
| 2136 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 2137 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 2138 | } | |
| 2139 | ||
| 2140 | def owned(path): | |
| 2141 | parts = path.relative_to(root).parts[:-1] | |
| 2142 | return not any( | |
| 2143 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 2144 | for part in parts | |
| 2145 | ) | |
| 2146 | ||
| 2147 | for manifest in sorted(root.rglob('package.json')): | |
| 2148 | if not owned(manifest): | |
| 2149 | continue | |
| 2150 | try: | |
| 2151 | package = json.loads(manifest.read_text(encoding='utf-8')) | |
| 2152 | except (OSError, json.JSONDecodeError): | |
| 2153 | continue | |
| 2154 | match = re.fullmatch(r'npm@(\d+\.\d+\.\d+)', package.get('packageManager', '')) | |
| 2155 | if match: | |
| 2156 | print(f'{manifest.relative_to(root).as_posix()}|{match.group(1)}') | |
| 2157 | docker = Path(sys.argv[2]) | |
| 2158 | if not docker.is_absolute(): | |
| 2159 | docker = root / docker | |
| 2160 | try: | |
| 2161 | text = docker.read_text(encoding='utf-8') | |
| 2162 | match = re.search(r'^\s*ARG\s+NPM_VERSION=(\d+\.\d+\.\d+)\s*$', text, re.MULTILINE) | |
| 2163 | if match: | |
| 2164 | print(f'Dockerfile.dev|{match.group(1)}') | |
| 2165 | except OSError: | |
| 2166 | pass | |
| 2167 | " "$root" "$dockerfile" 2>/dev/null | |
| 2168 | } | |
| 2169 | ||
| 2170 | # extract_cdk_cli_pins [root] [dockerfile] | |
| 2171 | # | |
| 2172 | # Emits the root tooling graph's aws-cdk version and Dockerfile.dev's global | |
| 2173 | # CDK CLI pin. Both execution paths must synthesize with the same CLI release. | |
| 2174 | extract_cdk_cli_pins() { | |
| 2175 | 37 | local root="${1:-.}" |
| 2176 | 37 | local dockerfile="${2:-Dockerfile.dev}" |
| 2177 | 37 | python3 -c " |
| 2178 | import json, re, sys | |
| 2179 | from pathlib import Path | |
| 2180 | ||
| 2181 | root = Path(sys.argv[1]).resolve() | |
| 2182 | try: | |
| 2183 | package = json.loads((root / 'package.json').read_text(encoding='utf-8')) | |
| 2184 | version = (package.get('devDependencies') or {}).get('aws-cdk', '') | |
| 2185 | if re.fullmatch(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?', version): | |
| 2186 | print(f'package.json|{version}') | |
| 2187 | except (OSError, json.JSONDecodeError): | |
| 2188 | pass | |
| 2189 | docker = Path(sys.argv[2]) | |
| 2190 | if not docker.is_absolute(): | |
| 2191 | docker = root / docker | |
| 2192 | try: | |
| 2193 | text = docker.read_text(encoding='utf-8') | |
| 2194 | match = re.search(r'^\s*ARG\s+CDK_VERSION=(\S+)\s*$', text, re.MULTILINE) | |
| 2195 | if match: | |
| 2196 | print(f'Dockerfile.dev|{match.group(1)}') | |
| 2197 | except OSError: | |
| 2198 | pass | |
| 2199 | " "$root" "$dockerfile" 2>/dev/null | |
| 2200 | } | |
| 2201 | ||
| 2202 | # parse_suppression_expiries <file> | |
| 2203 | # | |
| 2204 | # Prints ``ID|YYYY-MM-DD`` for every dated suppression entry in a | |
| 2205 | # ``.trivyignore`` / ``.pip-audit-ignore`` / ``.npm-audit-ignore`` file (any | |
| 2206 | # non-comment line carrying an ``exp:YYYY-MM-DD`` marker). For the | |
| 2207 | # whitespace-delimited trivy/pip format the ID is the first token; for the | |
| 2208 | # npm-audit pipe format (``package-dir|package|advisory|node-path|exp:…``) | |
| 2209 | # the ID is the advisory field, so the report names the GHSA rather than the | |
| 2210 | # whole entry line. The caller computes days-to-expiry and surfaces entries | |
| 2211 | # expiring soon so they get renewed *before* the CI expiry validator hard- | |
| 2212 | # fails a PR — the report is the early warning, the validator is the gate. | |
| 2213 | # | |
| 2214 | # Empty output when the file is absent or has no dated entries. | |
| 2215 | parse_suppression_expiries() { | |
| 2216 | 105 | local file="$1" |
| 2217 | 106 | [ -f "$file" ] || return 0 |
| 2218 | 104 | python3 -c " |
| 2219 | import re, sys | |
| 2220 | with open(sys.argv[1]) as f: | |
| 2221 | for line in f: | |
| 2222 | s = line.strip() | |
| 2223 | if not s or s.startswith('#'): | |
| 2224 | continue | |
| 2225 | m = re.search(r'exp:(\d{4}-\d{2}-\d{2})', s) | |
| 2226 | if not m: | |
| 2227 | continue | |
| 2228 | fields = s.split('|') | |
| 2229 | if len(fields) == 5: | |
| 2230 | # .npm-audit-ignore pipe format — the advisory is field 3. | |
| 2231 | ident = fields[2] | |
| 2232 | else: | |
| 2233 | ident = s.split()[0] | |
| 2234 | print(f'{ident}|{m.group(1)}') | |
| 2235 | " "$file" 2>/dev/null | |
| 2236 | } | |
| 2237 | ||
| 2238 | # extract_helm_installer_pins [dockerfile] | |
| 2239 | # | |
| 2240 | # Prints the tool versions hardcoded in the helm-installer Lambda's | |
| 2241 | # Dockerfile RUN lines: | |
| 2242 | # HELM_VERSION|vX.Y.Z from the get.helm.sh download URL | |
| 2243 | # KUBECTL_VERSION|vX.Y.Z from the dl.k8s.io download URL | |
| 2244 | # | |
| 2245 | # These pins are RUN-line URL literals — not ARGs, ``FROM`` lines, or | |
| 2246 | # workflow env — so Dependabot, the Dockerfile.dev ARG sweep, and the | |
| 2247 | # workflow-env consistency check were all blind to them. The consistency | |
| 2248 | # section compares them against the HELM_VERSION / KUBECTL_VERSION workflow | |
| 2249 | # env pins (and Dockerfile.dev's KUBECTL_VERSION ARG) so the Lambda image, | |
| 2250 | # the CI installs, and the dev container can't silently disagree about | |
| 2251 | # which helm/kubectl they ship. | |
| 2252 | # | |
| 2253 | # Empty output when the file is absent or the URLs aren't found — callers | |
| 2254 | # treat that as "skip", matching every other extractor here. | |
| 2255 | 46 | extract_helm_installer_pins() { |
| 2256 | 201 | local file="${1:-lambda/helm-installer/Dockerfile}" |
| 2257 | 209 | [ -f "$file" ] || return 0 |
| 2258 | 193 | python3 -c " |
| 2259 | import re, sys | |
| 2260 | with open(sys.argv[1]) as f: | |
| 2261 | text = f.read() | |
| 2262 | # Versions from the download URLs; checksums from the sha256sum trust | |
| 2263 | # anchor bound to each download's output path. These four lines are the | |
| 2264 | # single Helm/kubectl source the CI jobs load into GITHUB_ENV — the | |
| 2265 | # workflows deliberately carry no literal copies of their own. | |
| 2266 | m = re.search(r'get\.helm\.sh/helm-(v\d+\.\d+\.\d+)-', text) | |
| 2267 | if m: | |
| 2268 | print(f'HELM_VERSION|{m.group(1)}') | |
| 2269 | m = re.search(r'([0-9a-f]{64})\s+/tmp/helm\.tar\.gz', text) | |
| 2270 | if m: | |
| 2271 | print(f'HELM_SHA256|{m.group(1)}') | |
| 2272 | m = re.search(r'dl\.k8s\.io/release/(v\d+\.\d+\.\d+)/', text) | |
| 2273 | if m: | |
| 2274 | print(f'KUBECTL_VERSION|{m.group(1)}') | |
| 2275 | m = re.search(r'([0-9a-f]{64})\s+/tmp/kubectl', text) | |
| 2276 | if m: | |
| 2277 | print(f'KUBECTL_SHA256|{m.group(1)}') | |
| 2278 | " "$file" 2>/dev/null | |
| 2279 | } | |
| 2280 | ||
| 2281 | # extract_kubernetes_client_pin [pyproject_path] | |
| 2282 | # | |
| 2283 | # Prints the exact ``kubernetes==X.Y.Z`` version pinned in | |
| 2284 | # ``[project.dependencies]`` of pyproject.toml (``36.0.3``), or nothing when | |
| 2285 | # the file is missing, unparseable, or carries no exact pin. Callers treat | |
| 2286 | # empty output as "skip", matching the other extractors here. | |
| 2287 | # | |
| 2288 | # kubernetes-client/python numbers its major after the Kubernetes minor it | |
| 2289 | # was generated from, so the Version Consistency section compares this major | |
| 2290 | # with cdk.json's ``kubernetes_version`` minor (see check_kubernetes_client_skew). | |
| 2291 | extract_kubernetes_client_pin() { | |
| 2292 | 41 | local pyproject="${1:-pyproject.toml}" |
| 2293 | 43 | [ -f "$pyproject" ] || return 0 |
| 2294 | 39 | python3 -c " |
| 2295 | import re, sys, tomllib | |
| 2296 | try: | |
| 2297 | with open(sys.argv[1], 'rb') as f: | |
| 2298 | data = tomllib.load(f) | |
| 2299 | except Exception: | |
| 2300 | sys.exit(0) | |
| 2301 | for spec in (data.get('project', {}) or {}).get('dependencies', []) or []: | |
| 2302 | m = re.fullmatch(r'kubernetes==(\d+\.\d+\.\d+)', str(spec).strip()) | |
| 2303 | if m: | |
| 2304 | print(m.group(1)) | |
| 2305 | break | |
| 2306 | " "$pyproject" 2>/dev/null | |
| 2307 | } | |
| 2308 | ||
| 2309 | # check_kubernetes_client_skew <client-version> <k8s-version> | |
| 2310 | # | |
| 2311 | # Applies the client/server rule the PR-time guard | |
| 2312 | # (tests/test_integration.py::TestDependencyVersionConsistency:: | |
| 2313 | # test_kubernetes_python_client_matches_eks_version) enforces: the client's | |
| 2314 | # major must equal the cluster minor, or trail it by exactly one. Trailing | |
| 2315 | # by one is allowed because upstream publishes the new client weeks after | |
| 2316 | # the Kubernetes release; this check is the monthly reminder that the one | |
| 2317 | # allowed minor of lag is being used, so the report nags until the client is | |
| 2318 | # bumped. Prints one ``problem`` line when the client trails the cluster | |
| 2319 | # (``kubernetes==36.0.3 is one minor behind EKS 1.37; pin 37.x once | |
| 2320 | # released``); prints nothing when they agree. A client *ahead* of the | |
| 2321 | # cluster, or two or more minors behind, is also reported — the PR-time | |
| 2322 | # guard rejects both, so seeing either here means the guard was bypassed. | |
| 2323 | # Empty input prints nothing (the caller already skipped). | |
| 2324 | check_kubernetes_client_skew() { | |
| 2325 | 43 | local client="$1" k8s="$2" |
| 2326 | 87 | [ -n "$client" ] && [ -n "$k8s" ] || return 0 |
| 2327 | 9 | local client_major k8s_minor |
| 2328 | 9 | client_major="${client%%.*}" |
| 2329 | 9 | k8s_minor="${k8s#*.}" |
| 2330 | 9 | k8s_minor="${k8s_minor%%.*}" |
| 2331 | 19 | [[ "$client_major" =~ ^[0-9]+$ && "$k8s_minor" =~ ^[0-9]+$ ]] || return 0 |
| 2332 | 7 | if [ "$client_major" -eq "$k8s_minor" ]; then |
| 2333 | 1 | return 0 |
| 2334 | 6 | elif [ "$client_major" -eq $((k8s_minor - 1)) ]; then |
| 2335 | 4 | echo "kubernetes==${client} is one minor behind EKS ${k8s}; pin kubernetes==${k8s_minor}.x once a stable release is on PyPI" |
| 2336 | 2 | elif [ "$client_major" -gt "$k8s_minor" ]; then |
| 2337 | 1 | echo "kubernetes==${client} is ahead of EKS ${k8s}; the client must not lead the cluster" |
| 2338 | else | |
| 2339 | 1 | echo "kubernetes==${client} is $((k8s_minor - client_major)) minors behind EKS ${k8s}; outside the one-minor skew the guard test allows" |
| 2340 | fi | |
| 2341 | } | |
| 2342 | ||
| 2343 | # --------------------------------------------------------------------------- | |
| 2344 | # Machine-readable findings | |
| 2345 | # | |
| 2346 | # The Markdown report is for people. Anything that acts on the findings (a | |
| 2347 | # script, an agent session, ``gco deps scan -o json``) must not parse prose, | |
| 2348 | # so every surface is also recorded as JSON: the rows the Markdown tables | |
| 2349 | # render, keyed by column name, plus the surface's urgency and skip reason. | |
| 2350 | # The scan emits facts only; which findings to act on is the consumer's | |
| 2351 | # policy. | |
| 2352 | # --------------------------------------------------------------------------- | |
| 2353 | ||
| 2354 | #: Schema identifier written into every findings document. Bump the suffix | |
| 2355 | #: when a field changes meaning, so a consumer can refuse a shape it does not | |
| 2356 | #: know. | |
| 2357 | 852 | FINDINGS_SCHEMA="gco.dependency-scan.findings/1" |
| 2358 | ||
| 2359 | #: Markers around the findings JSON embedded in the Markdown report, so a | |
| 2360 | #: consumer can lift it out of the rolling issue body without parsing the | |
| 2361 | #: rest. Both lines are HTML comments and render as nothing. | |
| 2362 | 852 | FINDINGS_BEGIN_MARKER="<!-- gco-deps-findings:begin -->" |
| 2363 | 852 | FINDINGS_END_MARKER="<!-- gco-deps-findings:end -->" |
| 2364 | ||
| 2365 | #: An issue body is capped at 65,536 characters. The report itself runs to | |
| 2366 | #: about 15,000, so a findings document above this size is left to the | |
| 2367 | #: workflow artifact and the report says so instead of risking a failed | |
| 2368 | #: ``gh issue edit``. | |
| 2369 | 852 | FINDINGS_EMBED_MAX_BYTES="${FINDINGS_EMBED_MAX_BYTES:-40000}" |
| 2370 | ||
| 2371 | # findings_rows <keys> <results-file> | |
| 2372 | # | |
| 2373 | # Turns a pipe-delimited results file into a JSON array of objects. <keys> | |
| 2374 | # names the columns, pipe-separated, in the order the scan writes them | |
| 2375 | # (``image|current|latest``). A row with more cells than keys keeps the | |
| 2376 | # overflow in its last field, joined back with ``|``; a row with fewer | |
| 2377 | # cells fills the missing fields with "". An empty or missing file is ``[]``. | |
| 2378 | 6 | findings_rows() { |
| 2379 | 748 | local keys="$1" file="${2:-}" |
| 2380 | 1490 | if [ -z "$file" ] || [ ! -s "$file" ]; then |
| 2381 | 599 | echo "[]" |
| 2382 | 599 | return |
| 2383 | fi | |
| 2384 | 149 | jq -R -s --arg keys "$keys" ' |
| 2385 | ($keys | split("|")) as $cols | |
| 2386 | | ($cols | length) as $n | |
| 2387 | | split("\n") | |
| 2388 | | map(select(length > 0)) | |
| 2389 | | map( | |
| 2390 | split("|") as $cells | |
| 2391 | | ($cells[0:($n - 1)] + [($cells[($n - 1):] | join("|"))]) as $fitted | |
| 2392 | | [range(0; $n)] | map({key: $cols[.], value: ($fitted[.] // "")}) | from_entries | |
| 2393 | )' "$file" | |
| 2394 | } | |
| 2395 | ||
| 2396 | # findings_surface <title> <urgency> <count> <skip-reason> <keys> [results-file] | |
| 2397 | # | |
| 2398 | # Appends one surface record to the file named by ``FINDINGS_SURFACES``: | |
| 2399 | # the Markdown section title, the urgency the summary table shows, the row | |
| 2400 | # count, the skip reason (null when the surface ran) and the rows as | |
| 2401 | # objects. Surfaces whose findings are not rows (the Python packages' JSON, | |
| 2402 | # the accelerator catalog's Markdown) append their own record with | |
| 2403 | # ``findings_surface_json``. | |
| 2404 | 3 | findings_surface() { |
| 2405 | 741 | local title="$1" urgency="$2" count="$3" skip="$4" keys="$5" file="${6:-}" |
| 2406 | 741 | local rows |
| 2407 | 1482 | rows="$(findings_rows "$keys" "$file")" |
| 2408 | 741 | findings_surface_json "$title" "$urgency" "$count" "$skip" "$rows" |
| 2409 | } | |
| 2410 | ||
| 2411 | # findings_surface_json <title> <urgency> <count> <skip-reason> <findings-json> [extra-json] | |
| 2412 | # | |
| 2413 | # The general form of ``findings_surface``: <findings-json> is the ready | |
| 2414 | # JSON array of findings and the optional <extra-json> object is merged | |
| 2415 | # into the record (the accelerator surface carries its reports this way). | |
| 2416 | 5 | findings_surface_json() { |
| 2417 | 815 | local title="$1" urgency="$2" count="$3" skip="$4" rows="$5" extra="${6:-{\}}" |
| 2418 | 815 | jq -cn --arg title "$title" --arg urgency "$urgency" --argjson count "$count" \ |
| 2419 | --arg skip "$skip" --argjson rows "$rows" --argjson extra "$extra" ' | |
| 2420 | {surface: $title, urgency: $urgency, count: $count, | |
| 2421 | skipped: (if $skip == "" then null else $skip end), findings: $rows} + $extra' \ | |
| 2422 | >> "$FINDINGS_SURFACES" | |
| 2423 | } | |
| 2424 | ||
| 2425 | # write_findings_document <output-file> <scan-complete> <has-drift> <incomplete-reasons-file> | |
| 2426 | # | |
| 2427 | # Assembles the findings document from the surface records accumulated in | |
| 2428 | # ``FINDINGS_SURFACES`` and writes it to <output-file>. Fails when a record | |
| 2429 | # is not valid JSON, so a broken emitter can never publish a half document. | |
| 2430 | 2 | write_findings_document() { |
| 2431 | 40 | local output="$1" complete="$2" drift="$3" reasons_file="$4" |
| 2432 | 40 | local reasons='[]' |
| 2433 | 40 | if [ -s "$reasons_file" ]; then |
| 2434 | 51 | reasons="$(sort -u "$reasons_file" | jq -R -s 'split("\n") | map(select(length > 0))')" |
| 2435 | fi | |
| 2436 | 80 | jq -s --arg schema "$FINDINGS_SCHEMA" \ |
| 2437 | --arg generated "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ | |
| 2438 | --argjson complete "$complete" --argjson drift "$drift" --argjson reasons "$reasons" ' | |
| 2439 | {schema: $schema, generated_at: $generated, scan_complete: $complete, has_drift: $drift, | |
| 2440 | incomplete_reasons: $reasons, surfaces: .}' "$FINDINGS_SURFACES" > "$output" | |
| 2441 | } | |
| 2442 | ||
| 2443 | # emit_findings_embed <findings-file> | |
| 2444 | # | |
| 2445 | # Prints the Markdown that carries the findings document inside the report: | |
| 2446 | # a collapsed block between the two markers, or a pointer at the workflow | |
| 2447 | # artifact when the document is too large to embed. | |
| 2448 | emit_findings_embed() { | |
| 2449 | 25 | local file="$1" size |
| 2450 | 75 | size="$(wc -c < "$file" | tr -d ' ')" |
| 2451 | 25 | echo "<details>" |
| 2452 | 25 | echo "<summary>Machine-readable findings (the same report as JSON)</summary>" |
| 2453 | 25 | echo "" |
| 2454 | 25 | if [ "$size" -gt "$FINDINGS_EMBED_MAX_BYTES" ]; then |
| 2455 | 1 | echo "_The findings document (${size} bytes) is too large to embed here; download the" |
| 2456 | 1 | echo "\`dependency-scan-findings\` artifact of the workflow run, or run \`gco deps scan\`._" |
| 2457 | else | |
| 2458 | 24 | echo "$FINDINGS_BEGIN_MARKER" |
| 2459 | 24 | echo '```json' |
| 2460 | 24 | cat "$file" |
| 2461 | 24 | echo '```' |
| 2462 | 24 | echo "$FINDINGS_END_MARKER" |
| 2463 | fi | |
| 2464 | 25 | echo "" |
| 2465 | 25 | echo "</details>" |
| 2466 | } | |
| 2467 | ||
| 2468 | # dependency_scan_is_complete <incomplete-reasons-file> [skip-reason ...] | |
| 2469 | # | |
| 2470 | # Returns success only when the durable incomplete-reason channel exists and | |
| 2471 | # is empty and every section-specific skip reason is empty. The driver uses | |
| 2472 | # this single predicate for its ``scan_complete`` output, so a failed lookup, | |
| 2473 | # parser, or explicit skip can never close the rolling dependency issue. | |
| 2474 | dependency_scan_is_complete() { | |
| 2475 | 39 | local incomplete_reasons_file="$1" |
| 2476 | 39 | shift |
| 2477 | ||
| 2478 | 40 | [ -f "$incomplete_reasons_file" ] || return 1 |
| 2479 | 54 | [ ! -s "$incomplete_reasons_file" ] || return 1 |
| 2480 | ||
| 2481 | 22 | local skip_reason |
| 2482 | 205 | for skip_reason in "$@"; do |
| 2483 | 211 | [ -z "$skip_reason" ] || return 1 |
| 2484 | done | |
| 2485 | } | |
| 2486 | ||
| 2487 | # check_lockfile_freshness [pyproject] [lockfile] | |
| 2488 | # | |
| 2489 | # Prints ``normalised-name[;marker]|expected-version|locked-version`` for each | |
| 2490 | # exact direct dependency whose compiled lock entry is missing or has a | |
| 2491 | # different version. ``locked-version`` is ``<missing>`` when no matching | |
| 2492 | # name-and-marker entry exists. Both base and optional dependency groups are | |
| 2493 | # checked; self-referential ``gco-cli`` extras are intentionally excluded. | |
| 2494 | # | |
| 2495 | # Whitespace around ``==`` and marker operators is insignificant. Marker text | |
| 2496 | # remains part of the comparison identity so mutually exclusive platform pins | |
| 2497 | # can carry different exact versions without being treated as conflicts. | |
| 2498 | # Returns nonzero for missing/malformed input, non-exact direct dependencies, | |
| 2499 | # conflicting pins with the same marker, or unparseable lock records. | |
| 2500 | check_lockfile_freshness() { | |
| 2501 | 52 | local pyproject="${1:-pyproject.toml}" |
| 2502 | 52 | local lockfile="${2:-requirements-lock.txt}" |
| 2503 | 52 | python3 -c " |
| 2504 | import re, sys, tomllib | |
| 2505 | ||
| 2506 | pyproject, lockfile = sys.argv[1], sys.argv[2] | |
| 2507 | ||
| 2508 | def fail(message): | |
| 2509 | print(f'lockfile freshness error: {message}', file=sys.stderr) | |
| 2510 | raise SystemExit(2) | |
| 2511 | ||
| 2512 | try: | |
| 2513 | with open(pyproject, 'rb') as handle: | |
| 2514 | data = tomllib.load(handle) | |
| 2515 | except (OSError, tomllib.TOMLDecodeError) as exc: | |
| 2516 | fail(f'cannot read {pyproject}: {exc}') | |
| 2517 | ||
| 2518 | project = data.get('project') | |
| 2519 | if not isinstance(project, dict): | |
| 2520 | fail(f'{pyproject} has no valid [project] table') | |
| 2521 | base_dependencies = project.get('dependencies', []) | |
| 2522 | optional_dependencies = project.get('optional-dependencies', {}) | |
| 2523 | if not isinstance(base_dependencies, list) or not isinstance(optional_dependencies, dict): | |
| 2524 | fail(f'{pyproject} dependency tables have invalid types') | |
| 2525 | ||
| 2526 | def norm(name): | |
| 2527 | return re.sub(r'[-_.]+', '-', name).lower() | |
| 2528 | ||
| 2529 | def canonical_marker(marker, source): | |
| 2530 | marker = (marker or '').strip() | |
| 2531 | if not marker: | |
| 2532 | return '' | |
| 2533 | if '|' in marker: | |
| 2534 | fail(f'{source} marker contains an unsupported pipe character') | |
| 2535 | result = [] | |
| 2536 | quote = None | |
| 2537 | escaped = False | |
| 2538 | for character in marker: | |
| 2539 | if quote is not None: | |
| 2540 | result.append(character) | |
| 2541 | if escaped: | |
| 2542 | escaped = False | |
| 2543 | elif character == '\\\\': | |
| 2544 | escaped = True | |
| 2545 | elif character == quote: | |
| 2546 | # PEP 508 treats single- and double-quoted marker values as | |
| 2547 | # equivalent. Emit one quote style for identity comparison. | |
| 2548 | result[-1] = '\"' | |
| 2549 | quote = None | |
| 2550 | elif character in ('\"', \"'\"): | |
| 2551 | quote = character | |
| 2552 | result.append('\"') | |
| 2553 | elif not character.isspace(): | |
| 2554 | result.append(character) | |
| 2555 | if quote is not None: | |
| 2556 | fail(f'{source} marker has an unterminated quoted value') | |
| 2557 | return ''.join(result) | |
| 2558 | ||
| 2559 | exact_requirement = re.compile( | |
| 2560 | r'\\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\\s*' | |
| 2561 | r'(?:\\[\\s*[A-Za-z0-9._-]+(?:\\s*,\\s*[A-Za-z0-9._-]+)*\\s*\\])?\\s*' | |
| 2562 | r'==\\s*(?P<version>[^\\s;]+)\\s*' | |
| 2563 | r'(?:;\\s*(?P<marker>.+?))?\\s*' | |
| 2564 | ) | |
| 2565 | # A concrete PEP-440-shaped literal may carry an epoch, pre/post/dev suffix, | |
| 2566 | # or local version, but never a wildcard, another comparator, or a comma- | |
| 2567 | # separated clause. This deliberately rejects ``===`` arbitrary equality. | |
| 2568 | concrete_version = re.compile(r'v?(?:[0-9]+!)?[0-9]+[A-Za-z0-9._+-]*') | |
| 2569 | ||
| 2570 | def parse_exact(spec, source): | |
| 2571 | if not isinstance(spec, str): | |
| 2572 | fail(f'{source} contains a non-string dependency') | |
| 2573 | name_match = re.match(r'^\\s*([A-Za-z0-9][A-Za-z0-9._-]*)', spec) | |
| 2574 | if not name_match: | |
| 2575 | fail(f'{source} contains an invalid dependency: {spec!r}') | |
| 2576 | name = norm(name_match.group(1)) | |
| 2577 | if name == 'gco-cli': | |
| 2578 | return None | |
| 2579 | match = exact_requirement.fullmatch(spec) | |
| 2580 | if not match: | |
| 2581 | fail(f'{source} dependency is not an exact == pin: {spec!r}') | |
| 2582 | version = match.group('version') | |
| 2583 | if not concrete_version.fullmatch(version): | |
| 2584 | fail(f'{source} dependency is not a concrete exact == pin: {spec!r}') | |
| 2585 | marker = canonical_marker(match.group('marker'), source) | |
| 2586 | return (name, marker), version | |
| 2587 | ||
| 2588 | def add_pin(pins, parsed, source): | |
| 2589 | if parsed is None: | |
| 2590 | return | |
| 2591 | identity, version = parsed | |
| 2592 | previous = pins.get(identity) | |
| 2593 | if previous is not None and previous != version: | |
| 2594 | display = identity[0] + (f';{identity[1]}' if identity[1] else '') | |
| 2595 | fail(f'{display} has conflicting pins in {source}: {previous} and {version}') | |
| 2596 | pins[identity] = version | |
| 2597 | ||
| 2598 | expected = {} | |
| 2599 | for dependency in base_dependencies: | |
| 2600 | add_pin( | |
| 2601 | expected, | |
| 2602 | parse_exact(dependency, '[project].dependencies'), | |
| 2603 | '[project].dependencies', | |
| 2604 | ) | |
| 2605 | for group, dependencies in optional_dependencies.items(): | |
| 2606 | if not isinstance(dependencies, list): | |
| 2607 | fail(f'[project.optional-dependencies].{group} is not an array') | |
| 2608 | source = f'[project.optional-dependencies].{group}' | |
| 2609 | for dependency in dependencies: | |
| 2610 | add_pin(expected, parse_exact(dependency, source), source) | |
| 2611 | if not expected: | |
| 2612 | fail(f'{pyproject} contains no exact direct dependency pins') | |
| 2613 | ||
| 2614 | locked = {} | |
| 2615 | try: | |
| 2616 | with open(lockfile, encoding='utf-8') as handle: | |
| 2617 | for line_number, raw_line in enumerate(handle, 1): | |
| 2618 | candidate = raw_line.strip() | |
| 2619 | if not candidate or candidate.startswith(('#', '-')): | |
| 2620 | continue | |
| 2621 | candidate = candidate.split(' #', 1)[0].rstrip() | |
| 2622 | if candidate.endswith('\\\\'): | |
| 2623 | candidate = candidate[:-1].rstrip() | |
| 2624 | source = f'{lockfile}:{line_number}' | |
| 2625 | add_pin(locked, parse_exact(candidate, source), source) | |
| 2626 | except OSError as exc: | |
| 2627 | fail(f'cannot read {lockfile}: {exc}') | |
| 2628 | if not locked: | |
| 2629 | fail(f'{lockfile} contains no pinned requirements') | |
| 2630 | ||
| 2631 | for identity, expected_version in sorted(expected.items()): | |
| 2632 | locked_version = locked.get(identity) | |
| 2633 | if locked_version != expected_version: | |
| 2634 | name, marker = identity | |
| 2635 | display = name + (f';{marker}' if marker else '') | |
| 2636 | print(f'{display}|{expected_version}|{locked_version or \"<missing>\"}') | |
| 2637 | " "$pyproject" "$lockfile" | |
| 2638 | } | |
| 2639 | ||
| 2640 | # extract_security_epochs <dockerfile> | |
| 2641 | # | |
| 2642 | # Prints ``ARGNAME|YYYY-MM-DD`` for each build-time security-refresh epoch | |
| 2643 | # ARG in the given Dockerfile (``APT_SECURITY_EPOCH`` for the Debian service | |
| 2644 | # images / dev image, ``DNF_SECURITY_EPOCH`` for the AL2023 helm-installer | |
| 2645 | # Lambda). These dates are bumped by hand to bust the CI layer cache and pull | |
| 2646 | # freshly-published OS security patches; nothing else reminds anyone to move | |
| 2647 | # them, so the report flags an epoch older than the freshness window. Trivy's | |
| 2648 | # container scan is the backstop; this is the proactive nudge. | |
| 2649 | # | |
| 2650 | # Empty output when the file is absent or pins no epoch ARG. | |
| 2651 | extract_security_epochs() { | |
| 2652 | 116 | local file="$1" |
| 2653 | 117 | [ -f "$file" ] || return 0 |
| 2654 | 115 | python3 -c " |
| 2655 | import re, sys | |
| 2656 | with open(sys.argv[1]) as f: | |
| 2657 | for line in f: | |
| 2658 | stripped = line.split('#', 1)[0] | |
| 2659 | m = re.match(r'^\s*ARG\s+((?:APT|DNF)_SECURITY_EPOCH)=(\d{4}-\d{2}-\d{2})\s*$', stripped) | |
| 2660 | if m: | |
| 2661 | print(f'{m.group(1)}|{m.group(2)}') | |
| 2662 | " "$file" 2>/dev/null | |
| 2663 | } | |
| 2664 | ||
| 2665 | # extract_claude_code_pin [autopilot_py] | |
| 2666 | # | |
| 2667 | # Prints the exact Claude Code release ``gco autopilot`` installs, read from | |
| 2668 | # the ``CLAUDE_CODE_VERSION`` constant in ``cli/autopilot.py``. | |
| 2669 | # | |
| 2670 | # Like the Mooncake image above, this pin lives in a Python constant, so | |
| 2671 | # neither Dependabot nor the npm-graph sweep sees it — autopilot installs | |
| 2672 | # Claude Code lazily via ``npm install -g`` rather than declaring it in any | |
| 2673 | # package.json. This extractor feeds the autopilot-pins drift check so a | |
| 2674 | # newer npm release is surfaced in the monthly report. | |
| 2675 | # | |
| 2676 | # The textual contract (a single-line, double-quoted assignment) is locked | |
| 2677 | # by tests/test_cli_autopilot.py, so this regex and the constant cannot | |
| 2678 | # silently drift apart. Prints nothing if the file or constant is absent — | |
| 2679 | # the caller treats an empty result as "skip". | |
| 2680 | 24 | extract_claude_code_pin() { |
| 2681 | 86 | local file="${1:-cli/autopilot.py}" |
| 2682 | 91 | [ -f "$file" ] || return 0 |
| 2683 | 81 | python3 -c " |
| 2684 | import re, sys | |
| 2685 | with open(sys.argv[1]) as f: | |
| 2686 | text = f.read() | |
| 2687 | m = re.search(r'^CLAUDE_CODE_VERSION = \"([^\"]+)\"', text, re.MULTILINE) | |
| 2688 | if m: | |
| 2689 | print(m.group(1)) | |
| 2690 | " "$file" 2>/dev/null | |
| 2691 | } | |
| 2692 | ||
| 2693 | # extract_codex_pin [autopilot_py] | |
| 2694 | # | |
| 2695 | # Prints the exact OpenAI Codex CLI release the Codex Autopilot engine installs, | |
| 2696 | # read from the scanner-stable ``CODEX_VERSION`` literal in cli/autopilot.py. | |
| 2697 | # Codex is another lazy global npm dependency, so it must flow through the same | |
| 2698 | # monthly registry-drift system as Claude Code rather than package.json. | |
| 2699 | 24 | extract_codex_pin() { |
| 2700 | 86 | local file="${1:-cli/autopilot.py}" |
| 2701 | 91 | [ -f "$file" ] || return 0 |
| 2702 | 81 | python3 -c " |
| 2703 | import re, sys | |
| 2704 | with open(sys.argv[1]) as f: | |
| 2705 | text = f.read() | |
| 2706 | m = re.search(r'^CODEX_VERSION = \"([^\"]+)\"', text, re.MULTILINE) | |
| 2707 | if m: | |
| 2708 | print(m.group(1)) | |
| 2709 | " "$file" 2>/dev/null | |
| 2710 | } | |
| 2711 | ||
| 2712 | # extract_opencode_pin [autopilot_py] | |
| 2713 | # | |
| 2714 | # Prints the exact OpenCode CLI release the OpenCode Autopilot engine | |
| 2715 | # installs, read from the scanner-stable ``OPENCODE_VERSION`` literal in | |
| 2716 | # cli/autopilot.py. OpenCode (npm ``opencode-ai``) is the third lazy global | |
| 2717 | # npm dependency, so it flows through the same monthly registry-drift | |
| 2718 | # system as Claude Code and Codex rather than package.json. | |
| 2719 | 24 | extract_opencode_pin() { |
| 2720 | 86 | local file="${1:-cli/autopilot.py}" |
| 2721 | 91 | [ -f "$file" ] || return 0 |
| 2722 | 81 | python3 -c " |
| 2723 | import re, sys | |
| 2724 | with open(sys.argv[1]) as f: | |
| 2725 | text = f.read() | |
| 2726 | m = re.search(r'^OPENCODE_VERSION = \"([^\"]+)\"', text, re.MULTILINE) | |
| 2727 | if m: | |
| 2728 | print(m.group(1)) | |
| 2729 | " "$file" 2>/dev/null | |
| 2730 | } | |
| 2731 | ||
| 2732 | # extract_companion_mcp_packages [autopilot_py] | |
| 2733 | # | |
| 2734 | # Prints one ``name|registry|package`` line per companion MCP server in the | |
| 2735 | # ``COMPANION_MCP_SERVERS`` registry of ``cli/autopilot.py`` — the servers | |
| 2736 | # ``gco autopilot`` wires into every session. ``registry`` is ``npm`` or | |
| 2737 | # ``pypi``. | |
| 2738 | # | |
| 2739 | # These packages are launch-time dependencies fetched by npx/uvx, so they | |
| 2740 | # appear in no lockfile and no manifest Dependabot watches. The autopilot | |
| 2741 | # liveness check uses this list to verify each package is still published | |
| 2742 | # (and not deprecated/yanked) on its registry — exactly the failure mode | |
| 2743 | # that got mcp-server-fetch and mcp-server-calculator pruned in 2026-08. | |
| 2744 | # | |
| 2745 | # Parses the ``name=`` / ``registry=`` / ``package=`` keywords inside each | |
| 2746 | # ``CompanionServer(`` block textually (no imports), so BATS can exercise it | |
| 2747 | # against fixtures; tests/test_cli_autopilot.py locks the source formatting. | |
| 2748 | # Prints nothing for a missing or unparseable file. | |
| 2749 | extract_companion_mcp_packages() { | |
| 2750 | 37 | local file="${1:-cli/autopilot.py}" |
| 2751 | 38 | [ -f "$file" ] || return 0 |
| 2752 | 36 | python3 -c " |
| 2753 | import re, sys | |
| 2754 | with open(sys.argv[1]) as f: | |
| 2755 | text = f.read() | |
| 2756 | pattern = re.compile( | |
| 2757 | r'CompanionServer\(\s*name=\"([^\"]+)\",\s*registry=\"([^\"]+)\",\s*package=\"([^\"]+)\",' | |
| 2758 | ) | |
| 2759 | for name, registry, package in pattern.findall(text): | |
| 2760 | print(f'{name}|{registry}|{package}') | |
| 2761 | " "$file" 2>/dev/null | |
| 2762 | } | |
| 2763 | ||
| 2764 | # get_registry_package_status <registry> <package> | |
| 2765 | # | |
| 2766 | # Resolves a package's publication status on its public registry and prints | |
| 2767 | # exactly one of: | |
| 2768 | # | |
| 2769 | # ok|<latest-version> published and healthy | |
| 2770 | # missing| registry answers 404 — unpublished/renamed | |
| 2771 | # deprecated|<message> npm ``deprecated`` flag on the latest release | |
| 2772 | # yanked|<latest-version> PyPI ``yanked`` flag on the latest release | |
| 2773 | # | |
| 2774 | # Prints nothing on a network/transport failure or an unknown registry so | |
| 2775 | # the caller marks the check skipped instead of reporting phantom drift — | |
| 2776 | # the same fail-quiet contract as the other ``get_latest_*`` helpers here. | |
| 2777 | # Both endpoints are public; no credentials needed. | |
| 2778 | get_registry_package_status() { | |
| 2779 | 191 | local registry="$1" package="$2" |
| 2780 | 191 | local url="" body="" code="" |
| 2781 | 191 | case "$registry" in |
| 2782 | npm) | |
| 2783 | # Scoped packages need the slash percent-encoded on the | |
| 2784 | # per-version route (@scope%2Fname/latest). | |
| 2785 | 147 | url="https://registry.npmjs.org/${package//\//%2F}/latest" |
| 2786 | ;; | |
| 2787 | pypi) | |
| 2788 | 43 | url="https://pypi.org/pypi/${package}/json" |
| 2789 | ;; | |
| 2790 | *) | |
| 2791 | 1 | return 0 |
| 2792 | ;; | |
| 2793 | esac | |
| 2794 | ||
| 2795 | 380 | body="$(mktemp)" |
| 2796 | 387 | code="$(curl -sSL --max-time 15 "${SCAN_CURL_RETRY[@]}" -o "$body" -w '%{http_code}' "$url" 2>/dev/null)" || code="" |
| 2797 | 190 | if [ "$code" = "404" ]; then |
| 2798 | 3 | rm -f "$body" |
| 2799 | 3 | echo "missing|" |
| 2800 | 3 | return 0 |
| 2801 | fi | |
| 2802 | 187 | if [ "$code" != "200" ]; then |
| 2803 | 8 | rm -f "$body" |
| 2804 | 8 | return 0 |
| 2805 | fi | |
| 2806 | ||
| 2807 | 179 | python3 -c " |
| 2808 | import json, sys | |
| 2809 | registry, path = sys.argv[1], sys.argv[2] | |
| 2810 | try: | |
| 2811 | with open(path) as handle: | |
| 2812 | data = json.load(handle) | |
| 2813 | except Exception: | |
| 2814 | sys.exit(0) | |
| 2815 | if registry == 'npm': | |
| 2816 | deprecated = data.get('deprecated') | |
| 2817 | if deprecated: | |
| 2818 | print('deprecated|' + ' '.join(str(deprecated).split())[:120]) | |
| 2819 | else: | |
| 2820 | version = str(data.get('version', '') or '') | |
| 2821 | if version: | |
| 2822 | print('ok|' + version) | |
| 2823 | else: | |
| 2824 | info = data.get('info') or {} | |
| 2825 | urls = data.get('urls') or [] | |
| 2826 | yanked = bool(urls and urls[0].get('yanked')) | |
| 2827 | version = str(info.get('version', '') or '') | |
| 2828 | if version: | |
| 2829 | print(('yanked|' if yanked else 'ok|') + version) | |
| 2830 | " "$registry" "$body" 2>/dev/null | |
| 2831 | 179 | rm -f "$body" |
| 2832 | } | |
| 2833 | ||
| 2834 | # check_lambda_requirements_pins [root] [pyproject] [lockfile] | |
| 2835 | # | |
| 2836 | # Emits ``requirements-path|problem`` for every pin in a per-Lambda | |
| 2837 | # ``requirements.txt`` that disagrees with the version this repository | |
| 2838 | # actually resolves for that package. | |
| 2839 | # | |
| 2840 | # Each Lambda carries its own ``requirements.txt`` because it is packaged and | |
| 2841 | # deployed independently, so the same library is pinned in up to seven places: | |
| 2842 | # ``pyproject.toml`` (what the test suite and the CLI resolve), | |
| 2843 | # ``requirements-lock.txt`` (what pip-compile pins the whole graph to), and one | |
| 2844 | # copy per Lambda that declares it. Nothing watched the Lambda copies. A bump | |
| 2845 | # applied centrally therefore left them behind silently, and the handlers | |
| 2846 | # shipped a different boto3 than anything CI ever exercised — the exact drift | |
| 2847 | # class the rest of this library exists to catch, on the one surface that | |
| 2848 | # reaches production directly. | |
| 2849 | # | |
| 2850 | # Resolution order for the authoritative version, most specific first: | |
| 2851 | # 1. ``[project].dependencies`` — the packages we pin deliberately | |
| 2852 | # 2. ``[project.optional-dependencies]`` — group-only pins | |
| 2853 | # 3. ``requirements-lock.txt`` — transitives (cryptography, …) that | |
| 2854 | # no pyproject entry names but the repository still resolves exactly | |
| 2855 | # A package found in none of the three is a Lambda-only dependency with no | |
| 2856 | # central copy to disagree with, so it is skipped rather than reported. | |
| 2857 | # | |
| 2858 | # Only ``lambda/<name>/requirements.txt`` is read. The generated ``*-build`` | |
| 2859 | # staging bundles copy their requirements from the source directory, so | |
| 2860 | # including them would double-report every finding when they happen to exist | |
| 2861 | # locally and report nothing in CI, where they do not. | |
| 2862 | # | |
| 2863 | # A requirements file that pins nothing (three of them only document that the | |
| 2864 | # Lambda runtime supplies boto3) is a skip, not a finding. A missing or | |
| 2865 | # unparseable ``pyproject.toml`` is reported, because it always exists here and | |
| 2866 | # a parse break must not silently downgrade this to a pass. | |
| 2867 | # | |
| 2868 | # Example output: | |
| 2869 | # | |
| 2870 | # lambda/secret-rotation/requirements.txt|boto3==1.43.74 must match pyproject.toml 1.43.85 | |
| 2871 | # | |
| 2872 | # The PR-time half of this contract lives in | |
| 2873 | # tests/test_integration.py::TestDependencyVersionConsistency::test_lambda_requirements_match_pyproject. | |
| 2874 | check_lambda_requirements_pins() { | |
| 2875 | 44 | local root="${1:-.}" |
| 2876 | 44 | local pyproject="${2:-pyproject.toml}" |
| 2877 | 44 | local lockfile="${3:-requirements-lock.txt}" |
| 2878 | 44 | python3 -c " |
| 2879 | import re, sys, tomllib | |
| 2880 | from pathlib import Path | |
| 2881 | ||
| 2882 | root = Path(sys.argv[1]).resolve() | |
| 2883 | ||
| 2884 | def resolve(path): | |
| 2885 | candidate = Path(path) | |
| 2886 | return candidate if candidate.is_absolute() else root / candidate | |
| 2887 | ||
| 2888 | def normalize(name): | |
| 2889 | # PEP 503 normalisation: lowercase, ``_`` + ``.`` -> ``-``. | |
| 2890 | return re.sub(r'[-_.]+', '-', name).lower() | |
| 2891 | ||
| 2892 | pin = re.compile(r'([A-Za-z0-9][A-Za-z0-9._-]*)\s*==\s*([^\s;]+)') | |
| 2893 | ||
| 2894 | try: | |
| 2895 | with open(resolve(sys.argv[2]), 'rb') as handle: | |
| 2896 | data = tomllib.load(handle) | |
| 2897 | except Exception: | |
| 2898 | print('pyproject.toml|missing or unparseable, cannot verify Lambda pins') | |
| 2899 | sys.exit(0) | |
| 2900 | ||
| 2901 | project = data.get('project', {}) or {} | |
| 2902 | ||
| 2903 | def collect(specs): | |
| 2904 | found = {} | |
| 2905 | for spec in specs or []: | |
| 2906 | if not isinstance(spec, str): | |
| 2907 | continue | |
| 2908 | match = pin.fullmatch(spec.strip()) | |
| 2909 | if match: | |
| 2910 | found.setdefault(normalize(match.group(1)), set()).add(match.group(2)) | |
| 2911 | return found | |
| 2912 | ||
| 2913 | direct = collect(project.get('dependencies')) | |
| 2914 | optional = {} | |
| 2915 | for group in (project.get('optional-dependencies', {}) or {}).values(): | |
| 2916 | for name, versions in collect(group).items(): | |
| 2917 | optional.setdefault(name, set()).update(versions) | |
| 2918 | ||
| 2919 | locked = {} | |
| 2920 | try: | |
| 2921 | for line in resolve(sys.argv[3]).read_text(encoding='utf-8').splitlines(): | |
| 2922 | # Lock annotations are indented ('' # via boto3''); pins are not. | |
| 2923 | if not line or line[:1].isspace() or line.lstrip().startswith('#'): | |
| 2924 | continue | |
| 2925 | match = pin.match(line.split('#', 1)[0].strip()) | |
| 2926 | if match: | |
| 2927 | locked.setdefault(normalize(match.group(1)), set()).add(match.group(2)) | |
| 2928 | except OSError: | |
| 2929 | pass | |
| 2930 | ||
| 2931 | def authority(name): | |
| 2932 | for label, table in ( | |
| 2933 | ('pyproject.toml', direct), | |
| 2934 | ('pyproject.toml optional groups', optional), | |
| 2935 | ('requirements-lock.txt', locked), | |
| 2936 | ): | |
| 2937 | if name in table: | |
| 2938 | return label, table[name] | |
| 2939 | return None, None | |
| 2940 | ||
| 2941 | def owned(path): | |
| 2942 | return not any(part.endswith('-build') for part in path.relative_to(root).parts[:-1]) | |
| 2943 | ||
| 2944 | for requirements in sorted((root / 'lambda').glob('*/requirements.txt')): | |
| 2945 | if not owned(requirements): | |
| 2946 | continue | |
| 2947 | relative = requirements.relative_to(root).as_posix() | |
| 2948 | try: | |
| 2949 | lines = requirements.read_text(encoding='utf-8').splitlines() | |
| 2950 | except OSError: | |
| 2951 | print(relative + '|unreadable, cannot verify Lambda pins') | |
| 2952 | continue | |
| 2953 | for line in lines: | |
| 2954 | entry = line.split('#', 1)[0].strip() | |
| 2955 | if not entry: | |
| 2956 | continue | |
| 2957 | match = pin.fullmatch(entry) | |
| 2958 | if not match: | |
| 2959 | continue | |
| 2960 | name, version = normalize(match.group(1)), match.group(2) | |
| 2961 | label, expected = authority(name) | |
| 2962 | if not expected or version in expected: | |
| 2963 | continue | |
| 2964 | want = ','.join(sorted(expected)) | |
| 2965 | print(f'{relative}|{name}=={version} must match {label} {want}') | |
| 2966 | " "$root" "$pyproject" "$lockfile" 2>/dev/null | |
| 2967 | } | |
| 2968 | ||
| 2969 | # check_image_digest_consistency [root] | |
| 2970 | # | |
| 2971 | # Emits ``image|problem`` for every ``repo:tag`` this repository pins to two | |
| 2972 | # different ``@sha256:`` digests — the tag was re-pushed upstream and only some | |
| 2973 | # of the copies were refreshed. | |
| 2974 | # | |
| 2975 | # This exists because the drift sections answer "is this pin behind upstream?" | |
| 2976 | # and the version-consistency checks answer "do the copies of a *named* pin | |
| 2977 | # agree?", but neither could see a pin restated as a bare literal elsewhere in | |
| 2978 | # the tree. That is the class that keeps escaping: the #317 sweep refreshed the | |
| 2979 | # python-slim digest in a smoke manifest while a test kept the previous one, and | |
| 2980 | # it surfaced only when the CI shard holding that test happened to run. | |
| 2981 | # | |
| 2982 | # Deliberately narrow. Two broader variants were written and measured against | |
| 2983 | # this tree before being cut, because both were pure noise here: | |
| 2984 | # | |
| 2985 | # * "same repo, different tags" reported python at 3.14, 3.14.6-slim and | |
| 2986 | # 3.14.7-slim for three legitimate purposes, busybox pinned in examples | |
| 2987 | # versus ``:latest`` in property-test fixtures, and several synthetic | |
| 2988 | # Volcano tags — about fifteen rows, none of them drift. | |
| 2989 | # * "digest-pinned here but named at a bare tag there" reported only fixture | |
| 2990 | # image strings and the quoted registry-timeout error messages in the | |
| 2991 | # ``build-image-with-retry`` docs. | |
| 2992 | # | |
| 2993 | # A section that is mostly noise trains readers to skip it. Two digests under | |
| 2994 | # one tag, by contrast, cannot be anything but a copy someone missed: a fixture | |
| 2995 | # does not invent a real 64-hex digest. Images that need tag-level coverage get | |
| 2996 | # a dedicated guard instead — see ``tests/test_pinned_floci_version.py``. | |
| 2997 | # | |
| 2998 | # Excludes generated trees, sibling git worktrees, recorded terminal casts, and | |
| 2999 | # the generated flowchart HTML: all of them hold point-in-time copies of source | |
| 3000 | # that are not pins. | |
| 3001 | # | |
| 3002 | # Example output: | |
| 3003 | # | |
| 3004 | # docker.io/library/python|tag 3.14.7-slim has 2 digests: 656d12e7… in a.yaml, ce407646… in b.py | |
| 3005 | # | |
| 3006 | # Prints nothing when every digest-pinned image agrees with itself. | |
| 3007 | check_image_digest_consistency() { | |
| 3008 | 41 | local root="${1:-.}" |
| 3009 | 41 | python3 -c " |
| 3010 | import re, sys | |
| 3011 | from pathlib import Path | |
| 3012 | from collections import defaultdict | |
| 3013 | ||
| 3014 | root = Path(sys.argv[1]).resolve() | |
| 3015 | excluded = { | |
| 3016 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', '.worktrees', | |
| 3017 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', 'site', | |
| 3018 | '.example-job-validation', | |
| 3019 | } | |
| 3020 | suffixes = {'.py', '.yaml', '.yml', '.md', '.sh', '.txt', '.toml'} | |
| 3021 | ||
| 3022 | def owned(path): | |
| 3023 | parts = path.relative_to(root).parts | |
| 3024 | if any(p in excluded or p.startswith('.venv') or p.endswith('-build') for p in parts[:-1]): | |
| 3025 | return False | |
| 3026 | if parts and parts[0] in excluded: | |
| 3027 | return False | |
| 3028 | # Generated flowchart HTML embeds a copy of the source it charts. | |
| 3029 | if parts and parts[0] == 'diagrams' and path.suffix != '.py': | |
| 3030 | return False | |
| 3031 | return path.suffix in suffixes or 'ockerfile' in path.name | |
| 3032 | ||
| 3033 | # repo:tag[@sha256:digest]. The tag must look like a version so ordinary | |
| 3034 | # ''key: value'' text and ''host:port'' pairs are never read as images. | |
| 3035 | reference = re.compile( | |
| 3036 | r'(?<![\w./:-])' | |
| 3037 | r'([a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)+)' | |
| 3038 | r':(v?[0-9][A-Za-z0-9._-]*)' | |
| 3039 | r'(?:@sha256:([0-9a-f]{64}))?' | |
| 3040 | ) | |
| 3041 | ||
| 3042 | pinned = defaultdict(lambda: defaultdict(set)) # repo -> tag -> {(digest, file)} | |
| 3043 | ||
| 3044 | for path in sorted(root.rglob('*')): | |
| 3045 | if not path.is_file() or not owned(path): | |
| 3046 | continue | |
| 3047 | try: | |
| 3048 | text = path.read_text(encoding='utf-8') | |
| 3049 | except (OSError, UnicodeDecodeError): | |
| 3050 | continue | |
| 3051 | relative = path.relative_to(root).as_posix() | |
| 3052 | # Rejoin references split across adjacent string literals. The stale copy | |
| 3053 | # that caused the incident was written exactly this way — | |
| 3054 | # ''docker.io/library/python:3.14.7-slim@'' on one line and | |
| 3055 | # ''sha256:656d…'' on the next — so a line-at-a-time matcher saw no pin at | |
| 3056 | # all and reported nothing. Only the seam around the digest is rejoined, so | |
| 3057 | # unrelated adjacent literals cannot be welded into a false reference. | |
| 3058 | text = re.sub(r'@[\"\x27][\s,]*[\"\x27]sha256:', '@sha256:', text) | |
| 3059 | text = re.sub(r'[\"\x27][\s,]*[\"\x27]@sha256:', '@sha256:', text) | |
| 3060 | for line in text.splitlines(): | |
| 3061 | for match in reference.finditer(line): | |
| 3062 | repo, tag, digest = match.group(1), match.group(2), match.group(3) | |
| 3063 | prefix = line[: match.start()] | |
| 3064 | # ''https://host/path:1.2'' is a URL, not an image reference. | |
| 3065 | if '://' in prefix[-12:] or prefix.endswith('//'): | |
| 3066 | continue | |
| 3067 | if digest: | |
| 3068 | pinned[repo][tag].add((digest, relative)) | |
| 3069 | ||
| 3070 | def listing(items): | |
| 3071 | return ', '.join(sorted(items)) | |
| 3072 | ||
| 3073 | for repo in sorted(pinned): | |
| 3074 | for tag in sorted(pinned[repo]): | |
| 3075 | seen = pinned[repo][tag] | |
| 3076 | distinct = sorted({digest for digest, _ in seen}) | |
| 3077 | if len(distinct) > 1: | |
| 3078 | detail = ', '.join( | |
| 3079 | d[:8] + '… in ' + listing(f for g, f in seen if g == d) for d in distinct | |
| 3080 | ) | |
| 3081 | print(f'{repo}|tag {tag} has {len(distinct)} digests: {detail}') | |
| 3082 | ||
| 3083 | " "$root" 2>/dev/null | |
| 3084 | } |