diff --git a/files/scripts/openshift-app-image-report/openshift-app-image-notes.yml b/files/scripts/openshift-app-image-report/openshift-app-image-notes.yml new file mode 100644 index 0000000000..74ad8f5e39 --- /dev/null +++ b/files/scripts/openshift-app-image-report/openshift-app-image-notes.yml @@ -0,0 +1,22 @@ +--- +# Optional annotations for openshift-app-image-report.py. +# +# The scanner reads the OpenShift app files directly. Use this file for cases +# that static scanning cannot infer, such as Jinja variables, external +# Dockerfiles, or manually maintained tags. + +# fill in like +# annotations: +# - app: +# match: +# os_version: +# kind: +# note: +# EXAMPLE: +# annotations: +# - app: transtats +# match: registry.fedoraproject.org/fedora:34 +# os_version: Fedora 34 +# note: Inline Dockerfile base image; should be reviewed for EOL. + +annotations: [] diff --git a/files/scripts/openshift-app-image-report/openshift-app-image-report.py b/files/scripts/openshift-app-image-report/openshift-app-image-report.py new file mode 100755 index 0000000000..436ef150aa --- /dev/null +++ b/files/scripts/openshift-app-image-report/openshift-app-image-report.py @@ -0,0 +1,430 @@ +#! /usr/bin/python3 + +import argparse +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +try: + import yaml +except ImportError: + yaml = None + + +SCRIPT_DIR = Path(__file__).resolve().parent +NOTES_FILE = SCRIPT_DIR / "openshift-app-image-notes.yml" + + +def find_repo_root(start): + for path in (start, *start.parents): + if (path / "roles" / "openshift-apps").is_dir(): + return path + print("Error: could not find roles/openshift-apps above script path", file=sys.stderr) + sys.exit(2) + + +REPO_ROOT = find_repo_root(SCRIPT_DIR) +APP_ROOT = REPO_ROOT / "roles" / "openshift-apps" + +IMAGE_PREFIXES = ( + "docker.io/", + "docker-registry.", + "fedora:", + "image-registry.", + "quay.io/", + "redis:", + "registry.access.redhat.com/", + "registry.fedoraproject.org/", + "solr:", + "valkey/", + "busybox", +) + + +@dataclass(frozen=True) +class Finding: + app: str + kind: str + image: str + os_version: str + floating: bool + unresolved: bool + path: str + line: int + note: str = "" + + +def _strip_value(value): + value = value.strip() + if value.startswith(("'", '"')) and value.endswith(("'", '"')): + value = value[1:-1] + return value.strip() + + +def _looks_like_image(value): + value = _strip_value(value) + if not value: + return False + if value.startswith(IMAGE_PREFIXES): + return True + if "/" in value and ":" in value: + return True + if ":" in value and not value.startswith(("http://", "https://")): + return True + return False + + +def _tag_from_image(image): + if "@" in image: + return image.rsplit("@", 1)[1] + tail = image.rsplit("/", 1)[-1] + if ":" not in tail: + return "" + return tail.rsplit(":", 1)[1] + + +def _is_floating(image): + if "{{" in image: + return False + tag = _tag_from_image(image) + return tag in ("", "latest") + + +def _python_version(text): + match = re.search(r"python[-:]?(\d)(\d{1,2})", text) + if not match: + return "" + major, minor = match.groups() + return f"Python {major}.{minor}" + + +def _infer_os_version(image): + low = image.lower() + + match = re.search( + r"(?:registry\.fedoraproject\.org/|quay\.io/fedora/)?fedora:(\d+|latest)", low + ) + if match: + return f"Fedora {match.group(1)}" + + match = re.search(r"ubi(\d+)", low) + if match: + parts = [f"UBI {match.group(1)}"] + pyver = _python_version(low) + if pyver: + parts.append(pyver) + nginx = re.search(r"nginx[-:]?(\d)(\d{2})", low) + if nginx: + parts.append(f"nginx {nginx.group(1)}.{nginx.group(2)}") + return " / ".join(parts) + + pyver = _python_version(low) + if pyver: + return pyver + + if low.startswith("busybox"): + return "busybox" + if low.startswith("redis:"): + return "redis" + if "bitnami" in low and "redis" in low: + return "redis" + if low.startswith("solr:"): + return "Solr" + if low.startswith("valkey/") or low.startswith("valkey:"): + return "Valkey" + + return "?" + + +def _source_kind(line, value): + stripped = line.strip() + if stripped.startswith("FROM "): + return "dockerfile-from" + if stripped.startswith("image:"): + return "runtime-image" + if stripped.startswith("dockerfilePath:"): + return "dockerfile-path" + if stripped.startswith("uri:"): + return "git-source" + if stripped.startswith("ref:"): + return "git-ref" + if stripped.startswith("name:"): + if value.startswith("image-registry.") or value.startswith("docker-registry."): + return "internal-image" + return "image-or-builder" + return "image-reference" + + +def _iter_scan_files(app_dir): + for path in sorted(app_dir.rglob("*")): + if not path.is_file(): + continue + rel_parts = path.relative_to(app_dir).parts + if not rel_parts or rel_parts[0] not in ("files", "templates", "vars"): + continue + name = path.name.lower() + suffix = path.suffix.lower() + if suffix in (".yml", ".yaml", ".j2", ".toml", ".cfg"): + yield path + continue + if "dockerfile" in name or "containerfile" in name: + yield path + + +def _scan_line(line): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + + if stripped.startswith("FROM "): + return stripped.split(None, 1)[1].split()[0], True + + for key in ("image:", "name:", "dockerfilePath:", "uri:", "ref:"): + if not stripped.startswith(key): + continue + value = _strip_value(stripped[len(key) :]) + if key == "dockerfilePath:": + return value, False + if key in ("uri:", "ref:"): + if "github.com" in value or "gitlab" in value or "{{" in value: + return value, False + return None + if key == "image:" and "{{" in value: + return value, True + if _looks_like_image(value): + return value, True + return None + + return None + + +def scan_apps(app_filter=None): + findings = [] + wanted = set(app_filter or []) + for app_dir in sorted(APP_ROOT.iterdir()): + if not app_dir.is_dir(): + continue + app = app_dir.name + if wanted and app not in wanted: + continue + for path in _iter_scan_files(app_dir): + rel_path = path.relative_to(REPO_ROOT).as_posix() + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + continue + for lineno, line in enumerate(lines, 1): + scanned = _scan_line(line) + if not scanned: + continue + value, is_image = scanned + unresolved = "{{" in value or "{%" in value + if line.strip().startswith("dockerfilePath:"): + unresolved = True + kind = _source_kind(line, value) + os_version = _infer_os_version(value) if is_image else "?" + findings.append( + Finding( + app=app, + kind=kind, + image=value, + os_version=os_version, + floating=_is_floating(value) if is_image else False, + unresolved=unresolved, + path=rel_path, + line=lineno, + ) + ) + return findings + + +def load_annotations(notes_file): + if not notes_file.exists(): + return [] + if yaml is None: + print( + "Error: python3-yaml is required to read openshift-app-image-notes.yml", + file=sys.stderr, + ) + sys.exit(2) + with notes_file.open(encoding="utf-8") as stream: + data = yaml.safe_load(stream) or {} + annotations = data.get("annotations", []) + if not isinstance(annotations, list): + print( + "Error: annotations must be a list in openshift-app-image-notes.yml", + file=sys.stderr, + ) + sys.exit(2) + return annotations + + +def apply_annotations(findings, annotations): + by_key = {} + for item in annotations: + if not isinstance(item, dict): + continue + app = item.get("app") + match = item.get("match", "") + note = item.get("note", "") + os_version = item.get("os_version", "") + kind = item.get("kind", "") + if not app or not match: + continue + by_key[(app, match)] = (note, os_version, kind) + + used = set() + updated = [] + for finding in findings: + note = finding.note + os_version = finding.os_version + kind = finding.kind + for (app, match), values in by_key.items(): + if app != finding.app: + continue + if match not in finding.image and match not in finding.path: + continue + used.add((app, match)) + ann_note, ann_os, ann_kind = values + note = ann_note or note + os_version = ann_os or os_version + kind = ann_kind or kind + updated.append( + Finding( + app=finding.app, + kind=kind, + image=finding.image, + os_version=os_version, + floating=finding.floating, + unresolved=finding.unresolved, + path=finding.path, + line=finding.line, + note=note, + ) + ) + return updated, sorted(set(by_key) - used) + + +def filter_findings(findings, args): + filtered = findings + if args.kind: + wanted = set(args.kind) + filtered = [item for item in filtered if item.kind in wanted] + if args.floating_only: + filtered = [item for item in filtered if item.floating] + if args.unresolved_only: + filtered = [item for item in filtered if item.unresolved] + return filtered + + +def _short_path(finding): + prefix = f"roles/openshift-apps/{finding.app}/" + path = finding.path + if path.startswith(prefix): + path = path[len(prefix) :] + return f"{path}:{finding.line}" + + +def _clip(value, width): + if len(value) <= width: + return value + if width <= 1: + return value[:width] + return value[: width - 3] + "..." + + +def print_table(findings): + columns = ( + ("APP", 22), + ("KIND", 16), + ("OS/VERSION", 20), + ("FLOAT", 5), + ("IMAGE/SOURCE", 54), + ("PATH", 58), + ("NOTE", 30), + ) + rows = [] + for finding in findings: + rows.append( + ( + finding.app, + finding.kind, + finding.os_version, + "yes" if finding.floating else "", + ("?" if finding.unresolved else "") + finding.image, + _short_path(finding), + finding.note, + ) + ) + + header = " ".join(name.ljust(width) for name, width in columns) + print(header) + print(" ".join("-" * width for _name, width in columns)) + for row in rows: + print( + " ".join( + _clip(value, width).ljust(width) + for value, (_name, width) in zip(row, columns) + ) + ) + print(f"\n{len(rows)} findings") + + +def parse_args(argv): + parser = argparse.ArgumentParser( + description="Print OpenShift app image and base-image references found in roles/openshift-apps.", + ) + parser.add_argument( + "--app", + action="append", + help="Only report one app. May be used more than once.", + ) + parser.add_argument( + "--kind", + action="append", + help="Only report one finding kind. May be used more than once.", + ) + parser.add_argument( + "--floating-only", + action="store_true", + help="Only show unpinned/latest image references.", + ) + parser.add_argument( + "--unresolved-only", + action="store_true", + help="Only show Jinja or external Dockerfile references.", + ) + parser.add_argument( + "--notes", default=os.fspath(NOTES_FILE), help="Annotation YAML file to load." + ) + parser.add_argument( + "--no-note-warnings", + action="store_true", + help="Do not warn about annotations that match no rows.", + ) + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv or sys.argv[1:]) + notes_file = Path(args.notes) + findings = scan_apps(args.app) + findings, unused_annotations = apply_annotations(findings, load_annotations(notes_file)) + findings = filter_findings(findings, args) + findings = sorted( + findings, key=lambda item: (item.app, item.path, item.line, item.image) + ) + if unused_annotations and not args.no_note_warnings: + for app, match in unused_annotations: + print( + f"Warning: annotation for {app!r} with match {match!r} matched no rows", + file=sys.stderr, + ) + print_table(findings) + return 0 + + +if __name__ == "__main__": + sys.exit(main())