diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0b63d4cf..b79f1e52 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,6 +12,12 @@ "description": "Adversarial hypothesis reviewer — systematically attacks theories and root cause analyses to find weaknesses before they find you", "version": "1.0.0" }, + { + "name": "edge-cve", + "source": "./plugins/edge-cve", + "description": "Investigate open Black CVE Jira tickets with govulncheck scans and actionable remediation reports", + "version": "1.0.0" + }, { "name": "edge-ic", "source": "./plugins/edge-ic", diff --git a/.gitignore b/.gitignore index 9028faab..44f8d093 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ markdownlint-cli2-results.json .env plugins/edge-ocp-rc/jobs/*.txt node_modules/ +.work diff --git a/plugins/edge-cve/.claude-plugin/plugin.json b/plugins/edge-cve/.claude-plugin/plugin.json new file mode 100644 index 00000000..20c0e829 --- /dev/null +++ b/plugins/edge-cve/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "edge-cve", + "description": "Investigate open Black CVE Jira tickets with govulncheck scans and actionable remediation reports", + "version": "1.0.0", + "author": { + "name": "edge-tooling" + }, + "homepage": "https://github.com/openshift-eng/edge-tooling", + "license": "Apache-2.0" +} diff --git a/plugins/edge-cve/README.md b/plugins/edge-cve/README.md new file mode 100644 index 00000000..7ddb0b84 --- /dev/null +++ b/plugins/edge-cve/README.md @@ -0,0 +1,301 @@ +# edge-cve + +Investigate open **Black** CVE Jira tickets for OpenShift edge components. +Deterministic scripts fetch and structure tickets; `govulncheck` runs against +target repositories either as OpenShift Jobs or, for local testing, as +sequential podman containers; LLM agents handle ambiguous grouping and +actionability analysis. + +## Quick start + +```bash +# Prerequisites +export JIRA_BASE_URL=https://redhat.atlassian.net +export JIRA_EMAIL=you@redhat.com +export JIRA_API_TOKEN= + +# Full workflow via skill +/edge-cve:investigate + +# Or run scripts directly (OpenShift) +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/edge-cve-workdir.XXXXXX")" +bash plugins/edge-cve/scripts/cve-investigator.sh prepare --workdir "$WORKDIR" +bash plugins/edge-cve/scripts/cve-investigator.sh scan --workdir "$WORKDIR" --repo openshift/lvm-operator --dry-run +bash plugins/edge-cve/scripts/cve-investigator.sh scan --workdir "$WORKDIR" --repo openshift/lvm-operator +bash plugins/edge-cve/scripts/cve-investigator.sh collect --workdir "$WORKDIR" --repo openshift/lvm-operator +bash plugins/edge-cve/scripts/cve-investigator.sh finalize --workdir "$WORKDIR" + +# Or run scan locally with podman instead (no cluster required) +bash plugins/edge-cve/scripts/cve-investigator.sh scan-local --workdir "$WORKDIR" --repo openshift/lvm-operator +bash plugins/edge-cve/scripts/cve-investigator.sh finalize --workdir "$WORKDIR" + +# --repo is repeatable to scope to a set of repositories +bash plugins/edge-cve/scripts/cve-investigator.sh scan --workdir "$WORKDIR" \ + --repo openshift/lvm-operator --repo openshift/microshift + +# Ad-hoc: is one repo/ref affected right now? No Jira/workdir setup needed. +bash plugins/edge-cve/scripts/cve-investigator.sh check-repo \ + --repo-url https://github.com/openshift/lvm-operator --ref release-4.18 --cve CVE-2024-99999 +``` + +## Jira scope + +Only tickets matching this JQL are in scope: + +```jql +filter = "All Open CVEs" AND filter = "All Open Black CVEs" +``` + +This matches the saved filter intersection at: + +https://redhat.atlassian.net/issues/?filter=92079&jql=filter%20%3D%20%22All%20Open%20CVEs%22%20and%20filter%20%3D%20%22All%20Open%20Black%20CVEs%22 + +## Workflow + +```mermaid +flowchart LR + A[Jira fetch] --> B[Parse by component/version] + B --> C[Group CVEs] + C --> D{Ambiguous?} + D -->|yes| E[LLM group review] + D -->|no| F[Build scan targets] + E --> F + F --> G1[OpenShift govulncheck jobs] + F --> G2[Local podman govulncheck, sequential] + G1 --> H[Collect results] + G2 --> H + H --> I{Affected?} + I -->|yes| J[LLM actionability analysis] + I -->|no| K[Report] + J --> K +``` + +Both execution paths run the exact same scan logic (`scan_target.sh` + +`process_govulncheck_result.go`); only how the result is published differs +(Kubernetes ConfigMap vs. local file). + +## Scripts + +| Script | Purpose | +|--------|---------| +| `fetch_cves.py` | Pull Black CVE tickets from Jira | +| `parse_cves.py` | Extract CVE IDs, components, versions, repos | +| `group_cves.py` | Deterministic grouping; flags ambiguous groups | +| `build_scan_targets.py` | Unique repo/ref targets for scanning | +| `run_govulncheck_jobs.sh` | Apply OpenShift jobs via `oc` (supports repeatable `--repo` filter) | +| `collect_govulncheck_results.py` | Read result ConfigMaps (supports repeatable `--repo` filter) | +| `run_govulncheck_podman.sh` | Run govulncheck sequentially via podman, no cluster required (supports repeatable `--repo` filter) | +| `scan_target.sh` | Shared clone/build/scan logic used by both the OpenShift Job and the podman runner | +| `run_single_repo_scan.sh` | Ad-hoc single repo@ref scan via podman, no scan-targets.json/Jira data required | +| `analyze_scan_result.py` | Deterministic verdict + `suggested_agent_prompt` for a single scan result | +| `generate_report.py` | Markdown report + remediation prompts | +| `generate_html_report.py` | Browsable HTML report, grouped by component/version, private tickets redacted | +| `cve-investigator.sh` | Orchestrator (`prepare`, `scan`, `collect`, `scan-local`, `finalize`, `check-repo`) | + +## Configuration + +Edit `config/component-repos.json` to map Jira components to GitHub repositories +and release branch templates. Each ticket's Jira versions (e.g. `4.18`) are +rendered through `version_ref_template` (e.g. `release-{version}`) to produce +the git refs we scan - we deliberately do **not** also scan tip-of-tree +(`main`/`master`) for versioned components, since that would capture far more +than the ticket is asking about. Leave `version_ref_fallbacks` empty for +versioned components; it is only consulted when a ticket has no version at +all. Tickets with a known repo but no resolvable release ref are skipped +(flagged `no_git_ref_resolved`) rather than silently pointed at `main`. + +## Python dependencies + +```bash +pip install requests +``` + +## OpenShift prerequisites + +- `oc` CLI logged into the target OpenShift cluster (`oc login`) +- Applies `k8s/namespace.yaml`, `k8s/rbac.yaml` (ServiceAccount/Role/RoleBinding), and one Job per target +- Jobs use `registry.redhat.io/ubi9/go-toolset:1.23` (OpenShift arbitrary-UID compatible), clone the repo at the target ref, and run `govulncheck -json ./...` +- `GOTOOLCHAIN=auto` lets Go auto-download a newer toolchain if `govulncheck@latest` requires one (needs egress to `proxy.golang.org` / `go.dev`) +- Container runs non-root with `readOnlyRootFilesystem`, `allowPrivilegeEscalation: false`, and all capabilities dropped; writable `emptyDir` volumes cover `/tmp` and `/tmp/workspace`. No fixed `runAsUser`/`fsGroup` — the namespace's default SCC (typically `restricted-v2`) assigns UID/GID automatically +- Cluster must be able to pull from `registry.redhat.io` + +## Result storage + +Each job publishes its result directly to the Kubernetes API as a ConfigMap +(`process_govulncheck_result.go` uses the mounted `edge-cve-scanner` service +account token — no PVC, shared storage, or collector pod involved). This +avoids exec/copy flakiness on clusters with slow or unreliable storage. + +- ConfigMap name: `govulncheck-result-` +- Labels: `app.kubernetes.io/name=edge-cve-govulncheck-result`, `edge-cve/target-id=`, `edge-cve/repo=` +- Data key: `result.json` — curated summary (`affected`, `scan_incomplete`, `matched_findings`, `cve_ids`, `ticket_keys`, `stderr_tail`, etc.) + +The raw, unfiltered `govulncheck -json` output is **not** stored in the +ConfigMap — for real repos it easily runs from hundreds of KB to tens of MB +(see e.g. the ~20k-line output for `lvm-operator@main`), which is both far +over the ConfigMap's ~1MiB total size limit and not practically useful once +inside one. `matched_findings` in `result.json` already carries the +CVE-relevant subset. If you need the full raw output for debugging, use the +local podman path (below), which writes it to disk uncapped. + +`scan_incomplete` is `true` when `scan_exit_code > 128` — i.e. the container +was terminated by a signal (137 = SIGKILL, almost always an OOM kill) before +govulncheck finished. In that case `/tmp/govulncheck.json` is partial/empty, +so `affected: false` does **not** mean "not affected" — it means the scan +never completed. `generate_report.py` treats `scan_incomplete` tickets as +`inconclusive`, never `not_affected`, so a killed scan can't be mistaken for +a clean result. + +Query results directly, e.g.: + +```bash +oc -n edge-cve-scans get configmaps -l edge-cve/repo=openshift--lvm-operator +``` + +## Job resource limits + +Each scan job/container requests `250m` CPU / `1Gi` memory, with limits of +`2` CPU / `4Gi` memory and `3Gi` `ephemeral-storage`. `govulncheck`'s +source-mode call-graph analysis (plus the go1.25 toolchain download it +triggers via `GOTOOLCHAIN=auto` for modules requiring a newer Go) can need +several GB of RAM even for a moderately sized operator repo — 1-2Gi is not +enough and gets SIGKILL'd (`scan_exit_code: 137`). OpenShift Jobs also cap +the `workspace` `emptyDir` at `3Gi` and set `activeDeadlineSeconds: 1800`, so +a bad repo/module still can't monopolize cluster capacity indefinitely when +many jobs run concurrently. If you still see exit 137, raise +`limits.memory` in `k8s/govulncheck-job.yaml.template` (or `--memory` for +podman) further. + +## Local execution (podman) + +`run_govulncheck_podman.sh` runs the same `scan_target.sh` clone/build/scan +logic as the OpenShift Job, one target at a time, in disposable podman +containers — no cluster, namespace, RBAC, or ConfigMaps required: + +```bash +bash plugins/edge-cve/scripts/run_govulncheck_podman.sh --workdir "$WORKDIR" --repo openshift/lvm-operator +# Bigger/slower repo needing more headroom? +bash plugins/edge-cve/scripts/run_govulncheck_podman.sh --workdir "$WORKDIR" --repo openshift/lvm-operator --memory 6g --cpus 4 +``` + +- Requires `podman` and pull access to `registry.redhat.io`. +- `process_govulncheck_result.go` detects local mode via the `RESULT_DIR` env + var (set by the podman script) and writes `result.json` plus a full, + uncapped copy of the raw `govulncheck.json` output to + `${WORKDIR}/scans/results//` instead of publishing a ConfigMap — + local disk isn't limited the way a ConfigMap is, so nothing is truncated. +- A named podman volume (`edge-cve-govulncheck-gocache`) is reused across + targets to cache the Go toolchain and module downloads between sequential + runs. The repo clone and Go build cache (`go clean -cache`) are removed + inside the container after every target (see `scan_target.sh`) so this + volume only grows with genuinely reusable data (modules/toolchains), not + with the ephemeral checkout or build objects. +- After all targets finish, results are aggregated into + `${WORKDIR}/scans/govulncheck-results.json` — the same shape + `collect_govulncheck_results.py` produces — so `finalize` works unchanged. + There is no separate `collect` step for local runs. +- Default container limits are `--memory 6g --cpus 3` (override with + `--memory`/`--cpus`) — the same "don't consume the whole machine" guardrail + as the OpenShift Job resource limits. If a target is OOM-killed + (`scan_exit_code: 137`, logged as "OOM-killed"), re-run with a higher + `--memory`; its `result.json` will have `scan_incomplete: true` so it's + never mistaken for a clean "not affected" result. +- Each target gets a named container (`edge-cve-scan-`) and a + `--timeout` wall-clock cap (default 1800s, override with `--timeout`). If a + clone or toolchain download hangs past the timeout, the container is + force-removed rather than left running indefinitely — this is what + previously orphaned a multi-GB container and filled up the podman VM's + disk. Host-wide `podman system prune -f` is **opt-in** via `--prune` + (default is no prune / `--no-prune`) — never run it against a shared + podman machine without explicit approval, since it can delete unrelated + stopped containers and dangling images. +- If the podman machine's disk still fills up (e.g. from unrelated images on + the same machine), reclaim space only after confirming with the user: + `podman system prune -f` (leaves named volumes alone) or, more aggressively, + `podman image prune -a -f --filter until=720h` to drop any image unused for + 30+ days. + +## Ad-hoc single-repo check + +`cve-investigator.sh check-repo` (or `/edge-cve:investigate --check-repo`) is +a lightweight alternative to the full prepare/scan/finalize pipeline for +"is this one repo/ref affected, right now" questions - no Jira ticket or +`scan-targets.json` needed: + +```bash +bash plugins/edge-cve/scripts/cve-investigator.sh check-repo \ + --repo-url https://github.com/openshift/lvm-operator --ref release-4.18 \ + --cve CVE-2024-99999 --jira-url https://redhat.atlassian.net/browse/EDGE-123 +``` + +- Clones the repo@ref and runs `govulncheck` via podman (`run_single_repo_scan.sh`, + reusing the same `scan_target.sh`/hardening as `run_govulncheck_podman.sh`: + named container, wall-clock `--timeout`, cleanup on exit, shared + `edge-cve-govulncheck-gocache` volume for speed). +- `--cve` is repeatable and optional - omit it for a general "any known + vulnerability at this ref" check instead of a specific-CVE check. +- `analyze_scan_result.py` then deterministically (no LLM call) computes a + `verdict` (`affected` | `not_affected` | `inconclusive`) and prints JSON + with a `suggested_agent_prompt` field: a ready-to-use remediation prompt + built from the scan's own matched findings when the repo is affected, or + `null` when it isn't. `inconclusive` (e.g. an OOM-killed scan) gets a + prompt that says to re-run with more memory rather than to write a fix. +- Optional `--ticket`, `--summary`, `--component` add more context to the + generated prompt; none are required. + +## HTML report + +`finalize` (and `/edge-cve:investigate`'s Step 4) also writes +`report-cve-investigation.html` via `generate_html_report.py` - a +self-contained, dependency-free HTML file (no CDN/JS framework - safe to open +offline or attach to an email/Slack message): + +- **Scoped to components we actually track**: only components listed in + `config/component-repos.json` (e.g. MicroShift, Logical Volume Manager + Storage) are shown - the Black CVE filter spans hundreds of components + across the whole org, so everything else is dropped (not just collapsed) + before rendering. The dropped count and the exact component list used are + both printed on stdout (`known_components`, `dropped_unmapped_components`) + so this is a visible, deterministic filter, not missing data. Override + with `--config PATH` if you need a different mapping file. +- Grouped by Jira **component**, then by **affected version**; each ticket's + CVE ID(s) link back to its Jira ticket, with a colored verdict badge + (`AFFECTED` / `NOT AFFECTED` / `INCONCLUSIVE` / `NOT SCANNED`) and, when + scanned, the govulncheck status per repo@ref - including the actual + matched finding(s) (vulnerability ID + module, via the same formatting as + `analyze_scan_result.py`), not just the pass/fail badge. +- A separate **"Ad-hoc repo checks"** section lists every `check-repo` run + found under `/scans/results/*/analysis.json` - so validating + `openshift/microshift` or `openshift/lvm-operator` directly (outside the + Jira pipeline) still shows up in the same report, as long as it used the + same `--workdir`. These are kept separate from the ticket tables above + since an ad-hoc check may have no corresponding Jira ticket at all. +- A client-side text filter (plain JS, no network calls) narrows down to + matching CVE/ticket/component/repo text across both sections. +- **Private tickets are redacted**: any ticket whose Jira Security Level or + labels contain "private" (see `lib.cve_extract.is_private_ticket`) renders + as nothing but a lock icon and a link to the Jira ticket - no CVE ID, + summary, or govulncheck findings are included in the HTML for those. This + is computed once in `parse_cves.py` (`is_private`/`security_level` fields) + and enforced again at render time in `generate_html_report.py`. +- Like every other asset in the pipeline (`jira/cves-*.json`, `scans/*.json`, + `report-cve-investigation.md`), the HTML report always lives under + `--workdir` - there's no separate `--output` flag, so a report can never + end up outside the run's own directory. Regenerate it without re-scanning: + `python3 plugins/edge-cve/scripts/generate_html_report.py --workdir "$WORKDIR"` + +## Outputs + +- `report-cve-investigation.md` — team notification report (markdown) +- `report-cve-investigation.html` — same data, browsable/filterable HTML (see above) +- `remediation-prompts.md` — agent prompts for actionable CVEs +- `jira/cves-llm-review.json` — groups needing LLM review before scanning +- `check-repo`: prints its JSON result (including `suggested_agent_prompt`) + directly to stdout and writes it to + `/scans/results//analysis.json` + +## Design principles + +- **Deterministic first**: Jira parsing, grouping, repo resolution, and report structure are code-driven. +- **LLM for judgment**: grouping review, govulncheck interpretation, and remediation planning only. +- **Black CVEs only**: the JQL filter intersection is fixed and must not be broadened. diff --git a/plugins/edge-cve/config/component-repos.json b/plugins/edge-cve/config/component-repos.json new file mode 100644 index 00000000..80cda5ad --- /dev/null +++ b/plugins/edge-cve/config/component-repos.json @@ -0,0 +1,48 @@ +{ + "defaults": { + "host": "github.com", + "org": "openshift" + }, + "components": { + "MicroShift": { + "repo": "openshift/microshift", + "language": "go", + "version_ref_template": "release-{version}", + "version_ref_fallbacks": [] + }, + "Logical Volume Manager Storage": { + "repo": "openshift/lvm-operator", + "language": "go", + "version_ref_template": "release-{version}", + "version_ref_fallbacks": [] + }, + "Two Node Fencing": { + "repo": "openshift-eng/two-node-toolbox", + "language": "go", + "version_ref_template": "main", + "version_ref_fallbacks": [] + }, + "Two Node Arbiter": { + "repo": "openshift-eng/two-node-toolbox", + "language": "go", + "version_ref_template": "main", + "version_ref_fallbacks": [] + }, + "Cluster Node Tuning Operator": { + "repo": "openshift/cluster-node-tuning-operator", + "language": "go", + "version_ref_template": "release-{version}", + "version_ref_fallbacks": [] + }, + "Machine Config Operator": { + "repo": "openshift/machine-config-operator", + "language": "go", + "version_ref_template": "release-{version}", + "version_ref_fallbacks": [] + } + }, + "repo_url_patterns": [ + "github\\.com/(?P[^/\\s]+)/(?P[^/\\s#?]+)", + "git@github\\.com:(?P[^/\\s]+)/(?P[^/\\s#?.]+)" + ] +} diff --git a/plugins/edge-cve/k8s/govulncheck-job.yaml.template b/plugins/edge-cve/k8s/govulncheck-job.yaml.template new file mode 100644 index 00000000..29d2df50 --- /dev/null +++ b/plugins/edge-cve/k8s/govulncheck-job.yaml.template @@ -0,0 +1,92 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: govulncheck-__TARGET_ID__ + namespace: edge-cve-scans + labels: + app.kubernetes.io/name: edge-cve-govulncheck + edge-cve/target-id: "__TARGET_ID__" + edge-cve/repo: "__REPO_LABEL__" +spec: + backoffLimit: 1 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: edge-cve-govulncheck + edge-cve/target-id: "__TARGET_ID__" + spec: + restartPolicy: Never + serviceAccountName: edge-cve-scanner + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + containers: + - name: govulncheck + image: registry.redhat.io/ubi9/go-toolset:1.23 + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + env: + - name: REPO_URL + value: "__REPO_URL__" + - name: REPO_SLUG + value: "__REPO_SLUG__" + - name: REPO_LABEL + value: "__REPO_LABEL__" + - name: GIT_REF + value: "__GIT_REF__" + - name: TARGET_ID + value: "__TARGET_ID__" + - name: CVE_IDS + value: "__CVE_IDS__" + - name: TICKET_KEYS + value: "__TICKET_KEYS__" + - name: HOME + value: "/tmp" + - name: GOPATH + value: "/tmp/go" + - name: GOCACHE + value: "/tmp/go/cache" + - name: GOMODCACHE + value: "/tmp/go/pkg/mod" + - name: GOTOOLCHAIN + value: "auto" + resources: + requests: + cpu: "1" + memory: "4Gi" + ephemeral-storage: "4Gi" + limits: + cpu: "3" + memory: "6Gi" + ephemeral-storage: "6Gi" + volumeMounts: + # Writable /tmp for HOME/GOPATH/GOCACHE/GOMODCACHE and + # govulncheck stdout/stderr files (required with readOnlyRootFilesystem). + - name: tmp + mountPath: /tmp + - name: workspace + mountPath: /tmp/workspace + - name: scripts + mountPath: /scripts + readOnly: true + command: + - /bin/bash + - /scripts/scan_target.sh + activeDeadlineSeconds: 1800 + volumes: + - name: tmp + emptyDir: + sizeLimit: 3Gi + - name: workspace + emptyDir: + sizeLimit: 3Gi + - name: scripts + configMap: + name: edge-cve-govulncheck-scripts + defaultMode: 0444 diff --git a/plugins/edge-cve/k8s/namespace.yaml b/plugins/edge-cve/k8s/namespace.yaml new file mode 100644 index 00000000..e699222c --- /dev/null +++ b/plugins/edge-cve/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: edge-cve-scans + labels: + app.kubernetes.io/name: edge-cve-scans diff --git a/plugins/edge-cve/k8s/rbac.yaml b/plugins/edge-cve/k8s/rbac.yaml new file mode 100644 index 00000000..d0899a10 --- /dev/null +++ b/plugins/edge-cve/k8s/rbac.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: edge-cve-scanner + namespace: edge-cve-scans +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: edge-cve-scanner + namespace: edge-cve-scans +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "create", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: edge-cve-scanner + namespace: edge-cve-scans +subjects: + - kind: ServiceAccount + name: edge-cve-scanner + namespace: edge-cve-scans +roleRef: + kind: Role + name: edge-cve-scanner + apiGroup: rbac.authorization.k8s.io diff --git a/plugins/edge-cve/requirements.txt b/plugins/edge-cve/requirements.txt new file mode 100644 index 00000000..a8608b2c --- /dev/null +++ b/plugins/edge-cve/requirements.txt @@ -0,0 +1 @@ +requests>=2.28.0 diff --git a/plugins/edge-cve/scripts/analyze_scan_result.py b/plugins/edge-cve/scripts/analyze_scan_result.py new file mode 100755 index 00000000..e160638f --- /dev/null +++ b/plugins/edge-cve/scripts/analyze_scan_result.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Deterministically analyze a single govulncheck result.json and decide what +action, if any, is needed - no LLM call required for this base determination. + +Given the result.json written by process_govulncheck_result.go (local mode; +see run_single_repo_scan.sh / scan_target.sh), this: + +1. Computes a verdict ("affected", "not_affected", or "inconclusive") using + the same signal-kill-aware logic as generate_report.py's + verdict_for_ticket, so a scan that was OOM-killed (scan_incomplete) is + never mistaken for a clean "not affected" result. +2. Builds a ready-to-use `suggested_agent_prompt` string from a fixed + template filled in with the scan's own matched findings - a deterministic + remediation prompt, not an LLM-generated one. Callers who want the LLM to + refine/verify this prompt (e.g. edge-cve:investigate Step 3) can still do + so as a separate step. + +Usage: + analyze_scan_result.py --result RESULT_JSON [--out OUT_JSON] + [--jira-url URL] [--summary TEXT] [--component NAME] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def determine_verdict(result: dict) -> tuple[str, bool]: + """Return (verdict, action_required).""" + if result.get("scan_incomplete"): + return "inconclusive", False + if result.get("affected"): + return "affected", True + # govulncheck: 0 = clean, 3 = vulnerabilities found. Any other exit is + # abnormal (tool/build error, etc.) - treat as inconclusive even when + # finding_count is 0, so a failed scan is never mistaken for "not affected". + if result.get("scan_exit_code", 0) not in (0, 3): + return "inconclusive", False + return "not_affected", False + + +def finding_label(finding: dict) -> str: + inner = finding.get("finding", finding) + if not isinstance(inner, dict): + inner = finding if isinstance(finding, dict) else {} + # govulncheck may emit finding.osv as a string ID or an embedded OSV object. + osv = inner.get("osv") + if not osv: + osv = inner.get("vulnerability") + vuln_id = "?" + if isinstance(osv, str): + vuln_id = osv or "?" + elif isinstance(osv, dict): + raw_id = osv.get("id", "?") + vuln_id = raw_id if isinstance(raw_id, str) and raw_id else "?" + module = "" + trace = inner.get("trace") or [] + if trace and isinstance(trace, list) and isinstance(trace[0], dict): + module = trace[0].get("module", "") or trace[0].get("package", "") or "" + return f"{vuln_id}" + (f" in {module}" if module else "") + + +def build_prompt( + result: dict, + verdict: str, + *, + jira_url: str = "", + summary: str = "", + component: str = "", +) -> str | None: + if verdict == "not_affected": + return None + + repo_url = result.get("repo_url", "") + repo_slug = result.get("repo_slug", "") + git_ref = result.get("git_ref", "") + commit = result.get("commit", "") or "" + cve_ids = result.get("cve_ids") or [] + findings = result.get("matched_findings") or [] + + lines = [ + "You are fixing a CVE in an OpenShift edge component repository.", + "", + ] + if jira_url: + lines.append(f"Jira: {jira_url}") + if summary: + lines.append(f"Summary: {summary}") + if cve_ids: + lines.append(f"CVEs: {', '.join(cve_ids)}") + if component: + lines.append(f"Component: {component}") + lines.append(f"Repository: {repo_slug} ({repo_url})") + lines.append(f"Target ref: {git_ref} (commit {commit[:12] if commit else 'unknown'})") + lines.append("") + + if verdict == "inconclusive": + lines.extend( + [ + "govulncheck did not produce a conclusive result for this ref", + "(scan_incomplete or a non-zero exit with ambiguous findings), so this", + "is NOT yet confirmed as affected. Before writing any fix:", + "1. Re-run with more memory/CPU (see run_single_repo_scan.sh/" + "run_govulncheck_podman.sh --memory) or check the scan's stderr_tail" + " for the real cause.", + "2. Only proceed with a fix once govulncheck confirms an affected finding.", + ] + ) + return "\n".join(lines) + + # verdict == "affected" + lines.append("govulncheck confirmed this repository/ref is affected:") + for finding in findings: + lines.append(f"- {finding_label(finding)}") + lines.extend( + [ + "", + "Steps:", + f"1. Clone the repository and checkout {git_ref}.", + "2. Confirm the vulnerable module/path above against govulncheck's " + "call-graph findings (matched_findings in the scan result).", + "3. Bump the dependency (or apply the upstream fix) to a version " + "that resolves the vulnerability.", + "4. Run `go mod tidy && go test ./...` and `govulncheck ./...` to " + "verify the fix and check for regressions.", + "5. Open a PR" + + (f" referencing {jira_url}" if jira_url else " describing the fix") + + ".", + ] + ) + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--result", required=True, help="Path to a result.json from a govulncheck scan") + parser.add_argument("--out", help="Write the augmented JSON here (always also printed to stdout)") + parser.add_argument("--jira-url", default="", help="Jira ticket URL for context in the prompt") + parser.add_argument("--summary", default="", help="Ticket/issue summary for context in the prompt") + parser.add_argument("--component", default="", help="Component name for context in the prompt") + args = parser.parse_args() + + result_path = Path(args.result) + if not result_path.is_file(): + print(f"Error: {result_path} not found", file=sys.stderr) + sys.exit(1) + + result = json.loads(result_path.read_text(encoding="utf-8")) + verdict, action_required = determine_verdict(result) + prompt = build_prompt( + result, + verdict, + jira_url=args.jira_url, + summary=args.summary, + component=args.component, + ) + + output = dict(result) + output["verdict"] = verdict + output["action_required"] = action_required + output["suggested_agent_prompt"] = prompt + # Persist the context args as their own fields (not just baked into the + # prompt text) so downstream consumers (generate_html_report.py) can + # render a proper Jira link/summary without re-parsing prose. + if args.jira_url: + output["jira_url"] = args.jira_url + if args.summary: + output["summary"] = args.summary + if args.component: + output["component"] = args.component + + text = json.dumps(output, indent=2) + if args.out: + Path(args.out).write_text(text + "\n", encoding="utf-8") + print(f"Written: {args.out}", file=sys.stderr) + print(text) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/build_scan_targets.py b/plugins/edge-cve/scripts/build_scan_targets.py new file mode 100644 index 00000000..0b97d3e9 --- /dev/null +++ b/plugins/edge-cve/scripts/build_scan_targets.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Build unique govulncheck scan targets from grouped CVE tickets. + +Usage: + build_scan_targets.py --workdir DIR [--input FILE] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + + +def slugify(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")[:50] + + +def _normalize_for_digest(value: str) -> str: + return value.strip().lower() + + +def target_id(repo_slug: str, git_ref: str) -> str: + """Readable slug pair plus a short digest of the full normalized inputs. + + Truncated slugify alone can collide (long slugs/refs); the digest keeps + ids distinct while preserving the human-readable prefix. + """ + digest = hashlib.sha256( + f"{_normalize_for_digest(repo_slug)}\n{_normalize_for_digest(git_ref)}".encode() + ).hexdigest()[:8] + return f"{slugify(repo_slug)}--{slugify(git_ref)}--{digest}" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build govulncheck scan targets") + parser.add_argument("--workdir", required=True) + parser.add_argument("--input", default="") + parser.add_argument("--output", default="") + args = parser.parse_args() + + workdir = Path(args.workdir) + input_path = Path(args.input) if args.input else workdir / "jira" / "cves-grouped.json" + output_path = Path(args.output) if args.output else workdir / "scans" / "scan-targets.json" + + if not input_path.is_file(): + print(f"Error: input not found: {input_path}", file=sys.stderr) + sys.exit(1) + + with open(input_path, encoding="utf-8") as fh: + grouped = json.load(fh) + + targets_map: dict[str, dict] = {} + + for group in grouped.get("groups", []): + cve_ids = sorted({group["cve_id"]} | set()) + if group["cve_id"] != "UNKNOWN-CVE": + group_cves = [group["cve_id"]] + else: + group_cves = sorted( + { + cve + for ticket in group.get("tickets", []) + for cve in ticket.get("cve_ids", []) + } + ) + if group_cves: + cve_ids = group_cves + else: + cve_ids = [] + + for ticket in group.get("tickets", []): + ticket_cves = ticket.get("cve_ids") or cve_ids + for target in ticket.get("scan_targets", []): + repo = target["repo"] + repo_slug = repo["slug"] + language = repo.get("language", "go") + for git_ref in target.get("git_refs") or []: + if not git_ref: + continue + tid = target_id(repo_slug, git_ref) + if tid not in targets_map: + targets_map[tid] = { + "id": tid, + "repo_slug": repo_slug, + "repo_url": repo["url"], + "git_ref": git_ref, + "language": language, + "cve_ids": sorted(set(ticket_cves)), + "ticket_keys": [], + "components": [], + "versions": [], + } + entry = targets_map[tid] + entry["cve_ids"] = sorted(set(entry["cve_ids"]) | set(ticket_cves)) + if ticket["key"] not in entry["ticket_keys"]: + entry["ticket_keys"].append(ticket["key"]) + comp = ticket.get("component") + if comp and comp not in entry["components"]: + entry["components"].append(comp) + for version in ticket.get("versions", []): + if version not in entry["versions"]: + entry["versions"].append(version) + + targets = sorted(targets_map.values(), key=lambda t: (t["repo_slug"], t["git_ref"])) + for target in targets: + target["ticket_keys"] = sorted(target["ticket_keys"]) + target["components"] = sorted(target["components"]) + target["versions"] = sorted(target["versions"]) + + go_targets = [t for t in targets if t.get("language") == "go"] + skipped = [t for t in targets if t.get("language") != "go"] + + result = { + "built_at": datetime.now(timezone.utc).isoformat(), + "source": str(input_path), + "target_count": len(targets), + "go_target_count": len(go_targets), + "skipped_non_go": len(skipped), + "targets": go_targets, + "skipped_targets": skipped, + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=2) + + print( + f"Built {len(go_targets)} Go scan targets ({len(skipped)} non-Go skipped)", + file=sys.stderr, + ) + print(f"Written: {output_path}", file=sys.stderr) + print( + json.dumps( + { + "go_target_count": len(go_targets), + "skipped_non_go": len(skipped), + "output": str(output_path), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/collect_govulncheck_results.py b/plugins/edge-cve/scripts/collect_govulncheck_results.py new file mode 100644 index 00000000..21ac1765 --- /dev/null +++ b/plugins/edge-cve/scripts/collect_govulncheck_results.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Collect govulncheck job results from labeled ConfigMaps. + +Each scan job publishes its result as a ConfigMap labeled +`app.kubernetes.io/name=edge-cve-govulncheck-result`, `edge-cve/target-id`, +and `edge-cve/repo`. This collects them with a single `oc get configmaps` +call rather than exec-ing into a helper pod against shared storage. + +Usage: + collect_govulncheck_results.py --workdir DIR [--namespace NS] [--repo SLUG ...] [--timeout SECONDS] +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +RESULT_LABEL = "app.kubernetes.io/name=edge-cve-govulncheck-result" + + +def run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run(cmd, check=check, text=True, capture_output=True) + + +def oc_base(namespace: str) -> list[str]: + return ["oc", "-n", namespace] + + +def sanitize_label(raw: str) -> str: + """Mirror Go sanitizeLabel used for edge-cve/repo ConfigMap labels. + + Preserves uppercase (same as run_govulncheck_jobs.sh REPO_LABEL). Truncate + to 63 first, then trim trailing "-_." so the value always ends alphanumeric. + """ + label = raw.replace("/", "--") + label = re.sub(r"[^A-Za-z0-9._-]", "-", label) + label = label[:63] + return label.rstrip("-_.") + + +def job_is_terminal(job: dict) -> bool: + """True when the Job has a terminal Complete or Failed condition. + + Newly created Jobs often have active==0 before pods are scheduled; those + must keep polling until a terminal condition appears. + """ + status = job.get("status") or {} + for cond in status.get("conditions") or []: + if not isinstance(cond, dict): + continue + if cond.get("status") != "True": + continue + if cond.get("type") in ("Complete", "Failed"): + return True + return False + + +def summarize_jobs(items: list) -> dict: + """Aggregate Job counters and whether every Job is terminal.""" + active = failed = succeeded = 0 + all_terminal = True + for job in items: + status = job.get("status") or {} + if status.get("active"): + active += 1 + if status.get("failed"): + failed += int(status["failed"]) + if status.get("succeeded"): + succeeded += int(status["succeeded"]) + if not job_is_terminal(job): + all_terminal = False + return { + "complete": all_terminal, + "active": active, + "failed": failed, + "succeeded": succeeded, + } + + +def wait_for_jobs(namespace: str, timeout: int) -> dict: + start = time.time() + while time.time() - start < timeout: + proc = run( + [ + *oc_base(namespace), + "get", + "jobs", + "-l", + "app.kubernetes.io/name=edge-cve-govulncheck", + "-o", + "json", + ], + check=False, + ) + if proc.returncode != 0: + time.sleep(10) + continue + data = json.loads(proc.stdout or "{}") + items = data.get("items", []) + if not items: + return {"complete": True, "active": 0, "failed": 0, "succeeded": 0} + + summary = summarize_jobs(items) + if summary["complete"]: + return summary + time.sleep(15) + + return {"complete": False, "timeout": timeout} + + +def collect_result_configmaps(namespace: str, repo_filters: list[str]) -> list[dict]: + selector = RESULT_LABEL + if repo_filters: + sanitized = [sanitize_label(r) for r in repo_filters] + if len(sanitized) == 1: + selector = f"{selector},edge-cve/repo={sanitized[0]}" + else: + selector = f"{selector},edge-cve/repo in ({','.join(sanitized)})" + + proc = run( + [*oc_base(namespace), "get", "configmaps", "-l", selector, "-o", "json"], + check=False, + ) + if proc.returncode != 0: + print(f"Warning: failed to list result configmaps: {proc.stderr}", file=sys.stderr) + return [] + + data = json.loads(proc.stdout or "{}") + results = [] + for cm in data.get("items", []): + name = cm.get("metadata", {}).get("name", "") + raw = cm.get("data", {}).get("result.json") + if not raw: + print(f"Warning: configmap {name} has no result.json key", file=sys.stderr) + continue + try: + results.append(json.loads(raw)) + except json.JSONDecodeError: + print(f"Warning: invalid JSON in configmap {name}", file=sys.stderr) + return results + + +def main() -> None: + parser = argparse.ArgumentParser(description="Collect govulncheck scan results") + parser.add_argument("--workdir", required=True) + parser.add_argument("--namespace", default="edge-cve-scans") + parser.add_argument( + "--repo", + action="append", + default=[], + help="Only collect results for this repo slug (e.g. openshift/lvm-operator). Repeatable.", + ) + parser.add_argument("--timeout", type=int, default=3600) + parser.add_argument("--skip-wait", action="store_true") + args = parser.parse_args() + + proc = run(["oc", "whoami"], check=False) + if proc.returncode != 0: + print("Error: not logged into OpenShift. Run 'oc login' first.", file=sys.stderr) + sys.exit(1) + + workdir = Path(args.workdir) + output_path = workdir / "scans" / "govulncheck-results.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + + wait_info = {"skipped": True} + if not args.skip_wait: + wait_info = wait_for_jobs(args.namespace, args.timeout) + if not wait_info.get("complete"): + print( + f"Warning: timed out after {args.timeout}s waiting for jobs", + file=sys.stderr, + ) + + parsed_results = collect_result_configmaps(args.namespace, args.repo) + + payload = { + "collected_at": datetime.now(timezone.utc).isoformat(), + "namespace": args.namespace, + "repo_filters": args.repo, + "wait": wait_info, + "results": parsed_results, + } + + with open(output_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + + affected = sum(1 for r in parsed_results if r.get("affected")) + print( + f"Collected {len(parsed_results)} results ({affected} affected)", + file=sys.stderr, + ) + print(f"Written: {output_path}", file=sys.stderr) + print( + json.dumps( + { + "result_count": len(parsed_results), + "affected_count": affected, + "output": str(output_path), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/cve-investigator.sh b/plugins/edge-cve/scripts/cve-investigator.sh new file mode 100755 index 00000000..1b3281c6 --- /dev/null +++ b/plugins/edge-cve/scripts/cve-investigator.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# Deterministic orchestration for edge-cve investigation workflows. +# +# cve-investigator.sh prepare --workdir DIR +# cve-investigator.sh scan --workdir DIR [--namespace NS] [--repo SLUG ...] [--dry-run] +# cve-investigator.sh collect --workdir DIR [--namespace NS] [--repo SLUG ...] +# cve-investigator.sh scan-local --workdir DIR [--repo SLUG ...] [--image IMAGE] +# cve-investigator.sh finalize --workdir DIR +# cve-investigator.sh check-repo --repo-url URL --ref REF [--cve ID ...] +# [--ticket KEY ...] [--jira-url URL] [--summary TEXT] [--component NAME] +# [--workdir DIR] [--memory MEM] [--cpus N] [--timeout SECONDS] +# +# --repo is repeatable to scope any of scan/collect/scan-local to a set of +# repositories (e.g. --repo openshift/lvm-operator --repo openshift/microshift). +# +# "scan" + "collect" launch OpenShift Jobs and gather results from ConfigMaps. +# "scan-local" runs the same govulncheck logic sequentially via podman, with +# no cluster required, and writes scans/govulncheck-results.json directly - +# no separate collect step needed for that path. +# +# "check-repo" is an ad-hoc, single-repo alternative to the Jira-driven +# prepare/scan/finalize pipeline: clone one repo@ref, run govulncheck via +# podman, and deterministically decide whether action is needed - printing a +# JSON result with a `suggested_agent_prompt` field ready to hand to a coding +# agent. No scan-targets.json or Jira ticket is required (though --cve/ +# --ticket/--jira-url/--summary/--component can supply that context if known). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Validate a workdir for a new investigation (prepare). Rejects unsafe paths and +# non-empty directories so a run cannot clobber prior outputs or write into a +# system/home root. The directory may be missing (created by prepare) or exist +# and be empty. +validate_new_workdir() { + local dir="$1" + + if [[ -z "${dir}" ]]; then + echo "Error: --workdir is empty" >&2 + return 1 + fi + if [[ "${dir}" != /* ]]; then + echo "Error: --workdir must be an absolute path (got '${dir}')" >&2 + return 1 + fi + case "${dir}" in + *..*) + echo "Error: --workdir must not contain '..' (got '${dir}')" >&2 + return 1 + ;; + esac + case "${dir}" in + /|/tmp|/var|/usr|/etc|/home|/root|/opt|/Users|"${HOME}"|"${HOME%/}") + echo "Error: --workdir '${dir}' is unsafe (refusing system/home root)" >&2 + return 1 + ;; + esac + + if [[ -e "${dir}" ]]; then + if [[ ! -d "${dir}" ]]; then + echo "Error: --workdir '${dir}' exists and is not a directory" >&2 + return 1 + fi + # -A: treat hidden files as non-empty too + if [[ -n "$(ls -A -- "${dir}" 2>/dev/null || true)" ]]; then + echo "Error: --workdir '${dir}' is not empty; use a fresh per-run directory" >&2 + return 1 + fi + fi +} + +cmd_prepare() { + local workdir="" + local jql="" + while [[ $# -gt 0 ]]; do + case "$1" in + --workdir) workdir="$2"; shift 2 ;; + --jql) jql="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; return 1 ;; + esac + done + [[ -n "${workdir}" ]] || { echo "Error: --workdir required" >&2; return 1; } + validate_new_workdir "${workdir}" || return 1 + mkdir -p "${workdir}/jira" "${workdir}/scans" + + local fetch_args=(--workdir "${workdir}") + [[ -n "${jql}" ]] && fetch_args+=(--jql "${jql}") + + python3 "${SCRIPT_DIR}/fetch_cves.py" "${fetch_args[@]}" + python3 "${SCRIPT_DIR}/parse_cves.py" --workdir "${workdir}" + python3 "${SCRIPT_DIR}/group_cves.py" --workdir "${workdir}" + python3 "${SCRIPT_DIR}/build_scan_targets.py" --workdir "${workdir}" +} + +cmd_scan() { + bash "${SCRIPT_DIR}/run_govulncheck_jobs.sh" "$@" +} + +cmd_collect() { + python3 "${SCRIPT_DIR}/collect_govulncheck_results.py" "$@" +} + +cmd_scan_local() { + bash "${SCRIPT_DIR}/run_govulncheck_podman.sh" "$@" +} + +cmd_finalize() { + local workdir="" + while [[ $# -gt 0 ]]; do + case "$1" in + --workdir) workdir="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; return 1 ;; + esac + done + [[ -n "${workdir}" ]] || { echo "Error: --workdir required" >&2; return 1; } + python3 "${SCRIPT_DIR}/generate_report.py" --workdir "${workdir}" + python3 "${SCRIPT_DIR}/generate_html_report.py" --workdir "${workdir}" +} + +cmd_check_repo() { + local workdir="" repo_url="" repo_slug="" ref="" jira_url="" summary="" component="" + local memory="" cpus="" timeout="" prune=0 + local cve_ids=() ticket_keys=() + while [[ $# -gt 0 ]]; do + case "$1" in + --workdir) workdir="$2"; shift 2 ;; + --repo-url) repo_url="$2"; shift 2 ;; + --repo-slug) repo_slug="$2"; shift 2 ;; + --ref) ref="$2"; shift 2 ;; + --cve) cve_ids+=("$2"); shift 2 ;; + --ticket) ticket_keys+=("$2"); shift 2 ;; + --jira-url) jira_url="$2"; shift 2 ;; + --summary) summary="$2"; shift 2 ;; + --component) component="$2"; shift 2 ;; + --memory) memory="$2"; shift 2 ;; + --cpus) cpus="$2"; shift 2 ;; + --timeout) timeout="$2"; shift 2 ;; + --prune) prune=1; shift ;; + --no-prune) prune=0; shift ;; + *) echo "Unknown option: $1" >&2; return 1 ;; + esac + done + [[ -n "${repo_url}" && -n "${ref}" ]] || { + echo "Error: --repo-url and --ref are required" >&2 + return 1 + } + if [[ -z "${workdir}" ]]; then + # GNU/BSD-compatible template (trailing Xs required). Expand the path into + # the trap now so cleanup still works after this function's locals go away. + workdir="$(mktemp -d "${TMPDIR:-/tmp}/edge-cve-check-repo.XXXXXX")" + # shellcheck disable=SC2064 + trap "rm -rf -- $(printf '%q' "${workdir}")" EXIT + fi + local results_dir="${workdir}/scans/results" + mkdir -p "${results_dir}" + + local scan_args=(--repo-url "${repo_url}" --ref "${ref}" --result-dir "${results_dir}") + [[ -n "${repo_slug}" ]] && scan_args+=(--repo-slug "${repo_slug}") + if [[ ${#cve_ids[@]} -gt 0 ]]; then + for cve in "${cve_ids[@]}"; do + scan_args+=(--cve "${cve}") + done + fi + if [[ ${#ticket_keys[@]} -gt 0 ]]; then + for ticket in "${ticket_keys[@]}"; do + scan_args+=(--ticket "${ticket}") + done + fi + [[ -n "${memory}" ]] && scan_args+=(--memory "${memory}") + [[ -n "${cpus}" ]] && scan_args+=(--cpus "${cpus}") + [[ -n "${timeout}" ]] && scan_args+=(--timeout "${timeout}") + if [[ ${prune} -eq 1 ]]; then + scan_args+=(--prune) + else + scan_args+=(--no-prune) + fi + + local result_file + result_file="$(bash "${SCRIPT_DIR}/run_single_repo_scan.sh" "${scan_args[@]}")" + + local analyze_args=(--result "${result_file}" --out "$(dirname "${result_file}")/analysis.json") + [[ -n "${jira_url}" ]] && analyze_args+=(--jira-url "${jira_url}") + [[ -n "${summary}" ]] && analyze_args+=(--summary "${summary}") + [[ -n "${component}" ]] && analyze_args+=(--component "${component}") + + python3 "${SCRIPT_DIR}/analyze_scan_result.py" "${analyze_args[@]}" +} + +usage() { + cat <&2; usage; exit 1 ;; + esac +} + +main "$@" diff --git a/plugins/edge-cve/scripts/fetch_cves.py b/plugins/edge-cve/scripts/fetch_cves.py new file mode 100644 index 00000000..4b01e464 --- /dev/null +++ b/plugins/edge-cve/scripts/fetch_cves.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Fetch open Black CVE tickets from Jira. + +Uses the intersection of the "All Open CVEs" and "All Open Black CVEs" saved +filters — the same query as: + https://redhat.atlassian.net/issues/?filter=92079&jql=filter%20%3D%20%22All%20Open%20CVEs%22%20and%20filter%20%3D%20%22All%20Open%20Black%20CVEs%22 + +Usage: + fetch_cves.py --workdir DIR [--jql QUERY] [--output FILE] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from lib.jira_client import ( # noqa: E402 + DEFAULT_JQL, + JiraConfigError, + load_config, + normalize_issue, + search_jql, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Fetch Black CVE tickets from Jira") + parser.add_argument("--workdir", required=True, help="Working directory for outputs") + parser.add_argument("--jql", default=DEFAULT_JQL, help="Jira JQL query") + parser.add_argument( + "--output", + default="", + help="Output JSON path (default: /jira/cves-raw.json)", + ) + args = parser.parse_args() + + workdir = Path(args.workdir) + workdir.mkdir(parents=True, exist_ok=True) + jira_dir = workdir / "jira" + jira_dir.mkdir(parents=True, exist_ok=True) + + output_path = Path(args.output) if args.output else jira_dir / "cves-raw.json" + + try: + raw_issues = search_jql(args.jql) + except JiraConfigError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + cfg = load_config() + issues = [normalize_issue(item, base_url=cfg["base_url"]) for item in raw_issues] + result = { + "jql": args.jql, + "fetched_at": datetime.now(timezone.utc).isoformat(), + "count": len(issues), + "issues": issues, + } + + with open(output_path, "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=2) + + print(f"Fetched {len(issues)} issues", file=sys.stderr) + print(f"Written: {output_path}", file=sys.stderr) + print(json.dumps({"count": len(issues), "output": str(output_path)}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/generate_html_report.py b/plugins/edge-cve/scripts/generate_html_report.py new file mode 100644 index 00000000..ff06fecb --- /dev/null +++ b/plugins/edge-cve/scripts/generate_html_report.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Generate a self-contained HTML report of the CVE investigation. + +Groups tickets by Jira component, then by affected version, showing each +ticket's CVE ID(s) (linked back to the Jira ticket) and govulncheck scan +status - including the actual matched findings (vulnerability ID + module), +not just a pass/fail badge. No LLM involved - verdicts and grouping are +deterministic, reusing the same logic as generate_report.py's markdown report +and analyze_scan_result.py's finding formatting. + +Only components we've actually mapped to a repo in config/component-repos.json +are shown - the Jira "Black CVE" filter spans hundreds of components across +the whole org, most of which aren't edge components we scan/own, so showing +all of them would bury the ones we care about. Everything else is dropped +before rendering (not just hidden), and the dropped count is reported on +stdout so it's clear this is a deliberate, deterministic filter, not missing +data. + +Also renders a separate "Ad-hoc repo checks" section from any check-repo +(single-repo, outside the Jira pipeline) runs found under +/scans/results/*/analysis.json, so a one-off validation of e.g. +openshift/microshift or openshift/lvm-operator shows up in the same report +as the bulk Jira-driven results, as long as it used the same --workdir. + +Tickets flagged private (see lib.cve_extract.is_private_ticket - Jira +Security Level or a "private" label) are rendered with NOTHING but a link +back to the Jira ticket: no CVE ID, summary, or scan findings. They are +grouped under the neutral version "Withheld" so real OCP versions cannot +leak via section headings. + +Usage: + generate_html_report.py --workdir DIR +""" + +from __future__ import annotations + +import argparse +import html +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from analyze_scan_result import finding_label # noqa: E402 +from generate_report import load_json, verdict_for_ticket # noqa: E402 + +VERDICT_LABELS = { + "affected": ("AFFECTED", "affected"), + "not_affected": ("NOT AFFECTED", "not-affected"), + "inconclusive": ("INCONCLUSIVE", "inconclusive"), + "not_scanned": ("NOT SCANNED", "not-scanned"), + "needs_review": ("NEEDS REVIEW", "not-scanned"), +} + + +def version_sort_key(version: str) -> tuple: + if version in ("Unspecified", "Withheld"): + return (1, version) + parts = [] + for part in version.split("."): + try: + parts.append(int(part)) + except ValueError: + parts.append(0) + return (0, tuple(parts)) + + +def scan_status_badge(scan: dict) -> tuple[str, str]: + if scan.get("scan_incomplete"): + return f"INCOMPLETE (exit {scan.get('scan_exit_code')}, likely OOM)", "inconclusive" + if scan.get("affected"): + return "AFFECTED", "affected" + return "NOT AFFECTED", "not-affected" + + +def load_known_components(config_path: Path) -> set[str]: + """Component names we've mapped to a repo (config/component-repos.json).""" + if not config_path.is_file(): + return set() + config = json.loads(config_path.read_text(encoding="utf-8")) + return set(config.get("components", {}).keys()) + + +def build_scan_index(scan_results: dict) -> dict[str, list[dict]]: + index: dict[str, list[dict]] = {} + for result in scan_results.get("results", []): + for ticket_key in result.get("ticket_keys", []): + index.setdefault(ticket_key, []).append(result) + return index + + +def group_tickets( + parsed: dict, + scan_index: dict[str, list[dict]], + known_components: set[str], +) -> tuple[dict[str, dict[str, list[dict]]], int]: + """component -> version -> list of row dicts, sorted for rendering. + + Tickets whose component isn't in `known_components` (config/component-repos.json) + are dropped entirely rather than grouped under an "Unknown"/noise bucket - + this report is scoped to the edge components we actually map to a repo. + Returns the grouped dict plus how many tickets were dropped. + """ + grouped: dict[str, dict[str, list[dict]]] = {} + dropped = 0 + + for ticket in parsed.get("tickets", []): + component = ticket.get("component") or "Unknown" + if known_components and component not in known_components: + dropped += 1 + continue + scans = scan_index.get(ticket["key"], []) + is_private = ticket.get("is_private", False) + # Private tickets must not be bucketed under real OCP versions. + versions = ( + ["Withheld"] + if is_private + else (ticket.get("versions") or ["Unspecified"]) + ) + verdict = "private" if is_private else verdict_for_ticket(ticket, scans) + + row = { + "key": ticket["key"], + "url": ticket.get("url", ""), + "is_private": is_private, + "verdict": verdict, + "cve_ids": [] if is_private else ticket.get("cve_ids", []), + "summary": "" if is_private else ticket.get("summary", ""), + "status": ticket.get("status", ""), + "scans": [] if is_private else scans, + } + + for version in versions: + grouped.setdefault(component, {}).setdefault(version, []).append(row) + + return grouped, dropped + + +def render_findings(findings: list[dict]) -> str: + """List what govulncheck actually matched - not just a pass/fail badge.""" + if not findings: + return "" + items = "".join(f"
  • {html.escape(finding_label(f))}
  • " for f in findings) + return f'
      {items}
    ' + + +def render_scan_detail(scans: list[dict]) -> str: + if not scans: + return "" + items = [] + for scan in scans: + label, css_class = scan_status_badge(scan) + repo = html.escape(scan.get("repo_slug") or scan.get("repo_url", "")) + ref = html.escape(scan.get("git_ref", "")) + findings_html = render_findings(scan.get("matched_findings") or []) + items.append( + f'
  • {repo}@{ref} ' + f'{label}' + f"{findings_html}
  • " + ) + return f'
      {"".join(items)}
    ' + + +def render_row(row: dict) -> str: + key = html.escape(row["key"]) + url = html.escape(row["url"]) + if row["is_private"]: + return ( + '' + f'🔒 Private ticket - details withheld. ' + f'{key}' + "" + ) + + label, css_class = VERDICT_LABELS.get(row["verdict"], (row["verdict"].upper(), "not-scanned")) + cve_links = ", ".join( + f'{html.escape(cve)}' + for cve in row["cve_ids"] + ) or f'{key}' + summary = html.escape(row["summary"]) + scan_detail = render_scan_detail(row["scans"]) + + return ( + "" + f'{cve_links}
    {key} · {html.escape(row["status"])}
    ' + f'{summary}' + f'{label}' + f'{scan_detail or "—"}' + "" + ) + + +def unique_rows_by_key(versions: dict[str, list[dict]]) -> list[dict]: + """Deduplicate rows that appear under multiple version buckets. + + Tickets with several OCP versions are listed once per version section, but + component/global totals must count each ticket key only once. + """ + seen: dict[str, dict] = {} + for rows in versions.values(): + for row in rows: + key = row.get("key") + if key is None or key in seen: + continue + seen[key] = row + return list(seen.values()) + + +def render_component(component: str, versions: dict[str, list[dict]], *, open_by_default: bool) -> str: + version_blocks = [] + for version in sorted(versions.keys(), key=version_sort_key): + rows = versions[version] + rows_html = "".join(render_row(row) for row in sorted(rows, key=lambda r: r["key"])) + version_blocks.append( + f'

    {html.escape(version)}

    ' + '' + "" + f"{rows_html}
    CVE / TicketSummaryVerdictgovulncheck
    " + ) + + unique_rows = unique_rows_by_key(versions) + total = len(unique_rows) + affected = sum(1 for row in unique_rows if row["verdict"] == "affected") + badge = f'{affected} affected' if affected else "" + open_attr = " open" if open_by_default else "" + + return ( + f"" + f'{html.escape(component)} ' + f'({total} ticket{"s" if total != 1 else ""}) {badge}' + f'
    {"".join(version_blocks)}
    ' + "" + ) + + +def render_summary(grouped: dict[str, dict[str, list[dict]]]) -> tuple[str, dict[str, int]]: + counts = {"total": 0, "private": 0, "affected": 0, "not_affected": 0, "inconclusive": 0, "other": 0} + seen_keys: set[str] = set() + for versions in grouped.values(): + for row in unique_rows_by_key(versions): + key = row["key"] + if key in seen_keys: + continue + seen_keys.add(key) + counts["total"] += 1 + verdict = row["verdict"] + if verdict == "private": + counts["private"] += 1 + elif verdict in ("affected", "not_affected", "inconclusive"): + counts[verdict] += 1 + else: + counts["other"] += 1 + + cards = [ + ("Total tickets", counts["total"], "total"), + ("Affected", counts["affected"], "affected"), + ("Not affected", counts["not_affected"], "not-affected"), + ("Inconclusive", counts["inconclusive"], "inconclusive"), + ("Private (redacted)", counts["private"], "private"), + ] + cards_html = "".join( + f'
    {value}
    ' + f'
    {label}
    ' + for label, value, cls in cards + ) + return cards_html, counts + + +def load_check_repo_results(workdir: Path) -> list[dict]: + """Ad-hoc `check-repo` results (analyze_scan_result.py's analysis.json). + + Kept separate from the Jira-driven ticket tables above since a check-repo + run may have no corresponding Jira ticket at all (a pure "is this ref + affected by anything right now" check) - only result.json/analysis.json + exist for these, never a jira/cves-parsed.json entry. scan-local's plain + result.json files (no analysis.json) are intentionally NOT picked up here + to avoid double-reporting bulk-pipeline scans that are already covered by + the ticket tables above. + """ + results_dir = workdir / "scans" / "results" + if not results_dir.is_dir(): + return [] + entries = [] + for analysis_path in sorted(results_dir.glob("*/analysis.json")): + try: + entries.append(json.loads(analysis_path.read_text(encoding="utf-8"))) + except (json.JSONDecodeError, OSError): + continue + return entries + + +def render_check_repo_row(entry: dict) -> str: + verdict = entry.get("verdict", "unknown") + label, css_class = VERDICT_LABELS.get(verdict, (verdict.upper(), "not-scanned")) + repo = html.escape(entry.get("repo_slug") or entry.get("repo_url", "")) + ref = html.escape(entry.get("git_ref", "")) + cve_ids = entry.get("cve_ids") or [] + cve_text = html.escape(", ".join(cve_ids)) if cve_ids else "(any known vulnerability)" + jira_url = entry.get("jira_url", "") + ticket_keys = entry.get("ticket_keys") or [] + ticket_label = html.escape(", ".join(ticket_keys)) if ticket_keys else "Jira ticket" + if jira_url: + ticket_html = f'{ticket_label}' + elif ticket_keys: + ticket_html = ticket_label + else: + ticket_html = "—" + findings_html = render_findings(entry.get("matched_findings") or []) + + return ( + "" + f'{repo}@{ref}' + f"{cve_text}" + f'{label}' + f'{findings_html or "—"}' + f"{ticket_html}" + "" + ) + + +def render_check_repo_section(entries: list[dict]) -> str: + if not entries: + return "" + rows_html = "".join(render_check_repo_row(entry) for entry in entries) + return ( + "

    Ad-hoc repo checks

    " + f'

    {len(entries)} one-off check-repo run(s), outside the Jira-driven pipeline above.

    ' + "" + "" + f"{rows_html}
    Repo @ refCVE(s) checkedVerdictFindingsTicket
    " + ) + + +PAGE_TEMPLATE = """ + + + +Edge CVE Investigation Report + + + +

    Edge CVE Investigation Report

    +
    Generated {generated_at} · Jira scope: {jql}
    +
    Components: {components_list} · {dropped_note}
    +
    {stats_html}
    + +
    {components_html}
    +
    {check_repo_html}
    + + + +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate an HTML CVE investigation report") + parser.add_argument("--workdir", required=True) + parser.add_argument( + "--config", + default="", + help="Component mapping JSON (default: plugin config/component-repos.json)", + ) + args = parser.parse_args() + + # Every asset this pipeline produces lives under --workdir (same convention + # as fetch/parse/group/scan/finalize) - no --output override, so the HTML + # report can never end up somewhere other than alongside the rest of the + # run's data. + workdir = Path(args.workdir) + parsed_path = workdir / "jira" / "cves-parsed.json" + scan_path = workdir / "scans" / "govulncheck-results.json" + output_path = workdir / "report-cve-investigation.html" + config_path = ( + Path(args.config) if args.config else SCRIPT_DIR.parent / "config" / "component-repos.json" + ) + + if not parsed_path.is_file(): + print(f"Error: required input missing: {parsed_path}", file=sys.stderr) + sys.exit(1) + + parsed = load_json(parsed_path) + scans = load_json(scan_path) if scan_path.is_file() else {"results": []} + scan_index = build_scan_index(scans) + known_components = load_known_components(config_path) + if not known_components: + print( + f"Error: no components found in {config_path}; " + "refusing to render all Jira components (edge-only scope requires a mapping)", + file=sys.stderr, + ) + sys.exit(1) + + grouped, dropped = group_tickets(parsed, scan_index, known_components) + stats_html, counts = render_summary(grouped) + + components_html = "".join( + render_component( + component, + versions, + open_by_default=any( + row["verdict"] == "affected" for rows in versions.values() for row in rows + ), + ) + for component, versions in sorted(grouped.items()) + ) + + check_repo_results = load_check_repo_results(workdir) + check_repo_html = render_check_repo_section(check_repo_results) + + jql = 'filter = "All Open CVEs" AND filter = "All Open Black CVEs"' + components_list = ", ".join(sorted(known_components)) + dropped_note = ( + f"{dropped} ticket(s) for other components dropped" if dropped else "no other-component tickets found" + ) + page = PAGE_TEMPLATE.format( + generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + jql=html.escape(jql), + components_list=html.escape(components_list), + dropped_note=html.escape(dropped_note), + stats_html=stats_html, + components_html=components_html or "

    No tickets found for the configured components.

    ", + check_repo_html=check_repo_html, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(page, encoding="utf-8") + + counts["check_repo_checks"] = len(check_repo_results) + counts["dropped_unmapped_components"] = dropped + summary = {"output": str(output_path), "known_components": sorted(known_components), **counts} + print(f"Written: {output_path}", file=sys.stderr) + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/generate_report.py b/plugins/edge-cve/scripts/generate_report.py new file mode 100644 index 00000000..891bc1b0 --- /dev/null +++ b/plugins/edge-cve/scripts/generate_report.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Generate a deterministic CVE investigation report from grouped tickets and scans. + +Produces markdown suitable for team notification. Actionable remediation prompts +are written separately for LLM follow-up. + +Usage: + generate_report.py --workdir DIR +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def load_json(path: Path) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def build_ticket_index(parsed: dict) -> dict[str, dict]: + return {ticket["key"]: ticket for ticket in parsed.get("tickets", [])} + + +def scan_by_ticket(scan_results: dict) -> dict[str, list[dict]]: + index: dict[str, list[dict]] = {} + for result in scan_results.get("results", []): + for ticket_key in result.get("ticket_keys", []): + index.setdefault(ticket_key, []).append(result) + # Also attach via target metadata from grouped tickets if ticket_keys missing. + return index + + +def verdict_for_ticket(ticket: dict, scans: list[dict]) -> str: + if not ticket.get("cve_ids"): + return "needs_review" + if not scans: + return "not_scanned" + if any(scan.get("affected") for scan in scans): + return "affected" + # A scan that was OOM-killed/signal-terminated (scan_incomplete) never + # finished, so "no matches" there does NOT mean "not affected" - treat + # it as inconclusive rather than falsely clearing the ticket. + if any(scan.get("scan_incomplete") for scan in scans): + return "inconclusive" + if all(not scan.get("affected") for scan in scans): + return "not_affected" + return "inconclusive" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate CVE investigation report") + parser.add_argument("--workdir", required=True) + args = parser.parse_args() + + workdir = Path(args.workdir) + grouped_path = workdir / "jira" / "cves-grouped.json" + parsed_path = workdir / "jira" / "cves-parsed.json" + scan_path = workdir / "scans" / "govulncheck-results.json" + + for path in (grouped_path, parsed_path): + if not path.is_file(): + print(f"Error: required input missing: {path}", file=sys.stderr) + sys.exit(1) + + grouped = load_json(grouped_path) + parsed = load_json(parsed_path) + scans = load_json(scan_path) if scan_path.is_file() else {"results": []} + + ticket_index = build_ticket_index(parsed) + scan_index: dict[str, list[dict]] = {} + for result in scans.get("results", []): + for key in result.get("ticket_keys", []): + scan_index.setdefault(key, []).append(result) + + lines = [ + "# Edge CVE Investigation Report", + "", + f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", + f"**Jira groups:** {grouped.get('group_count', 0)}", + f"**Tickets:** {parsed.get('count', 0)}", + "", + "## Summary by component", + "", + "| Component | Tickets | Affected | Not Affected | Needs Review |", + "|-----------|---------|----------|--------------|--------------|", + ] + + component_stats: dict[str, dict[str, int]] = {} + ticket_rows = [] + + for ticket in parsed.get("tickets", []): + key = ticket["key"] + comp = ticket.get("component", "Unknown") + ticket_scans = scan_index.get(key, []) + verdict = verdict_for_ticket(ticket, ticket_scans) + stats = component_stats.setdefault( + comp, + { + "tickets": 0, + "affected": 0, + "not_affected": 0, + "needs_review": 0, + "not_scanned": 0, + }, + ) + stats["tickets"] += 1 + stats[verdict] = stats.get(verdict, 0) + 1 + ticket_rows.append((comp, ticket, verdict, ticket_scans)) + + for comp, stats in sorted(component_stats.items()): + lines.append( + f"| {comp} | {stats['tickets']} | {stats.get('affected', 0)} | " + f"{stats.get('not_affected', 0)} | " + f"{stats.get('needs_review', 0) + stats.get('not_scanned', 0) + stats.get('inconclusive', 0)} |" + ) + + lines.extend(["", "## Ticket details", ""]) + for comp, ticket, verdict, ticket_scans in sorted( + ticket_rows, key=lambda row: (row[0], row[1]["key"]) + ): + cves = ", ".join(ticket.get("cve_ids", [])) or "(none)" + versions = ", ".join(ticket.get("versions", [])) or "(unknown)" + repos = ", ".join(r["slug"] for r in ticket.get("repos", [])) or "(none)" + lines.append(f"### {ticket['key']} — {verdict}") + lines.append("") + lines.append(f"- **Summary:** {ticket.get('summary', '')}") + lines.append(f"- **Component:** {comp}") + lines.append(f"- **Versions:** {versions}") + lines.append(f"- **CVEs:** {cves}") + lines.append(f"- **Repos:** {repos}") + lines.append(f"- **Jira:** {ticket.get('url', '')}") + if ticket.get("parse_warnings"): + lines.append(f"- **Warnings:** {', '.join(ticket['parse_warnings'])}") + if ticket_scans: + lines.append("- **govulncheck:**") + for scan in ticket_scans: + if scan.get("scan_incomplete"): + status = f"INCOMPLETE (killed, exit {scan.get('scan_exit_code')} - likely OOM, re-run with more memory)" + elif scan.get("affected"): + status = "AFFECTED" + else: + status = "NOT AFFECTED" + lines.append( + f" - {scan.get('repo_url', '')} @ {scan.get('git_ref', '')} " + f"({scan.get('commit', '')[:12]}) → {status}" + ) + lines.append("") + + report_path = workdir / "report-cve-investigation.md" + report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + actionable = [ + row + for row in ticket_rows + if row[2] == "affected" + or (row[2] == "needs_review" and row[1].get("repos")) + ] + prompt_lines = [ + "# CVE Remediation Agent Prompts", + "", + "Use these prompts only for tickets marked affected or needing review.", + "", + ] + for _, ticket, verdict, ticket_scans in actionable: + prompt_lines.extend( + [ + f"## {ticket['key']} ({verdict})", + "", + "You are fixing a CVE in an OpenShift edge component repository.", + f"Jira: {ticket.get('url', '')}", + f"Summary: {ticket.get('summary', '')}", + f"CVEs: {', '.join(ticket.get('cve_ids', []))}", + f"Component: {ticket.get('component', '')}", + f"Target versions: {', '.join(ticket.get('versions', []))}", + f"Repositories: {', '.join(r['slug'] for r in ticket.get('repos', []))}", + "", + "Steps:", + "1. Clone the repository and checkout the target release branch.", + "2. Confirm the vulnerable module/path from govulncheck findings.", + "3. Bump the dependency or apply the upstream fix.", + "4. Run `go test ./...` and `govulncheck ./...` to verify.", + "5. Open a PR referencing the Jira ticket.", + "", + ] + ) + if ticket_scans: + prompt_lines.append("govulncheck evidence:") + for scan in ticket_scans: + if scan.get("matched_findings"): + prompt_lines.append( + f"- {scan.get('repo_url')}@{scan.get('git_ref')}: " + f"{len(scan['matched_findings'])} matched finding(s)" + ) + prompt_lines.append("") + + prompts_path = workdir / "remediation-prompts.md" + prompts_path.write_text("\n".join(prompt_lines) + "\n", encoding="utf-8") + + summary = { + "report": str(report_path), + "prompts": str(prompts_path), + "affected_tickets": sum(1 for _, _, v, _ in ticket_rows if v == "affected"), + "actionable_prompts": len(actionable), + } + with open(workdir / "report-summary.json", "w", encoding="utf-8") as fh: + json.dump(summary, fh, indent=2) + + print(f"Written: {report_path}", file=sys.stderr) + print(f"Written: {prompts_path}", file=sys.stderr) + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/group_cves.py b/plugins/edge-cve/scripts/group_cves.py new file mode 100644 index 00000000..a31abfbe --- /dev/null +++ b/plugins/edge-cve/scripts/group_cves.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Group parsed CVE tickets by component and CVE identity. + +Deterministic grouping keys: + - primary CVE ID (first CVE on ticket) + - primary component + - normalized summary stem (package/module token when present) + +Tickets that share a group but differ only by OCP version are clustered +together. Ambiguous groups are flagged for optional LLM review. + +Usage: + group_cves.py --workdir DIR [--input FILE] +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +PACKAGE_TOKEN_RE = re.compile(r"\b(?:golang|go|openssl|glibc|kernel|etcd|cri-o|podman)\b", re.I) +MODULE_PATH_RE = re.compile(r"\b[\w./-]+/[\w./-]+\b") + + +def summary_stem(summary: str, go_modules: list[str]) -> str: + """Derive a deterministic grouping stem from summary and modules.""" + if go_modules: + return go_modules[0].lower() + lowered = summary.lower() + pkg = PACKAGE_TOKEN_RE.search(lowered) + if pkg: + return pkg.group(0).lower() + # Fall back to first significant word chunk before version suffix. + stem = re.sub(r"\[.*?\]", "", summary) + stem = re.sub(r"\bCVE-\d{4}-\d+\b", "", stem, flags=re.I) + stem = re.sub(r"\b4\.\d{1,2}\b", "", stem) + stem = re.sub(r"[^a-z0-9]+", "-", stem.lower()).strip("-") + return stem[:80] or "unknown" + + +def group_key(ticket: dict) -> tuple: + cve = ticket["cve_ids"][0] if ticket.get("cve_ids") else "UNKNOWN-CVE" + component = ticket.get("component", "Unknown") + stem = summary_stem(ticket.get("summary", ""), ticket.get("go_modules", [])) + return (cve, component, stem) + + +def build_group(key: tuple, tickets: list[dict]) -> dict: + cve, component, stem = key + versions = sorted({v for t in tickets for v in t.get("versions", [])}) + ticket_keys = sorted(t["key"] for t in tickets) + repos = sorted({r["slug"] for t in tickets for r in t.get("repos", [])}) + + needs_llm_review = False + reasons: list[str] = [] + + if cve == "UNKNOWN-CVE": + needs_llm_review = True + reasons.append("missing_cve_id") + if len({t.get("summary", "") for t in tickets}) > 1 and len(versions) > 1: + # Same CVE/component but materially different summaries across versions. + summaries = {t.get("summary", "") for t in tickets} + if len(summaries) > 1: + needs_llm_review = True + reasons.append("divergent_summaries_across_versions") + if not repos: + needs_llm_review = True + reasons.append("no_repo_for_group") + + return { + "group_id": f"{cve}::{component}::{stem}", + "cve_id": cve, + "component": component, + "summary_stem": stem, + "ticket_count": len(tickets), + "ticket_keys": ticket_keys, + "versions": versions, + "repos": repos, + "tickets": tickets, + "needs_llm_review": needs_llm_review, + "llm_review_reasons": reasons, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Group parsed CVE tickets") + parser.add_argument("--workdir", required=True) + parser.add_argument("--input", default="") + parser.add_argument("--output", default="") + parser.add_argument("--llm-review-output", default="") + args = parser.parse_args() + + workdir = Path(args.workdir) + input_path = Path(args.input) if args.input else workdir / "jira" / "cves-parsed.json" + output_path = Path(args.output) if args.output else workdir / "jira" / "cves-grouped.json" + llm_path = ( + Path(args.llm_review_output) + if args.llm_review_output + else workdir / "jira" / "cves-llm-review.json" + ) + + if not input_path.is_file(): + print(f"Error: input not found: {input_path}", file=sys.stderr) + sys.exit(1) + + with open(input_path, encoding="utf-8") as fh: + data = json.load(fh) + + buckets: dict[tuple, list[dict]] = defaultdict(list) + for ticket in data.get("tickets", []): + buckets[group_key(ticket)].append(ticket) + + groups = [build_group(key, tickets) for key, tickets in sorted(buckets.items())] + groups.sort(key=lambda g: (g["component"], g["cve_id"], g["summary_stem"])) + + llm_review = [g for g in groups if g["needs_llm_review"]] + + result = { + "grouped_at": datetime.now(timezone.utc).isoformat(), + "source": str(input_path), + "group_count": len(groups), + "ticket_count": data.get("count", 0), + "llm_review_count": len(llm_review), + "groups": groups, + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=2) + + llm_payload = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "instruction": ( + "Review these CVE groups and confirm whether tickets represent the same " + "underlying vulnerability across versions. Merge or split groups if needed " + "before launching govulncheck scans." + ), + "groups": llm_review, + } + with open(llm_path, "w", encoding="utf-8") as fh: + json.dump(llm_payload, fh, indent=2) + + print( + f"Grouped {result['ticket_count']} tickets into {len(groups)} groups " + f"({len(llm_review)} need LLM review)", + file=sys.stderr, + ) + print(f"Written: {output_path}", file=sys.stderr) + print(f"Written: {llm_path}", file=sys.stderr) + print( + json.dumps( + { + "group_count": len(groups), + "llm_review_count": len(llm_review), + "output": str(output_path), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/lib/__init__.py b/plugins/edge-cve/scripts/lib/__init__.py new file mode 100644 index 00000000..32cb31b4 --- /dev/null +++ b/plugins/edge-cve/scripts/lib/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for edge-cve scripts.""" diff --git a/plugins/edge-cve/scripts/lib/cve_extract.py b/plugins/edge-cve/scripts/lib/cve_extract.py new file mode 100644 index 00000000..5731cbc9 --- /dev/null +++ b/plugins/edge-cve/scripts/lib/cve_extract.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""CVE and repository extraction helpers.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) +# OCP minor only (4.17, 5.2, also "4.18.z" Jira names). Reject longer numeric +# semver like "4.1.4" (upstream library versions) so a patch level is never +# mistaken for an OpenShift release - "(?!\.\d)" still allows the trailing +# ".z" used in Jira Affected Version fields. +OCP_VERSION_RE = re.compile(r"(? list[str]: + """Return unique CVE IDs found across texts, preserving first-seen order.""" + seen: set[str] = set() + ordered: list[str] = [] + for text in texts: + if not text: + continue + for match in CVE_RE.finditer(text): + cve = match.group(0).upper() + if cve not in seen: + seen.add(cve) + ordered.append(cve) + return ordered + + +def extract_ocp_versions(*texts: str) -> list[str]: + """Extract OCP minor versions like 4.17 from text fields.""" + seen: set[str] = set() + ordered: list[str] = [] + for text in texts: + if not text: + continue + for match in OCP_VERSION_RE.finditer(text): + version = match.group(1) + if version not in seen: + seen.add(version) + ordered.append(version) + return ordered + + +def load_component_config(config_path: Path) -> dict[str, Any]: + with open(config_path, encoding="utf-8") as fh: + return json.load(fh) + + +def extract_repo_urls(text: str, patterns: list[str]) -> list[dict[str, str]]: + """Find repository references in free text.""" + found: list[dict[str, str]] = [] + seen: set[str] = set() + for pattern in patterns: + regex = re.compile(pattern) + for match in regex.finditer(text or ""): + org = match.group("org") + repo = match.group("repo").split("/")[0] + if repo.endswith(".git"): + repo = repo[:-4] + slug = f"{org}/{repo}" + if slug in seen: + continue + seen.add(slug) + found.append( + { + "org": org, + "repo": repo, + "slug": slug, + "url": f"https://github.com/{slug}.git", + } + ) + return found + + +def resolve_component_repo( + component: str, + config: dict[str, Any], +) -> dict[str, Any] | None: + """Look up default repo metadata for a Jira component name.""" + components = config.get("components", {}) + entry = components.get(component) + if not entry: + return None + repo = entry["repo"] + if "/" in repo: + org, name = repo.split("/", 1) + else: + org = config.get("defaults", {}).get("org", "openshift") + name = repo + return { + "org": org, + "repo": name, + "slug": f"{org}/{name}", + "url": f"https://github.com/{org}/{name}.git", + "language": entry.get("language", "go"), + "version_ref_template": entry.get("version_ref_template", "release-{version}"), + "version_ref_fallbacks": entry.get("version_ref_fallbacks", []), + "source": "component-map", + } + + +def resolve_git_refs( + versions: list[str], + repo_meta: dict[str, Any], +) -> list[str]: + """Build candidate git refs from OCP versions and repo metadata. + + Prefer the ticket's own release versions (e.g. release-4.18 from a + version_ref_template of "release-{version}"). Do NOT invent a default + like "main"/"master" here - those tip-of-tree branches capture far more + than the ticket is asking about. version_ref_fallbacks is reserved for + rare cases (e.g. a component whose only branch is unversioned) and is + empty by default for versioned edge components. + """ + refs: list[str] = [] + template = repo_meta.get("version_ref_template", "") + fallbacks = repo_meta.get("version_ref_fallbacks", []) + + if versions: + for version in versions: + if template: + refs.append(template.format(version=version)) + elif fallbacks: + # Only fall back when the ticket itself has no version at all. + # Never append fallbacks on top of version-derived refs. + refs.extend(fallbacks) + elif template and "{version}" not in template: + # Unversioned component whose template is a fixed branch (e.g. "main" + # for two-node-toolbox) - use that single branch as-is. + refs.append(template) + + # Preserve order, drop duplicates. + seen: set[str] = set() + unique: list[str] = [] + for ref in refs: + if ref and ref not in seen: + seen.add(ref) + unique.append(ref) + return unique + + +def extract_go_modules(text: str) -> list[str]: + """Extract Go module paths mentioned in ticket text.""" + modules: list[str] = [] + seen: set[str] = set() + for match in GO_MODULE_RE.finditer(text or ""): + module = match.group(1) + if module not in seen: + seen.add(module) + modules.append(module) + return modules + + +def primary_component(components: list[str]) -> str: + return components[0] if components else "Unknown" + + +def is_private_ticket(issue: dict[str, Any]) -> bool: + """Deterministically decide whether a ticket is labeled private. + + Checks the Jira "Security Level" field and the ticket's labels for + anything containing "private" (case-insensitive) - covers Jira instances + that mark restricted CVE tickets either way. Callers must not render CVE + IDs, summaries, or scan findings for tickets this flags, only a link back + to the Jira ticket. + """ + security_level = str(issue.get("security_level", "")).strip().lower() + if "private" in security_level: + return True + for label in issue.get("labels", []) or []: + if "private" in str(label).strip().lower(): + return True + return False + + +def ticket_versions(issue: dict[str, Any]) -> list[str]: + """Resolve affected OCP versions from structured Jira fields and summary. + + Prefer Jira's Affected Version / Fix Version fields, then tokens in the + summary (e.g. "[openshift-4.23]"). The description is intentionally NOT + scanned - CVE writeups routinely mention upstream library versions like + "Prior to 4.1.4" that are not OpenShift releases. + """ + versions = list(issue.get("affected_versions", [])) + versions.extend(issue.get("fix_versions", [])) + extracted = extract_ocp_versions(issue.get("summary", "")) + + # Normalize Jira version names like "4.17.z" -> "4.17" + normalized: list[str] = [] + seen: set[str] = set() + for raw in versions + extracted: + m = OCP_VERSION_RE.search(str(raw)) + if not m: + continue + val = m.group(1) + if val not in seen: + seen.add(val) + normalized.append(val) + return normalized diff --git a/plugins/edge-cve/scripts/lib/jira_client.py b/plugins/edge-cve/scripts/lib/jira_client.py new file mode 100644 index 00000000..26a4c649 --- /dev/null +++ b/plugins/edge-cve/scripts/lib/jira_client.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Jira REST API client for edge-cve scripts.""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +import requests + +DEFAULT_BASE_URL = "https://redhat.atlassian.net" +DEFAULT_JQL = 'filter = "All Open CVEs" AND filter = "All Open Black CVEs"' + +DEFAULT_FIELDS = [ + "summary", + "status", + "components", + "versions", + "fixVersions", + "labels", + "description", + "issuetype", + "priority", + "assignee", + "created", + "updated", + "security", +] + + +class JiraConfigError(RuntimeError): + """Raised when required Jira environment variables are missing.""" + + +def load_config() -> dict[str, str]: + """Load Jira credentials from environment.""" + base_url = os.environ.get("JIRA_BASE_URL", DEFAULT_BASE_URL).rstrip("/") + email = os.environ.get("JIRA_EMAIL") or os.environ.get("JIRA_USERNAME", "") + token = os.environ.get("JIRA_API_TOKEN", "") + + missing = [] + if not email: + missing.append("JIRA_EMAIL or JIRA_USERNAME") + if not token: + missing.append("JIRA_API_TOKEN") + + if missing: + raise JiraConfigError( + "Missing required environment variables: " + + ", ".join(missing) + + "\nSet JIRA_BASE_URL, JIRA_EMAIL (or JIRA_USERNAME), and JIRA_API_TOKEN." + ) + + return {"base_url": base_url, "email": email, "token": token} + + +# Hard cap on /search/jql pages so a stuck/repeating nextPageToken cannot loop +# forever. 1000 pages * 100 results = 100k issues, well above Black CVE volume. +MAX_SEARCH_PAGES = 1000 + + +def search_jql( + jql: str, + *, + fields: list[str] | None = None, + max_results: int = 100, + session: requests.Session | None = None, +) -> list[dict[str, Any]]: + """Execute a JQL query with pagination via /rest/api/3/search/jql.""" + cfg = load_config() + fields = fields or DEFAULT_FIELDS + sess = session or requests.Session() + sess.auth = (cfg["email"], cfg["token"]) + sess.headers.update({"Accept": "application/json", "Content-Type": "application/json"}) + + url = f"{cfg['base_url']}/rest/api/3/search/jql" + issues: list[dict[str, Any]] = [] + next_page_token: str | None = None + seen_tokens: set[str] = set() + + for page in range(1, MAX_SEARCH_PAGES + 1): + payload: dict[str, Any] = { + "jql": jql, + "maxResults": max_results, + "fields": fields, + } + if next_page_token: + payload["nextPageToken"] = next_page_token + + try: + resp = sess.post(url, json=payload, timeout=120) + resp.raise_for_status() + except requests.RequestException as exc: + raise RuntimeError(f"Jira search failed: {exc}") from exc + + data = resp.json() + batch = data.get("issues", []) + issues.extend(batch) + + # Only trust an explicit final-page signal. Do not default missing + # isLast to True - that silently truncates when Jira omits the field. + is_last = data.get("isLast") + token = data.get("nextPageToken") + + if is_last is True: + return issues + + if token: + if token == next_page_token or token in seen_tokens: + raise RuntimeError( + f"Jira search pagination failed to advance " + f"(repeated nextPageToken on page {page})" + ) + seen_tokens.add(token) + next_page_token = token + continue + + if is_last is False: + raise RuntimeError( + f"Jira search page {page} has isLast=false but no nextPageToken" + ) + if is_last is None and len(batch) >= max_results: + raise RuntimeError( + f"Jira search page {page} missing isLast/nextPageToken on a " + f"full page of {len(batch)} issues; refusing to truncate" + ) + # Short/empty page with no token and no explicit isLast - treat as done. + return issues + + raise RuntimeError( + f"Jira search exceeded {MAX_SEARCH_PAGES} pages without isLast=true; " + f"refusing to continue" + ) + + +def normalize_issue(raw: dict[str, Any], *, base_url: str | None = None) -> dict[str, Any]: + """Flatten a Jira API issue into a stable dict for downstream scripts.""" + if base_url is None: + base_url = load_config()["base_url"] + fields = raw.get("fields", {}) + + def names(items: list | None) -> list[str]: + if not items: + return [] + out = [] + for item in items: + if isinstance(item, dict): + out.append(item.get("name", "")) + else: + out.append(str(item)) + return [n for n in out if n] + + status = fields.get("status", {}) + priority = fields.get("priority", {}) + assignee = fields.get("assignee") or {} + issue_type = fields.get("issuetype", {}) + security = fields.get("security") or {} + + description = fields.get("description") + if isinstance(description, dict): + description = _adf_to_text(description) + elif description is None: + description = "" + + return { + "key": raw.get("key", ""), + "summary": fields.get("summary", ""), + "status": status.get("name", ""), + "priority": priority.get("name", ""), + "issue_type": issue_type.get("name", ""), + "components": names(fields.get("components")), + "affected_versions": names(fields.get("versions")), + "fix_versions": names(fields.get("fixVersions")), + "labels": fields.get("labels", []) or [], + "security_level": security.get("name", ""), + "description": description, + "assignee": assignee.get("displayName", "Unassigned"), + "created": (fields.get("created") or "")[:10], + "updated": (fields.get("updated") or "")[:10], + "url": f"{base_url}/browse/{raw.get('key', '')}", + } + + +def _adf_to_text(node: dict[str, Any]) -> str: + """Convert Atlassian Document Format to plain text (best effort).""" + parts: list[str] = [] + + def walk(item: Any) -> None: + if isinstance(item, dict): + if item.get("type") == "text": + parts.append(item.get("text", "")) + for child in item.get("content", []): + walk(child) + elif isinstance(item, list): + for child in item: + walk(child) + + walk(node) + return "\n".join("".join(parts).splitlines()) + + +def die_config_error() -> None: + """Print configuration help and exit.""" + try: + load_config() + except JiraConfigError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/plugins/edge-cve/scripts/parse_cves.py b/plugins/edge-cve/scripts/parse_cves.py new file mode 100644 index 00000000..6b75cff6 --- /dev/null +++ b/plugins/edge-cve/scripts/parse_cves.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Parse raw Jira CVE tickets into scan-ready records. + +Categorizes tickets by component and version, extracts CVE IDs, and resolves +repository targets from ticket text or component mapping. + +Usage: + parse_cves.py --workdir DIR [--input FILE] [--config FILE] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from lib.cve_extract import ( # noqa: E402 + extract_cve_ids, + extract_go_modules, + extract_repo_urls, + is_private_ticket, + load_component_config, + primary_component, + resolve_component_repo, + resolve_git_refs, + ticket_versions, +) + + +def parse_issue(issue: dict, config: dict) -> dict: + cve_ids = extract_cve_ids(issue.get("summary", ""), issue.get("description", "")) + component = primary_component(issue.get("components", [])) + versions = ticket_versions(issue) + + patterns = config.get("repo_url_patterns", []) + text = f"{issue.get('summary', '')}\n{issue.get('description', '')}" + repos = extract_repo_urls(text, patterns) + + component_repo = resolve_component_repo(component, config) + if component_repo and not repos: + repos = [component_repo] + elif component_repo: + slugs = {r["slug"] for r in repos} + if component_repo["slug"] not in slugs: + repos.append(component_repo) + + scan_targets = [] + for repo in repos: + refs = resolve_git_refs(versions, repo) + if not refs: + # Ticket has a repo but no resolvable release version - do NOT + # invent a tip-of-tree branch (main/master); that would scan far + # more than the ticket is asking about. Surface via parse_warnings + # instead so the ticket still shows up as needing a version. + continue + scan_targets.append( + { + "repo": repo, + "git_refs": refs, + } + ) + + return { + "key": issue["key"], + "url": issue.get("url", ""), + "summary": issue.get("summary", ""), + "status": issue.get("status", ""), + "priority": issue.get("priority", ""), + "issue_type": issue.get("issue_type", ""), + "component": component, + "components": issue.get("components", []), + "versions": versions, + "cve_ids": cve_ids, + "go_modules": extract_go_modules(text), + "repos": repos, + "scan_targets": scan_targets, + "labels": issue.get("labels", []), + "security_level": issue.get("security_level", ""), + "is_private": is_private_ticket(issue), + "assignee": issue.get("assignee", ""), + "updated": issue.get("updated", ""), + "parse_warnings": _warnings(issue, cve_ids, repos, versions, scan_targets), + } + + +def _warnings(issue, cve_ids, repos, versions, scan_targets) -> list[str]: + warnings = [] + if not cve_ids: + warnings.append("no_cve_id_found") + if not repos: + warnings.append("no_repo_resolved") + if not versions: + warnings.append("no_version_resolved") + if repos and not scan_targets: + # Repo known but no release-branch ref could be derived - we refuse + # to invent main/master, so this ticket can't be scanned as-is. + warnings.append("no_git_ref_resolved") + if not issue.get("components"): + warnings.append("no_component") + return warnings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Parse Jira CVE tickets") + parser.add_argument("--workdir", required=True) + parser.add_argument( + "--input", + default="", + help="Input JSON (default: /jira/cves-raw.json)", + ) + parser.add_argument( + "--config", + default="", + help="Component mapping JSON (default: plugin config/component-repos.json)", + ) + parser.add_argument( + "--output", + default="", + help="Output JSON (default: /jira/cves-parsed.json)", + ) + args = parser.parse_args() + + workdir = Path(args.workdir) + plugin_dir = SCRIPT_DIR.parent + input_path = Path(args.input) if args.input else workdir / "jira" / "cves-raw.json" + config_path = ( + Path(args.config) + if args.config + else plugin_dir / "config" / "component-repos.json" + ) + output_path = Path(args.output) if args.output else workdir / "jira" / "cves-parsed.json" + + if not input_path.is_file(): + print(f"Error: input not found: {input_path}", file=sys.stderr) + sys.exit(1) + if not config_path.is_file(): + print(f"Error: config not found: {config_path}", file=sys.stderr) + sys.exit(1) + + with open(input_path, encoding="utf-8") as fh: + raw = json.load(fh) + + config = load_component_config(config_path) + parsed = [parse_issue(issue, config) for issue in raw.get("issues", [])] + + by_component: dict[str, int] = {} + for item in parsed: + comp = item["component"] + by_component[comp] = by_component.get(comp, 0) + 1 + + result = { + "parsed_at": datetime.now(timezone.utc).isoformat(), + "source": str(input_path), + "count": len(parsed), + "by_component": dict(sorted(by_component.items())), + "tickets": parsed, + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=2) + + warnings = sum(1 for t in parsed if t["parse_warnings"]) + print(f"Parsed {len(parsed)} tickets ({warnings} with warnings)", file=sys.stderr) + print(f"Written: {output_path}", file=sys.stderr) + print( + json.dumps( + { + "count": len(parsed), + "by_component": result["by_component"], + "output": str(output_path), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/process_govulncheck_result.go b/plugins/edge-cve/scripts/process_govulncheck_result.go new file mode 100644 index 00000000..9c07ff8f --- /dev/null +++ b/plugins/edge-cve/scripts/process_govulncheck_result.go @@ -0,0 +1,469 @@ +// Process govulncheck JSON output and publish the result for edge-cve +// collection. Run inside the scan container (OpenShift Job or local podman) +// after govulncheck completes. +// +// Two output modes, selected by the RESULT_DIR env var: +// - RESULT_DIR set (local/podman mode): write result.json (curated) and a +// full, uncapped copy of the raw govulncheck.json under +// RESULT_DIR// - local disk isn't size-constrained. +// - RESULT_DIR unset (OpenShift Job mode): publish only the curated +// result.json as a labeled ConfigMap via the in-cluster Kubernetes API +// (raw REST calls, since the job image only ships the Go toolchain, not +// kubectl/oc). The raw govulncheck output is NOT stored in the +// ConfigMap - it's typically far too large relative to the ~1MiB +// ConfigMap size limit to be useful there; matched_findings already +// carries the CVE-relevant subset. +package main + +import ( + "bufio" + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount" + +// Bound in-cluster ConfigMap create/patch calls so a stuck API server cannot +// hang the scan container indefinitely. +const k8sAPIClientTimeout = 30 * time.Second + +func parseScanExit(raw string) (int, error) { + if strings.TrimSpace(raw) == "" { + return 0, fmt.Errorf("SCAN_EXIT is required") + } + scanExit, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("invalid SCAN_EXIT %q: %w", raw, err) + } + return scanExit, nil +} + +func main() { + targetID := os.Getenv("TARGET_ID") + cveSet := parseCSVUpper(os.Getenv("CVE_IDS")) + ticketKeys := parseCSV(os.Getenv("TICKET_KEYS")) + scanExit, err := parseScanExit(os.Getenv("SCAN_EXIT")) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + findings, err := readNDJSON("/tmp/govulncheck.json") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to read govulncheck output: %v\n", err) + os.Exit(1) + } + matched := matchFindings(findings, cveSet) + + // A shell exit code > 128 means the process was terminated by a signal + // (e.g. 137 = 128+SIGKILL, typically an OOM kill). govulncheck's own + // exit codes (0 = clean, 3 = vulnerabilities found, 1 = error) are all + // < 128, so this never misclassifies a real result. In this case + // /tmp/govulncheck.json is partial/empty, so "no matches" does NOT mean + // "not affected" - it means the scan never finished. + scanIncomplete := scanExit > 128 + + result := map[string]any{ + "target_id": targetID, + "repo_url": os.Getenv("REPO_URL"), + "repo_slug": os.Getenv("REPO_SLUG"), + "git_ref": os.Getenv("GIT_REF"), + "commit": os.Getenv("COMMIT"), + "cve_ids": mapKeys(cveSet), + "ticket_keys": ticketKeys, + "scan_exit_code": scanExit, + "scan_incomplete": scanIncomplete, + "affected": len(matched) > 0, + "matched_findings": matched, + "finding_count": len(findings), + "stderr_tail": tailFile("/tmp/govulncheck.err", 8000), + } + + resultJSON, err := json.MarshalIndent(result, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal result: %v\n", err) + os.Exit(1) + } + + // Always surface the result in the pod/container log, since publishing + // may fail independently of the scan itself. Bracketed with markers so + // callers that capture container stdout directly (e.g. + // run_single_repo_scan.sh, which can't rely on a bind-mounted RESULT_DIR + // write being immediately visible on the host after the container exits) + // can reliably extract just the JSON amid toolchain/git log noise. + fmt.Println("EDGE_CVE_RESULT_JSON_BEGIN") + fmt.Println(string(resultJSON)) + fmt.Println("EDGE_CVE_RESULT_JSON_END") + + if resultDir := os.Getenv("RESULT_DIR"); resultDir != "" { + if err := writeLocalResult(resultDir, targetID, resultJSON); err != nil { + fmt.Fprintf(os.Stderr, "failed to write local result: %v\n", err) + os.Exit(1) + } + return + } + + if err := publishConfigMap(targetID, os.Getenv("REPO_LABEL"), resultJSON); err != nil { + fmt.Fprintf(os.Stderr, "failed to publish result configmap: %v\n", err) + os.Exit(1) + } +} + +// writeLocalResult writes the curated result.json plus a full, uncapped copy +// of the raw govulncheck.json output to RESULT_DIR//, +// for local (non-cluster) runs. Unlike the ConfigMap path, local disk has no +// meaningful size constraint, so the raw output isn't truncated here. +func writeLocalResult(resultDir, targetID string, resultJSON []byte) error { + dir := filepath.Join(resultDir, sanitizeLabel(targetID)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "result.json"), resultJSON, 0o644); err != nil { + return err + } + rawData, err := os.ReadFile("/tmp/govulncheck.json") + if err != nil { + // No raw output to copy (e.g. govulncheck never produced any) - not fatal. + return nil + } + return os.WriteFile(filepath.Join(dir, "govulncheck.json"), rawData, 0o644) +} + +func publishConfigMap(targetID, repoLabel string, resultJSON []byte) error { + client, apiServer, token, namespace, err := inClusterClient() + if err != nil { + return err + } + + name := configMapName(targetID) + body := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + "labels": map[string]string{ + "app.kubernetes.io/name": "edge-cve-govulncheck-result", + "edge-cve/target-id": sanitizeLabel(targetID), + "edge-cve/repo": sanitizeLabel(repoLabel), + }, + }, + "data": map[string]string{ + "result.json": string(resultJSON), + }, + } + payload, err := json.Marshal(body) + if err != nil { + return err + } + + createURL := fmt.Sprintf("%s/api/v1/namespaces/%s/configmaps", apiServer, namespace) + resp, err := doRequest(client, http.MethodPost, createURL, token, "application/json", payload) + if err != nil { + return err + } + if resp.status == http.StatusCreated { + return nil + } + if resp.status != http.StatusConflict { + return fmt.Errorf("create configmap %s failed: %d %s", name, resp.status, resp.body) + } + + // Already exists (e.g. job retry) - merge-patch the data/labels in place. + patch := map[string]any{ + "metadata": map[string]any{"labels": body["metadata"].(map[string]any)["labels"]}, + "data": body["data"], + } + patchPayload, err := json.Marshal(patch) + if err != nil { + return err + } + patchURL := fmt.Sprintf("%s/api/v1/namespaces/%s/configmaps/%s", apiServer, namespace, name) + resp, err = doRequest(client, http.MethodPatch, patchURL, token, "application/merge-patch+json", patchPayload) + if err != nil { + return err + } + if resp.status != http.StatusOK { + return fmt.Errorf("patch configmap %s failed: %d %s", name, resp.status, resp.body) + } + return nil +} + +type httpResult struct { + status int + body string +} + +func doRequest(client *http.Client, method, url, token, contentType string, payload []byte) (httpResult, error) { + req, err := http.NewRequest(method, url, bytes.NewReader(payload)) + if err != nil { + return httpResult{}, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + + if client.Timeout == 0 { + client.Timeout = k8sAPIClientTimeout + } + + resp, err := client.Do(req) + if err != nil { + return httpResult{}, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return httpResult{}, fmt.Errorf("reading response body: %w", err) + } + return httpResult{status: resp.StatusCode, body: string(respBody)}, nil +} + +func inClusterClient() (*http.Client, string, string, string, error) { + host := os.Getenv("KUBERNETES_SERVICE_HOST") + port := os.Getenv("KUBERNETES_SERVICE_PORT") + if host == "" || port == "" { + return nil, "", "", "", fmt.Errorf("KUBERNETES_SERVICE_HOST/PORT not set; not running in-cluster") + } + + tokenBytes, err := os.ReadFile(serviceAccountDir + "/token") + if err != nil { + return nil, "", "", "", fmt.Errorf("reading service account token: %w", err) + } + nsBytes, err := os.ReadFile(serviceAccountDir + "/namespace") + if err != nil { + return nil, "", "", "", fmt.Errorf("reading service account namespace: %w", err) + } + caBytes, err := os.ReadFile(serviceAccountDir + "/ca.crt") + if err != nil { + return nil, "", "", "", fmt.Errorf("reading service account ca.crt: %w", err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caBytes) { + return nil, "", "", "", fmt.Errorf("failed to parse ca.crt") + } + + client := &http.Client{ + Timeout: k8sAPIClientTimeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool}, + }, + } + apiServer := fmt.Sprintf("https://%s:%s", host, port) + return client, apiServer, strings.TrimSpace(string(tokenBytes)), strings.TrimSpace(string(nsBytes)), nil +} + +func configMapName(targetID string) string { + name := "govulncheck-result-" + sanitizeLabel(targetID) + if len(name) > 253 { + name = name[:253] + } + return strings.Trim(name, "-.") +} + +func sanitizeLabel(raw string) string { + // Preserve A-Z (no lowercasing) so ConfigMap edge-cve/repo labels match + // the case-preserving --repo filters from collect_govulncheck_results.py + // and the REPO_LABEL values set by run_govulncheck_jobs.sh. + var b strings.Builder + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + out := b.String() + // Truncate first, then trim trailing separators so a mid-label cut cannot + // leave a final '-', '_', or '.' (Kubernetes label values must end in + // alphanumeric). + if len(out) > 63 { + out = out[:63] + } + return strings.Trim(out, "-_.") +} + +func parseCSV(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func parseCSVUpper(raw string) map[string]bool { + set := make(map[string]bool) + for _, part := range parseCSV(raw) { + set[strings.ToUpper(part)] = true + } + return set +} + +func mapKeys(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for key := range set { + out = append(out, key) + } + return out +} + +func readNDJSON(path string) ([]map[string]any, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer file.Close() + + var entries []map[string]any + scanner := bufio.NewScanner(file) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var entry map[string]any + if err := json.Unmarshal([]byte(line), &entry); err != nil { + return nil, fmt.Errorf("decode NDJSON %s line %d: %w", path, lineNo, err) + } + entries = append(entries, entry) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan %s: %w", path, err) + } + return entries, nil +} + +// buildOSVIndex maps OSV IDs (and aliases) to top-level {"osv": {...}} catalog +// entries from the govulncheck NDJSON stream. Finding records reference these +// by string ID in finding.osv. +func buildOSVIndex(entries []map[string]any) map[string]map[string]any { + index := make(map[string]map[string]any) + for _, entry := range entries { + osv, ok := entry["osv"].(map[string]any) + if !ok { + continue + } + if id, ok := osv["id"].(string); ok && id != "" { + index[strings.ToUpper(id)] = osv + } + aliases, _ := osv["aliases"].([]any) + for _, alias := range aliases { + if s, ok := alias.(string); ok && s != "" { + index[strings.ToUpper(s)] = osv + } + } + } + return index +} + +// matchFindings selects the findings relevant to this scan. If CVE_IDS was +// provided (the Jira-driven bulk workflow, where we're checking a repo +// against specific known CVEs), only findings matching one of those IDs +// count. If no CVE_IDS was given (ad-hoc "is this repo/ref affected by +// anything" checks - see run_single_repo_scan.sh), every vulnerability +// govulncheck reports counts, since there's no specific CVE to filter to. +// +// Only NDJSON entries with a "finding" key are considered - top-level "osv" +// catalog records are used for ID resolution only and are never appended to +// matched (the original finding entry is preserved). +func matchFindings(findings []map[string]any, cveSet map[string]bool) []map[string]any { + osvByID := buildOSVIndex(findings) + matchAny := len(cveSet) == 0 + var matched []map[string]any + for _, entry := range findings { + if _, hasFinding := entry["finding"]; !hasFinding { + continue + } + if matchAny { + if findingOSV(entry, osvByID) != nil { + matched = append(matched, entry) + } + continue + } + if entryMatchesCVE(entry, cveSet, osvByID) { + matched = append(matched, entry) + } + } + return matched +} + +// findingOSV resolves the OSV record for a finding entry. govulncheck emits +// finding.osv as either an embedded object (legacy) or a string ID that +// references a prior top-level {"osv": {...}} catalog entry. +func findingOSV(entry map[string]any, osvByID map[string]map[string]any) map[string]any { + finding, ok := entry["finding"].(map[string]any) + if !ok { + finding = entry + } + switch v := finding["osv"].(type) { + case map[string]any: + return v + case string: + if v == "" { + break + } + if osv, ok := osvByID[strings.ToUpper(v)]; ok { + return osv + } + // Catalog entry missing (truncated stream) - still expose the ID so + // matchAny / exact-ID checks can see the finding. + return map[string]any{"id": v} + } + if osv, _ := finding["vulnerability"].(map[string]any); osv != nil { + return osv + } + return nil +} + +func entryMatchesCVE(entry map[string]any, cveSet map[string]bool, osvByID map[string]map[string]any) bool { + osv := findingOSV(entry, osvByID) + if osv == nil { + return false + } + if id, ok := osv["id"].(string); ok && cveSet[strings.ToUpper(id)] { + return true + } + aliases, _ := osv["aliases"].([]any) + for _, alias := range aliases { + if s, ok := alias.(string); ok && cveSet[strings.ToUpper(s)] { + return true + } + } + return false +} + +func tailFile(path string, max int) string { + content, _ := readCappedTail(path, max) + return content +} + +// readCappedTail returns the file contents, keeping only the last `max` +// bytes if the file is larger. The second return value reports whether +// truncation occurred. +func readCappedTail(path string, max int) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + if len(data) <= max { + return string(data), false + } + return string(data[len(data)-max:]), true +} diff --git a/plugins/edge-cve/scripts/process_govulncheck_result_test.go b/plugins/edge-cve/scripts/process_govulncheck_result_test.go new file mode 100644 index 00000000..0d8a841a --- /dev/null +++ b/plugins/edge-cve/scripts/process_govulncheck_result_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadNDJSONValidSkipsBlankLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.json") + content := "{\"osv\":{\"id\":\"GO-1\"}}\n\n{\"finding\":{\"osv\":\"GO-1\"}}\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + entries, err := readNDJSON(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } +} + +func TestReadNDJSONOpenFailure(t *testing.T) { + _, err := readNDJSON(filepath.Join(t.TempDir(), "missing.json")) + if err == nil || !strings.Contains(err.Error(), "open ") { + t.Fatalf("expected open error, got %v", err) + } +} + +func TestReadNDJSONDecodeFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte("{\"ok\":true}\nnot-json\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := readNDJSON(path) + if err == nil || !strings.Contains(err.Error(), "decode NDJSON") { + t.Fatalf("expected decode error, got %v", err) + } +} + +func TestParseScanExitValid(t *testing.T) { + got, err := parseScanExit("137") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 137 { + t.Fatalf("expected 137, got %d", got) + } +} + +func TestParseScanExitMissing(t *testing.T) { + _, err := parseScanExit("") + if err == nil || !strings.Contains(err.Error(), "required") { + t.Fatalf("expected required error, got %v", err) + } +} + +func TestParseScanExitMalformed(t *testing.T) { + _, err := parseScanExit("not-a-number") + if err == nil || !strings.Contains(err.Error(), "invalid SCAN_EXIT") { + t.Fatalf("expected invalid SCAN_EXIT error, got %v", err) + } +} + +func TestMatchFindingsStringOSVID(t *testing.T) { + entries := []map[string]any{ + { + "osv": map[string]any{ + "id": "GO-2024-1234", + "aliases": []any{"CVE-2024-99999"}, + }, + }, + { + "finding": map[string]any{ + "osv": "GO-2024-1234", + "trace": []any{map[string]any{"module": "example.com/mod"}}, + }, + }, + { + "progress": map[string]any{"message": "scanning"}, + }, + } + + // CVE filter matches via alias resolved from top-level OSV catalog. + matched := matchFindings(entries, map[string]bool{"CVE-2024-99999": true}) + if len(matched) != 1 { + t.Fatalf("expected 1 matched finding, got %d", len(matched)) + } + if _, ok := matched[0]["finding"]; !ok { + t.Fatalf("matched entry should preserve original finding record: %#v", matched[0]) + } + if matched[0]["finding"].(map[string]any)["osv"] != "GO-2024-1234" { + t.Fatalf("finding.osv string id should be preserved, got %#v", matched[0]["finding"]) + } +} + +func TestMatchFindingsEmbeddedOSVObject(t *testing.T) { + entries := []map[string]any{ + { + "finding": map[string]any{ + "osv": map[string]any{ + "id": "GO-2023-1", + "aliases": []any{"CVE-2023-11111"}, + }, + }, + }, + } + matched := matchFindings(entries, map[string]bool{"CVE-2023-11111": true}) + if len(matched) != 1 { + t.Fatalf("expected embedded osv object to match alias, got %d", len(matched)) + } +} + +func TestMatchFindingsMatchAnySkipsCatalogOnly(t *testing.T) { + entries := []map[string]any{ + {"osv": map[string]any{"id": "GO-2024-1", "aliases": []any{"CVE-1"}}}, + {"finding": map[string]any{"osv": "GO-2024-1"}}, + } + matched := matchFindings(entries, nil) + if len(matched) != 1 { + t.Fatalf("matchAny should return finding entries only, got %d", len(matched)) + } + if _, ok := matched[0]["finding"]; !ok { + t.Fatalf("expected finding entry, got %#v", matched[0]) + } +} + +func TestMatchFindingsNoMatch(t *testing.T) { + entries := []map[string]any{ + {"osv": map[string]any{"id": "GO-2024-1", "aliases": []any{"CVE-1"}}}, + {"finding": map[string]any{"osv": "GO-2024-1"}}, + } + matched := matchFindings(entries, map[string]bool{"CVE-9999": true}) + if len(matched) != 0 { + t.Fatalf("expected no matches, got %d", len(matched)) + } +} diff --git a/plugins/edge-cve/scripts/redact_parsed_for_analysis.py b/plugins/edge-cve/scripts/redact_parsed_for_analysis.py new file mode 100644 index 00000000..ac13b098 --- /dev/null +++ b/plugins/edge-cve/scripts/redact_parsed_for_analysis.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Build an LLM-safe ticket file for govulncheck analysis. + +Private tickets (is_private) are reduced to key/url stubs so summaries, CVE +IDs, and other fields never reach the analysis subagent. Non-private tickets +are copied unchanged. + +Usage: + redact_parsed_for_analysis.py --workdir DIR +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def redact_ticket(ticket: dict) -> dict: + if ticket.get("is_private"): + return { + "key": ticket.get("key", ""), + "url": ticket.get("url", ""), + "is_private": True, + "redacted": True, + } + return ticket + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Redact private tickets for LLM analysis input" + ) + parser.add_argument("--workdir", required=True) + parser.add_argument("--input", default="") + parser.add_argument("--output", default="") + args = parser.parse_args() + + workdir = Path(args.workdir) + input_path = Path(args.input) if args.input else workdir / "jira" / "cves-parsed.json" + output_path = ( + Path(args.output) + if args.output + else workdir / "jira" / "cves-parsed-for-analysis.json" + ) + + if not input_path.is_file(): + print(f"Error: input not found: {input_path}", file=sys.stderr) + sys.exit(1) + + with open(input_path, encoding="utf-8") as fh: + data = json.load(fh) + + tickets = data.get("tickets", []) + if not isinstance(tickets, list): + print("Error: 'tickets' must be a list", file=sys.stderr) + sys.exit(1) + + redacted_tickets = [redact_ticket(t) for t in tickets if isinstance(t, dict)] + private_count = sum(1 for t in redacted_tickets if t.get("is_private")) + + payload = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "source": str(input_path), + "purpose": "LLM analysis input; private tickets redacted to key/url only", + "count": len(redacted_tickets), + "private_redacted_count": private_count, + "tickets": redacted_tickets, + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + + print( + f"Wrote {len(redacted_tickets)} tickets " + f"({private_count} private redacted) -> {output_path}", + file=sys.stderr, + ) + print( + json.dumps( + { + "output": str(output_path), + "ticket_count": len(redacted_tickets), + "private_redacted_count": private_count, + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/scripts/run_govulncheck_jobs.sh b/plugins/edge-cve/scripts/run_govulncheck_jobs.sh new file mode 100755 index 00000000..908513c2 --- /dev/null +++ b/plugins/edge-cve/scripts/run_govulncheck_jobs.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Launch OpenShift govulncheck jobs for CVE scan targets. +# +# Usage: +# run_govulncheck_jobs.sh --workdir DIR [--namespace NS] [--repo SLUG ...] [--dry-run] +# +# Examples: +# run_govulncheck_jobs.sh --workdir DIR --repo openshift/lvm-operator --dry-run +# run_govulncheck_jobs.sh --workdir DIR --repo openshift/lvm-operator --repo openshift/microshift +# +# Prerequisites: +# - oc logged into an OpenShift cluster +# - scan-targets.json produced by build_scan_targets.py + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +WORKDIR="" +NAMESPACE="edge-cve-scans" +REPO_FILTERS=() +DRY_RUN=false + +usage() { + cat <&2; usage; exit 1 ;; + esac +done + +if [[ -z "${WORKDIR}" ]]; then + echo "Error: --workdir is required" >&2 + usage + exit 1 +fi + +TARGETS_FILE="${WORKDIR}/scans/scan-targets.json" +if [[ ! -f "${TARGETS_FILE}" ]]; then + echo "Error: ${TARGETS_FILE} not found. Run build_scan_targets.py first." >&2 + exit 1 +fi + +JOBS_DIR="${WORKDIR}/scans/jobs" +mkdir -p "${JOBS_DIR}" + +if [[ "${DRY_RUN}" == "false" ]]; then + if ! command -v oc >/dev/null 2>&1; then + echo "Error: oc is required (OpenShift CLI)" >&2 + exit 1 + fi + + if ! oc whoami >/dev/null 2>&1; then + echo "Error: not logged into OpenShift. Run 'oc login' first." >&2 + exit 1 + fi +fi + +if [[ "${DRY_RUN}" == "false" ]]; then + oc apply -f "${PLUGIN_DIR}/k8s/namespace.yaml" + sed "s/namespace: edge-cve-scans/namespace: ${NAMESPACE}/g" \ + "${PLUGIN_DIR}/k8s/rbac.yaml" | oc apply -f - + oc -n "${NAMESPACE}" create configmap edge-cve-govulncheck-scripts \ + --from-file=process_govulncheck_result.go="${SCRIPT_DIR}/process_govulncheck_result.go" \ + --from-file=scan_target.sh="${SCRIPT_DIR}/scan_target.sh" \ + --dry-run=client -o yaml | oc apply -f - +else + echo "[dry-run] would apply namespace, RBAC (edge-cve-scanner ServiceAccount/Role/RoleBinding)," + echo "[dry-run] and ConfigMap edge-cve-govulncheck-scripts from process_govulncheck_result.go + scan_target.sh" +fi + +PYTHON_ARGS=("${TARGETS_FILE}") +if [[ ${#REPO_FILTERS[@]} -gt 0 ]]; then + PYTHON_ARGS+=("${REPO_FILTERS[@]}") +fi + +mapfile -t TARGET_LINES < <( + python3 - <<'PY' "${PYTHON_ARGS[@]}" +import json, sys +targets_file = sys.argv[1] +repo_filters = sys.argv[2:] +with open(targets_file) as fh: + data = json.load(fh) +targets = data.get("targets", []) +if repo_filters: + targets = [t for t in targets if t.get("repo_slug") in repo_filters] + print(f"Filtering to repos {repo_filters!r}: {len(targets)} target(s)", file=sys.stderr) +for target in targets: + print("|".join([ + target["id"], + target["repo_url"], + target["repo_slug"], + target["git_ref"], + ",".join(target.get("cve_ids", [])), + ",".join(target.get("ticket_keys", [])), + ])) +PY +) + +if [[ ${#TARGET_LINES[@]} -eq 0 ]]; then + if [[ ${#REPO_FILTERS[@]} -gt 0 ]]; then + echo "No Go scan targets found for repos (${REPO_FILTERS[*]}) in ${TARGETS_FILE}" >&2 + else + echo "No Go scan targets found in ${TARGETS_FILE}" >&2 + fi + exit 0 +fi + +MANIFEST_INDEX="${JOBS_DIR}/index.json" +echo '{"jobs":[]}' > "${MANIFEST_INDEX}" +job_count=0 + +for line in "${TARGET_LINES[@]}"; do + IFS='|' read -r target_id repo_url repo_slug git_ref cve_ids ticket_keys <<< "${line}" + job_name="govulncheck-${target_id}" + job_name="${job_name:0:63}" + rendered="${JOBS_DIR}/${target_id}.yaml" + repo_label="${repo_slug//\//--}" + repo_label="$(printf '%s' "${repo_label}" | tr -cd 'A-Za-z0-9._-')" + repo_label="${repo_label:0:63}" + + sed \ + -e "s|__TARGET_ID__|${target_id}|g" \ + -e "s|__REPO_URL__|${repo_url}|g" \ + -e "s|__REPO_SLUG__|${repo_slug}|g" \ + -e "s|__REPO_LABEL__|${repo_label}|g" \ + -e "s|__GIT_REF__|${git_ref}|g" \ + -e "s|__CVE_IDS__|${cve_ids}|g" \ + -e "s|__TICKET_KEYS__|${ticket_keys}|g" \ + -e "s|namespace: edge-cve-scans|namespace: ${NAMESPACE}|g" \ + "${PLUGIN_DIR}/k8s/govulncheck-job.yaml.template" > "${rendered}" + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "[dry-run] would apply ${rendered}" + else + oc -n "${NAMESPACE}" delete job "${job_name}" --ignore-not-found=true >/dev/null 2>&1 || true + oc apply -f "${rendered}" + echo "Launched job ${job_name}" + fi + + python3 - </{result.json,govulncheck.json} +# and are aggregated into ${WORKDIR}/scans/govulncheck-results.json, in the +# same shape produced by collect_govulncheck_results.py, so generate_report.py +# works unchanged regardless of execution mode. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKDIR="" +REPO_FILTERS=() +IMAGE="registry.redhat.io/ubi9/go-toolset:1.23" +CACHE_VOLUME="edge-cve-govulncheck-gocache" +# govulncheck's source-mode call-graph analysis (plus the go1.25 toolchain +# auto-download it triggers via GOTOOLCHAIN=auto) can need several GB of RAM +# for larger operator repos - 1-2Gi is not enough and gets SIGKILL'd (exit 137). +CONTAINER_MEMORY="16g" +CONTAINER_CPUS="3" +# Hard wall-clock cap per target so a hung clone/toolchain-download can't sit +# forever holding a container's writable layer open (this is what previously +# left an orphaned multi-GB container behind and filled the podman VM disk). +CONTAINER_TIMEOUT="1800" +# Opt-in only: never prune the host's podman store unless the caller explicitly +# asked for it (--prune). Unrelated images/containers on a shared machine must +# not be deleted as a side effect of a CVE scan. +RUN_PRUNE=0 + +usage() { + cat <&2; usage; exit 1 ;; + esac +done + +if [[ -z "${WORKDIR}" ]]; then + echo "Error: --workdir is required" >&2 + usage + exit 1 +fi + +TARGETS_FILE="${WORKDIR}/scans/scan-targets.json" +if [[ ! -f "${TARGETS_FILE}" ]]; then + echo "Error: ${TARGETS_FILE} not found. Run build_scan_targets.py first." >&2 + exit 1 +fi + +if ! command -v podman >/dev/null 2>&1; then + echo "Error: podman is required" >&2 + exit 1 +fi + +TIMEOUT_BIN="" +if command -v timeout >/dev/null 2>&1; then + TIMEOUT_BIN="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_BIN="gtimeout" +else + echo "Error: timeout (GNU coreutils) or gtimeout (macOS coreutils) is required for wall-clock container limits" >&2 + exit 1 +fi + +# Force-remove whatever container is currently in flight if this script itself +# gets interrupted (Ctrl-C, killed by a wrapper/tool timeout, etc.) - this is +# exactly the scenario that previously orphaned a multi-GB container and filled +# the podman VM's disk, since a `podman run --rm` container that never exits +# cleanly never gets its writable layer reclaimed. +CURRENT_CONTAINER="" +cleanup_current_container() { + if [[ -n "${CURRENT_CONTAINER}" ]]; then + podman rm -f "${CURRENT_CONTAINER}" >/dev/null 2>&1 || true + fi +} +# EXIT for normal termination; INT/TERM must exit after cleanup so the scan +# loop cannot resume with an orphaned/half-removed container. +trap cleanup_current_container EXIT +trap 'cleanup_current_container; exit 130' INT +trap 'cleanup_current_container; exit 143' TERM + +if [[ ${RUN_PRUNE} -eq 1 ]]; then + echo "Pruning stopped containers / dangling images (--prune explicitly requested)..." >&2 + podman system prune -f >&2 || true +fi +podman system df >&2 || true + +RESULTS_DIR="${WORKDIR}/scans/results" +mkdir -p "${RESULTS_DIR}" + +PYTHON_ARGS=("${TARGETS_FILE}") +if [[ ${#REPO_FILTERS[@]} -gt 0 ]]; then + PYTHON_ARGS+=("${REPO_FILTERS[@]}") +fi + +mapfile -t TARGET_LINES < <( + python3 - <<'PY' "${PYTHON_ARGS[@]}" +import json, sys +targets_file = sys.argv[1] +repo_filters = sys.argv[2:] +with open(targets_file) as fh: + data = json.load(fh) +targets = data.get("targets", []) +if repo_filters: + targets = [t for t in targets if t.get("repo_slug") in repo_filters] + print(f"Filtering to repos {repo_filters!r}: {len(targets)} target(s)", file=sys.stderr) +for target in targets: + print("|".join([ + target["id"], + target["repo_url"], + target["repo_slug"], + target["git_ref"], + ",".join(target.get("cve_ids", [])), + ",".join(target.get("ticket_keys", [])), + ])) +PY +) + +if [[ ${#TARGET_LINES[@]} -eq 0 ]]; then + if [[ ${#REPO_FILTERS[@]} -gt 0 ]]; then + echo "No Go scan targets found for repos (${REPO_FILTERS[*]}) in ${TARGETS_FILE}" >&2 + else + echo "No Go scan targets found in ${TARGETS_FILE}" >&2 + fi + exit 0 +fi + +echo "Running ${#TARGET_LINES[@]} target(s) sequentially with podman (image: ${IMAGE}, memory: ${CONTAINER_MEMORY}, cpus: ${CONTAINER_CPUS})" >&2 + +fail_count=0 +oom_count=0 +index=0 +for line in "${TARGET_LINES[@]}"; do + index=$((index + 1)) + IFS='|' read -r target_id repo_url repo_slug git_ref cve_ids ticket_keys <<< "${line}" + repo_label="${repo_slug//\//--}" + repo_label="$(printf '%s' "${repo_label}" | tr -cd 'A-Za-z0-9._-')" + repo_label="${repo_label:0:63}" + + echo "[${index}/${#TARGET_LINES[@]}] Scanning ${repo_slug}@${git_ref} (target: ${target_id})" >&2 + + container_name="edge-cve-scan-$(printf '%s' "${target_id}" | tr -cd 'A-Za-z0-9_.-')" + container_name="${container_name:0:63}" + # Clean up any same-named leftover from a previous interrupted run before reusing the name. + podman rm -f "${container_name}" >/dev/null 2>&1 || true + CURRENT_CONTAINER="${container_name}" + + run_cmd=(podman run --rm --name "${container_name}" + --memory="${CONTAINER_MEMORY}" --cpus="${CONTAINER_CPUS}" + -e REPO_URL="${repo_url}" + -e REPO_SLUG="${repo_slug}" + -e REPO_LABEL="${repo_label}" + -e GIT_REF="${git_ref}" + -e TARGET_ID="${target_id}" + -e CVE_IDS="${cve_ids}" + -e TICKET_KEYS="${ticket_keys}" + -e RESULT_DIR=/results + -e HOME=/tmp + -e GOPATH=/tmp/go + -e GOCACHE=/tmp/go/cache + -e GOMODCACHE=/tmp/go/pkg/mod + -e GOTOOLCHAIN=auto + -v "${SCRIPT_DIR}/process_govulncheck_result.go:/scripts/process_govulncheck_result.go:ro,Z" + -v "${SCRIPT_DIR}/scan_target.sh:/scripts/scan_target.sh:ro,Z" + -v "${RESULTS_DIR}:/results:Z" + -v "${CACHE_VOLUME}:/tmp/go:Z" + "${IMAGE}" /bin/bash /scripts/scan_target.sh) + if [[ -n "${TIMEOUT_BIN}" ]]; then + run_cmd=("${TIMEOUT_BIN}" --kill-after=30 "${CONTAINER_TIMEOUT}" "${run_cmd[@]}") + fi + + set +e + "${run_cmd[@]}" + scan_exit=$? + set -e + + if [[ ${scan_exit} -eq 124 ]]; then + echo " -> timed out after ${CONTAINER_TIMEOUT}s (hung clone or toolchain download?) - forcing cleanup" >&2 + podman rm -f "${container_name}" >/dev/null 2>&1 || true + fail_count=$((fail_count + 1)) + elif [[ ${scan_exit} -eq 137 ]]; then + echo " -> OOM-killed (exit 137, memory=${CONTAINER_MEMORY}). Re-run with a higher --memory." >&2 + oom_count=$((oom_count + 1)) + fail_count=$((fail_count + 1)) + elif [[ ${scan_exit} -ne 0 ]]; then + echo " -> govulncheck exited ${scan_exit} (see ${RESULTS_DIR}/${target_id}/ for details)" >&2 + fail_count=$((fail_count + 1)) + else + echo " -> clean" >&2 + fi + CURRENT_CONTAINER="" +done + +podman system df >&2 || true + +AGGREGATE_FILE="${WORKDIR}/scans/govulncheck-results.json" +AGGREGATE_ARGS=("${RESULTS_DIR}" "${AGGREGATE_FILE}") +if [[ ${#REPO_FILTERS[@]} -gt 0 ]]; then + AGGREGATE_ARGS+=("${REPO_FILTERS[@]}") +fi + +python3 - < keep every valid result (existing behavior). + if repo_filters and result.get("repo_slug") not in repo_filters: + continue + results.append(result) + +payload = { + "collected_at": datetime.now(timezone.utc).isoformat(), + "namespace": "local-podman", + "repo_filters": repo_filters, + "wait": {"skipped": True, "mode": "sequential-podman"}, + "results": results, +} +Path(output_path).write_text(json.dumps(payload, indent=2), encoding="utf-8") + +affected = sum(1 for r in results if r.get("affected")) +print(f"Collected {len(results)} results ({affected} affected)", file=sys.stderr) +print(f"Written: {output_path}", file=sys.stderr) +print(json.dumps({"result_count": len(results), "affected_count": affected, "output": output_path}, indent=2)) +PY + +if [[ ${oom_count} -gt 0 ]]; then + echo "Warning: ${oom_count}/${#TARGET_LINES[@]} target(s) were OOM-killed (exit 137) at --memory=${CONTAINER_MEMORY}." >&2 + echo "Their results are incomplete (govulncheck never finished) - re-run with e.g. --memory 6g." >&2 +elif [[ ${fail_count} -gt 0 ]]; then + echo "Note: ${fail_count} target(s) had a non-zero govulncheck exit code (may just mean findings were reported)." >&2 +fi diff --git a/plugins/edge-cve/scripts/run_single_repo_scan.sh b/plugins/edge-cve/scripts/run_single_repo_scan.sh new file mode 100755 index 00000000..9dc6073c --- /dev/null +++ b/plugins/edge-cve/scripts/run_single_repo_scan.sh @@ -0,0 +1,233 @@ +#!/usr/bin/bash +# Clone+scan a single repo@ref with govulncheck via podman - no scan-targets.json +# or Jira data required. Used for ad-hoc "is this repo/ref affected" checks +# (see cve-investigator.sh check-repo / edge-cve:investigate --check-repo). +# +# Usage: +# run_single_repo_scan.sh --repo-url URL --ref REF --result-dir DIR +# [--repo-slug SLUG] [--cve ID ...] [--ticket KEY ...] +# [--image IMAGE] [--memory MEM] [--cpus N] [--timeout SECONDS] [--no-prune] +# +# --cve is repeatable and optional. If omitted, govulncheck's findings are +# treated as a general "any known vulnerability at this ref" check (see +# process_govulncheck_result.go); if given, only findings matching one of the +# listed CVE IDs/aliases are considered a match. +# +# Reuses the same scan_target.sh / process_govulncheck_result.go logic (and +# the shared edge-cve-govulncheck-gocache volume) as run_govulncheck_jobs.sh / +# run_govulncheck_podman.sh, so results are directly comparable, and the same +# disk-usage safeguards apply (named container, wall-clock timeout, cleanup +# trap, optional prune before starting). +# +# Writes RESULT-DIR//{result.json,govulncheck.json} and prints the +# result.json path on stdout. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_URL="" +REPO_SLUG="" +GIT_REF="" +CVE_IDS=() +TICKET_KEYS=() +RESULT_DIR="" +IMAGE="registry.redhat.io/ubi9/go-toolset:1.23" +CACHE_VOLUME="edge-cve-govulncheck-gocache" +CONTAINER_MEMORY="6g" +CONTAINER_CPUS="3" +CONTAINER_TIMEOUT="1800" +# Opt-in only: never prune the host's podman store unless the caller explicitly +# asked for it (--prune). +RUN_PRUNE=0 + +usage() { + cat <&2; usage; exit 1 ;; + esac +done + +if [[ -z "${REPO_URL}" || -z "${GIT_REF}" || -z "${RESULT_DIR}" ]]; then + echo "Error: --repo-url, --ref, and --result-dir are required" >&2 + usage + exit 1 +fi + +if [[ -z "${REPO_SLUG}" ]]; then + REPO_SLUG="$(printf '%s' "${REPO_URL}" | sed -E 's#^(https?://)?([^/]+/)?##; s#\.git$##')" +fi + +if ! command -v podman >/dev/null 2>&1; then + echo "Error: podman is required" >&2 + exit 1 +fi + +TIMEOUT_BIN="" +if command -v timeout >/dev/null 2>&1; then + TIMEOUT_BIN="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_BIN="gtimeout" +else + echo "Error: timeout (GNU coreutils) or gtimeout (macOS coreutils) is required for wall-clock container limits" >&2 + exit 1 +fi + +if [[ ${RUN_PRUNE} -eq 1 ]]; then + echo "Pruning stopped containers / dangling images (--prune explicitly requested)..." >&2 + podman system prune -f >&2 || true +fi + +mkdir -p "${RESULT_DIR}" + +repo_label="${REPO_SLUG//\//--}" +repo_label="$(printf '%s' "${repo_label}" | tr -cd 'A-Za-z0-9._-')" +repo_label="${repo_label:0:63}" + +ref_label="$(printf '%s' "${GIT_REF}" | tr -cd 'A-Za-z0-9._-')" +# Collision-resistant id: readable labels + digest of full URL/ref (normalized). +# Built before RESULT_DIR// so truncated labels cannot collide. +digest="$( + printf '%s\n%s\n' \ + "$(printf '%s' "${REPO_URL}" | tr '[:upper:]' '[:lower:]')" \ + "$(printf '%s' "${GIT_REF}" | tr '[:upper:]' '[:lower:]')" \ + | openssl dgst -sha256 \ + | awk '{print $NF}' \ + | cut -c1-8 +)" +base="${repo_label}--${ref_label}" +max_base=$((120 - 2 - ${#digest})) +if (( max_base < 1 )); then + max_base=1 +fi +base="${base:0:${max_base}}" +target_id="${base}--${digest}" + +cve_ids_csv="" +if [[ ${#CVE_IDS[@]} -gt 0 ]]; then + cve_ids_csv="$(IFS=,; echo "${CVE_IDS[*]}")" +fi +ticket_keys_csv="" +if [[ ${#TICKET_KEYS[@]} -gt 0 ]]; then + ticket_keys_csv="$(IFS=,; echo "${TICKET_KEYS[*]}")" +fi + +container_name="edge-cve-check-$(printf '%s' "${target_id}" | tr -cd 'A-Za-z0-9_.-')" +container_name="${container_name:0:63}" +podman rm -f "${container_name}" >/dev/null 2>&1 || true + +CURRENT_CONTAINER="${container_name}" +cleanup_current_container() { + if [[ -n "${CURRENT_CONTAINER:-}" ]]; then + podman rm -f "${CURRENT_CONTAINER}" >/dev/null 2>&1 || true + fi +} +# EXIT for normal termination; INT/TERM must exit after cleanup so execution +# cannot resume after signal handling. +trap cleanup_current_container EXIT +trap 'cleanup_current_container; exit 130' INT +trap 'cleanup_current_container; exit 143' TERM + +echo "Scanning ${REPO_SLUG}@${GIT_REF} (target: ${target_id})" >&2 + +run_cmd=(podman run --rm --name "${container_name}" + --memory="${CONTAINER_MEMORY}" --cpus="${CONTAINER_CPUS}" + -e REPO_URL="${REPO_URL}" + -e REPO_SLUG="${REPO_SLUG}" + -e REPO_LABEL="${repo_label}" + -e GIT_REF="${GIT_REF}" + -e TARGET_ID="${target_id}" + -e CVE_IDS="${cve_ids_csv}" + -e TICKET_KEYS="${ticket_keys_csv}" + -e RESULT_DIR=/results + -e HOME=/tmp + -e GOPATH=/tmp/go + -e GOCACHE=/tmp/go/cache + -e GOMODCACHE=/tmp/go/pkg/mod + -e GOTOOLCHAIN=auto + -v "${SCRIPT_DIR}/process_govulncheck_result.go:/scripts/process_govulncheck_result.go:ro,Z" + -v "${SCRIPT_DIR}/scan_target.sh:/scripts/scan_target.sh:ro,Z" + -v "${RESULT_DIR}:/results:Z" + -v "${CACHE_VOLUME}:/tmp/go:Z" + "${IMAGE}" /bin/bash /scripts/scan_target.sh) +if [[ -n "${TIMEOUT_BIN}" ]]; then + run_cmd=("${TIMEOUT_BIN}" --kill-after=30 "${CONTAINER_TIMEOUT}" "${run_cmd[@]}") +fi + +# Capture the container's combined output (while still streaming it live via +# tee) so we can pull the result JSON straight from stdout instead of relying +# on the RESULT_DIR bind mount being immediately visible on the host right +# after the container exits - on podman machine (macOS/virtiofs) that can lag +# well behind the container's own exit, occasionally by tens of seconds. +# +# IMPORTANT: tee's own stdout is redirected to stderr (>&2) below. This +# script's actual stdout is the return-value channel (callers like +# cve-investigator.sh capture it via command substitution to get the +# result.json path) - if tee were left writing to stdout too, that capture +# would end up containing the whole container log instead of just the path. +container_log="$(mktemp)" +set +e +"${run_cmd[@]}" 2>&1 | tee "${container_log}" >&2 +scan_exit=${PIPESTATUS[0]} +set -e +CURRENT_CONTAINER="" + +if [[ ${scan_exit} -eq 124 ]]; then + echo "Timed out after ${CONTAINER_TIMEOUT}s (hung clone or toolchain download?) - forcing cleanup" >&2 + podman rm -f "${container_name}" >/dev/null 2>&1 || true +elif [[ ${scan_exit} -eq 137 ]]; then + echo "OOM-killed (exit 137, memory=${CONTAINER_MEMORY}) - re-run with a higher --memory" >&2 +fi + +result_file="${RESULT_DIR}/${target_id}/result.json" +mkdir -p "$(dirname "${result_file}")" +if sed -n '/^EDGE_CVE_RESULT_JSON_BEGIN$/,/^EDGE_CVE_RESULT_JSON_END$/p' "${container_log}" \ + | sed '1d;$d' > "${result_file}.tmp" && [[ -s "${result_file}.tmp" ]]; then + mv "${result_file}.tmp" "${result_file}" +else + rm -f "${result_file}.tmp" + # Fall back to the bind-mounted copy (e.g. if markers weren't found for + # some reason), retrying briefly for the same host/VM sync lag noted above. + attempts=0 + while [[ ! -f "${result_file}" && ${attempts} -lt 30 ]]; do + sleep 1 + attempts=$((attempts + 1)) + done +fi +rm -f "${container_log}" + +if [[ ! -f "${result_file}" ]]; then + echo "Error: expected result file not found/extractable: ${result_file} (scan exit ${scan_exit})" >&2 + exit 1 +fi + +echo "${result_file}" diff --git a/plugins/edge-cve/scripts/scan_target.sh b/plugins/edge-cve/scripts/scan_target.sh new file mode 100755 index 00000000..18f40fe0 --- /dev/null +++ b/plugins/edge-cve/scripts/scan_target.sh @@ -0,0 +1,69 @@ +#!/usr/bin/bash +# Clone a repo at a target ref, run govulncheck, and process the result. +# +# Shared by both execution modes: +# - OpenShift Job (k8s/govulncheck-job.yaml.template), mounted from the +# edge-cve-govulncheck-scripts ConfigMap +# - Local podman runner (run_govulncheck_podman.sh), mounted as a bind mount +# +# Required env vars: REPO_URL, GIT_REF, TARGET_ID, CVE_IDS, TICKET_KEYS, +# REPO_SLUG, REPO_LABEL. +# +# Result publishing (handled by process_govulncheck_result.go): +# - If RESULT_DIR is set, results are written to local files under it +# (podman/local mode). +# - Otherwise, results are published to a Kubernetes ConfigMap using the +# in-cluster service account (OpenShift Job mode). +# +# Disk usage: the repo clone and Go build cache are removed on exit (see the +# cleanup trap below) so repeated runs against a shared cache volume/container +# don't grow disk usage without bound. +set -euo pipefail + +workdir="/tmp/workspace/repo" +mkdir -p "${workdir}" /tmp/go/bin /tmp/go/cache /tmp/go/pkg/mod + +# Always clean up the cloned repo tree and trim the build cache on exit, even +# on failure. GOMODCACHE (downloaded module sources) and the go toolchain +# under /tmp/go are left alone since they're reused heavily across targets +# (same deps across repo versions) and matter for scan speed; the git +# checkout and GOCACHE build objects have little/no reuse value across +# different repos/refs and are the main source of unbounded growth in the +# shared cache volume / container writable layer over many sequential runs. +# shellcheck disable=SC2329 # Invoked via trap. +cleanup() { + local ec=$? + cd / 2>/dev/null || true + rm -rf "${workdir}" 2>/dev/null || true + go clean -cache 2>/dev/null || true + exit "${ec}" +} +trap cleanup EXIT + +git clone --depth 1 --branch "${GIT_REF}" "${REPO_URL}" "${workdir}" 2>/dev/null \ + || git clone --depth 1 "${REPO_URL}" "${workdir}" + +cd "${workdir}" +if ! git rev-parse --verify "${GIT_REF}" >/dev/null 2>&1; then + git fetch --depth 1 origin "${GIT_REF}" || true +fi +if ! git checkout "${GIT_REF}" 2>/dev/null && ! git checkout "origin/${GIT_REF}" 2>/dev/null; then + echo "Error: failed to checkout GIT_REF=${GIT_REF} (also tried origin/${GIT_REF})" >&2 + exit 1 +fi + +commit="$(git rev-parse HEAD)" +export GOTOOLCHAIN=auto +go install golang.org/x/vuln/cmd/govulncheck@latest +export PATH="/tmp/go/bin:${PATH}" + +set +e +govulncheck -json ./... > /tmp/govulncheck.json 2>/tmp/govulncheck.err +scan_exit=$? +set -e + +export COMMIT="${commit}" +export SCAN_EXIT="${scan_exit}" +go run /scripts/process_govulncheck_result.go + +exit "${scan_exit}" diff --git a/plugins/edge-cve/scripts/test_analyze_scan_result.py b/plugins/edge-cve/scripts/test_analyze_scan_result.py new file mode 100644 index 00000000..809c0228 --- /dev/null +++ b/plugins/edge-cve/scripts/test_analyze_scan_result.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Unit tests for analyze_scan_result.determine_verdict / finding_label.""" + +import unittest +from pathlib import Path +import sys + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from analyze_scan_result import determine_verdict, finding_label # noqa: E402 + + +class FindingLabelTests(unittest.TestCase): + def test_string_osv_id_with_module(self): + # Positive: govulncheck NDJSON often stores finding.osv as a string ID. + label = finding_label( + { + "finding": { + "osv": "GO-2024-1234", + "trace": [{"module": "golang.org/x/net"}], + } + } + ) + self.assertEqual(label, "GO-2024-1234 in golang.org/x/net") + + def test_embedded_osv_object_with_module(self): + # Positive: legacy/embedded OSV object with id field. + label = finding_label( + { + "finding": { + "osv": {"id": "CVE-2024-99999", "summary": "example"}, + "trace": [{"package": "example.com/mod/pkg"}], + } + } + ) + self.assertEqual(label, "CVE-2024-99999 in example.com/mod/pkg") + + def test_missing_osv_falls_back_to_question_mark(self): + # Negative: no osv/vulnerability → preserve "?" fallback, no module. + self.assertEqual(finding_label({"finding": {"trace": []}}), "?") + + def test_empty_string_osv_id_falls_back(self): + # Negative: empty string ID must not produce a blank label. + self.assertEqual(finding_label({"finding": {"osv": ""}}), "?") + + def test_osv_object_without_id_falls_back(self): + # Negative: mapping without usable id → "?". + self.assertEqual( + finding_label({"finding": {"osv": {"summary": "no id"}}}), + "?", + ) + + +class DetermineVerdictTests(unittest.TestCase): + def test_scan_incomplete_inconclusive(self): + verdict, action = determine_verdict( + {"scan_incomplete": True, "affected": False, "scan_exit_code": 137} + ) + self.assertEqual(verdict, "inconclusive") + self.assertFalse(action) + + def test_affected_true(self): + verdict, action = determine_verdict( + {"scan_incomplete": False, "affected": True, "scan_exit_code": 3, "finding_count": 2} + ) + self.assertEqual(verdict, "affected") + self.assertTrue(action) + + def test_abnormal_exit_zero_findings_inconclusive(self): + # Positive case for the fix: exit 1 with no findings must not clear the repo. + verdict, action = determine_verdict( + {"scan_incomplete": False, "affected": False, "scan_exit_code": 1, "finding_count": 0} + ) + self.assertEqual(verdict, "inconclusive") + self.assertFalse(action) + + def test_abnormal_exit_with_findings_inconclusive(self): + verdict, action = determine_verdict( + {"scan_incomplete": False, "affected": False, "scan_exit_code": 1, "finding_count": 3} + ) + self.assertEqual(verdict, "inconclusive") + self.assertFalse(action) + + def test_clean_result_not_affected(self): + # Negative case: normal clean scan (exit 0, no findings) stays not_affected. + verdict, action = determine_verdict( + {"scan_incomplete": False, "affected": False, "scan_exit_code": 0, "finding_count": 0} + ) + self.assertEqual(verdict, "not_affected") + self.assertFalse(action) + + def test_exit_3_without_affected_flag_not_affected(self): + # Exit 3 is a normal govulncheck code; without affected=True we do not + # invent an affected verdict here (matched_findings drive that flag). + verdict, action = determine_verdict( + {"scan_incomplete": False, "affected": False, "scan_exit_code": 3, "finding_count": 0} + ) + self.assertEqual(verdict, "not_affected") + self.assertFalse(action) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/test_build_scan_targets.py b/plugins/edge-cve/scripts/test_build_scan_targets.py new file mode 100644 index 00000000..6843270c --- /dev/null +++ b/plugins/edge-cve/scripts/test_build_scan_targets.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Unit tests for build_scan_targets.target_id collision resistance.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from build_scan_targets import slugify, target_id # noqa: E402 + + +class TargetIdTests(unittest.TestCase): + def test_preserves_readable_slug_prefix(self) -> None: + tid = target_id("openshift/lvm-operator", "release-4.18") + self.assertTrue(tid.startswith("openshift-lvm-operator--release-4-18--")) + self.assertEqual(len(tid.rsplit("--", 1)[-1]), 8) + + def test_digest_disambiguates_truncated_slug_collision(self) -> None: + # Positive: two distinct long slugs that share a slugify() prefix must + # not share a target_id once the digest is appended. + a = "openshift/" + ("a" * 80) + "-one" + b = "openshift/" + ("a" * 80) + "-two" + self.assertEqual(slugify(a), slugify(b)) + self.assertNotEqual(target_id(a, "release-4.18"), target_id(b, "release-4.18")) + + def test_same_inputs_stable(self) -> None: + # Negative: identical inputs always produce the same id. + self.assertEqual( + target_id("openshift/microshift", "main"), + target_id("openshift/microshift", "main"), + ) + + def test_normalization_case_insensitive_digest(self) -> None: + self.assertEqual( + target_id("OpenShift/MicroShift", "Release-4.18"), + target_id("openshift/microshift", "release-4.18"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/test_collect_govulncheck_results.py b/plugins/edge-cve/scripts/test_collect_govulncheck_results.py new file mode 100644 index 00000000..3b12f2ab --- /dev/null +++ b/plugins/edge-cve/scripts/test_collect_govulncheck_results.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Unit tests for collect_govulncheck_results job completion helpers.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from collect_govulncheck_results import job_is_terminal, summarize_jobs # noqa: E402 + + +def _job(*, active=None, failed=None, succeeded=None, conditions=None): + status = {} + if active is not None: + status["active"] = active + if failed is not None: + status["failed"] = failed + if succeeded is not None: + status["succeeded"] = succeeded + if conditions is not None: + status["conditions"] = conditions + return {"status": status} + + +class JobTerminalTests(unittest.TestCase): + def test_complete_condition_terminal(self): + self.assertTrue( + job_is_terminal( + _job(conditions=[{"type": "Complete", "status": "True"}]) + ) + ) + + def test_failed_condition_terminal(self): + self.assertTrue( + job_is_terminal(_job(conditions=[{"type": "Failed", "status": "True"}])) + ) + + def test_false_condition_not_terminal(self): + self.assertFalse( + job_is_terminal( + _job(conditions=[{"type": "Complete", "status": "False"}]) + ) + ) + + def test_new_job_active_zero_not_terminal(self): + # Positive case for the fix: newly created jobs often have no active + # pods yet and must not be treated as finished. + self.assertFalse(job_is_terminal(_job())) + self.assertFalse(job_is_terminal(_job(active=0))) + + +class SummarizeJobsTests(unittest.TestCase): + def test_all_terminal_complete(self): + summary = summarize_jobs( + [ + _job( + succeeded=1, + conditions=[{"type": "Complete", "status": "True"}], + ), + _job( + failed=1, + conditions=[{"type": "Failed", "status": "True"}], + ), + ] + ) + self.assertTrue(summary["complete"]) + self.assertEqual(summary["succeeded"], 1) + self.assertEqual(summary["failed"], 1) + + def test_active_zero_without_conditions_keeps_polling(self): + # Negative: old active==0 heuristic would have returned complete. + summary = summarize_jobs([_job(active=0), _job()]) + self.assertFalse(summary["complete"]) + + def test_mixed_terminal_and_running_not_complete(self): + summary = summarize_jobs( + [ + _job( + succeeded=1, + conditions=[{"type": "Complete", "status": "True"}], + ), + _job(active=1), + ] + ) + self.assertFalse(summary["complete"]) + self.assertEqual(summary["active"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/test_cve_extract.py b/plugins/edge-cve/scripts/test_cve_extract.py new file mode 100644 index 00000000..0f161ef6 --- /dev/null +++ b/plugins/edge-cve/scripts/test_cve_extract.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Unit tests for CVE extraction helpers.""" + +import unittest +from pathlib import Path +import sys + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from lib.cve_extract import ( # noqa: E402 + extract_cve_ids, + extract_ocp_versions, + extract_repo_urls, + resolve_component_repo, + resolve_git_refs, + ticket_versions, +) + + +class CveExtractTests(unittest.TestCase): + def test_extract_cve_ids(self): + text = "Fix CVE-2024-12345 and CVE-2023-99999 in golang" + self.assertEqual( + extract_cve_ids(text), + ["CVE-2024-12345", "CVE-2023-99999"], + ) + + def test_extract_ocp_versions(self): + text = "MicroShift 4.17 and 4.18.z affected" + self.assertEqual(extract_ocp_versions(text), ["4.17", "4.18"]) + + def test_extract_ocp_versions_ignores_semver_patch(self): + # Upstream library versions like "4.1.4" must not become OCP "4.1". + text = "Prior to 4.1.4 and 3.0.5, decrypting a JWE object will panic [openshift-4.23]" + self.assertEqual(extract_ocp_versions(text), ["4.23"]) + + def test_extract_repo_urls(self): + text = "See https://github.com/openshift/microshift/pull/1" + patterns = [r"github\.com/(?P[^/\s]+)/(?P[^/\s#?]+)"] + repos = extract_repo_urls(text, patterns) + self.assertEqual(repos[0]["slug"], "openshift/microshift") + + def test_resolve_component_repo(self): + config = { + "defaults": {"org": "openshift"}, + "components": { + "MicroShift": { + "repo": "openshift/microshift", + "language": "go", + "version_ref_template": "release-{version}", + "version_ref_fallbacks": [], + } + }, + } + repo = resolve_component_repo("MicroShift", config) + self.assertEqual(repo["slug"], "openshift/microshift") + # Ticket versions map to release branches only - never invent main. + self.assertEqual(resolve_git_refs(["4.17", "4.18"], repo), ["release-4.17", "release-4.18"]) + self.assertEqual(resolve_git_refs([], repo), []) + + def test_resolve_git_refs_ignores_fallback_when_versions_present(self): + repo = { + "version_ref_template": "release-{version}", + "version_ref_fallbacks": ["main"], + } + # Fallbacks must NOT be appended on top of version-derived refs - + # tip-of-tree captures far more than the ticket is asking about. + self.assertEqual(resolve_git_refs(["4.17"], repo), ["release-4.17"]) + + def test_resolve_git_refs_uses_fallback_only_when_no_versions(self): + repo = { + "version_ref_template": "release-{version}", + "version_ref_fallbacks": ["main"], + } + self.assertEqual(resolve_git_refs([], repo), ["main"]) + + def test_resolve_git_refs_fixed_branch_template(self): + # Unversioned components (e.g. two-node-toolbox) use a fixed branch + # as their template - that single branch is fine to scan as-is. + repo = { + "version_ref_template": "main", + "version_ref_fallbacks": [], + } + self.assertEqual(resolve_git_refs([], repo), ["main"]) + self.assertEqual(resolve_git_refs(["4.17"], repo), ["main"]) + + def test_ticket_versions(self): + issue = { + "summary": "CVE-2024-1 in 4.19", + "description": "", + "affected_versions": ["4.18.z"], + "fix_versions": [], + } + self.assertEqual(ticket_versions(issue), ["4.18", "4.19"]) + + def test_ticket_versions_ignores_description_library_versions(self): + issue = { + "summary": ( + "CVE-2026-34986 lvms4/lvms-must-gather-rhel9: " + "Go JOSE DoS [openshift-4.23]" + ), + "description": "Prior to 4.1.4 and 3.0.5, decrypting a JWE object will panic.", + "affected_versions": ["4.23"], + "fix_versions": [], + } + self.assertEqual(ticket_versions(issue), ["4.23"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/test_generate_html_report.py b/plugins/edge-cve/scripts/test_generate_html_report.py new file mode 100644 index 00000000..3aba375b --- /dev/null +++ b/plugins/edge-cve/scripts/test_generate_html_report.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Unit tests for generate_html_report.group_tickets private version bucketing.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from generate_html_report import ( # noqa: E402 + group_tickets, + load_known_components, + render_component, + render_summary, + unique_rows_by_key, +) + + +class GroupTicketsPrivateVersionTests(unittest.TestCase): + def test_private_ticket_grouped_under_withheld(self) -> None: + parsed = { + "tickets": [ + { + "key": "OCPBUGS-1", + "url": "https://example/OCPBUGS-1", + "component": "MicroShift", + "versions": ["4.18", "4.19"], + "is_private": True, + "cve_ids": ["CVE-2024-1"], + "summary": "secret", + } + ] + } + grouped, dropped = group_tickets(parsed, {}, {"MicroShift"}) + self.assertEqual(dropped, 0) + self.assertIn("Withheld", grouped["MicroShift"]) + self.assertNotIn("4.18", grouped["MicroShift"]) + self.assertNotIn("4.19", grouped["MicroShift"]) + row = grouped["MicroShift"]["Withheld"][0] + self.assertTrue(row["is_private"]) + self.assertEqual(row["cve_ids"], []) + self.assertEqual(row["summary"], "") + self.assertEqual(row["scans"], []) + + def test_public_ticket_keeps_real_versions(self) -> None: + parsed = { + "tickets": [ + { + "key": "OCPBUGS-2", + "url": "https://example/OCPBUGS-2", + "component": "MicroShift", + "versions": ["4.18"], + "is_private": False, + "cve_ids": ["CVE-2024-2"], + "summary": "public", + } + ] + } + grouped, _ = group_tickets(parsed, {}, {"MicroShift"}) + self.assertIn("4.18", grouped["MicroShift"]) + self.assertNotIn("Withheld", grouped["MicroShift"]) + row = grouped["MicroShift"]["4.18"][0] + self.assertEqual(row["summary"], "public") + self.assertEqual(row["cve_ids"], ["CVE-2024-2"]) + + +class LoadKnownComponentsTests(unittest.TestCase): + def test_missing_config_returns_empty(self) -> None: + self.assertEqual(load_known_components(Path("/nonexistent/component-repos.json")), set()) + + def test_empty_components_mapping_returns_empty(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "component-repos.json" + path.write_text(json.dumps({"components": {}}), encoding="utf-8") + self.assertEqual(load_known_components(path), set()) + + def test_main_exits_when_mapping_empty(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + (workdir / "jira").mkdir() + (workdir / "jira" / "cves-parsed.json").write_text( + json.dumps({"tickets": []}), encoding="utf-8" + ) + empty_cfg = workdir / "empty-components.json" + empty_cfg.write_text(json.dumps({"components": {}}), encoding="utf-8") + # S603: argv is sys.executable + fixed script path + test-controlled args only. + result = subprocess.run( # noqa: S603 + [ + sys.executable, + str(SCRIPT_DIR / "generate_html_report.py"), + "--workdir", + str(workdir), + "--config", + str(empty_cfg), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("refusing to render all Jira components", result.stderr) + self.assertFalse((workdir / "report-cve-investigation.html").exists()) + + +class DedupStatsTests(unittest.TestCase): + def test_multi_version_ticket_counted_once(self) -> None: + parsed = { + "tickets": [ + { + "key": "OCPBUGS-9", + "url": "https://example/OCPBUGS-9", + "component": "MicroShift", + "versions": ["4.18", "4.19"], + "is_private": False, + "cve_ids": ["CVE-2024-9"], + "summary": "multi", + } + ] + } + grouped, _ = group_tickets(parsed, {}, {"MicroShift"}) + # Still listed under both version sections for display. + self.assertEqual(len(grouped["MicroShift"]["4.18"]), 1) + self.assertEqual(len(grouped["MicroShift"]["4.19"]), 1) + unique = unique_rows_by_key(grouped["MicroShift"]) + self.assertEqual(len(unique), 1) + + _, counts = render_summary(grouped) + self.assertEqual(counts["total"], 1) + + html_out = render_component("MicroShift", grouped["MicroShift"], open_by_default=False) + self.assertIn("(1 ticket)", html_out) + # Version headings preserved (badge/summary display behavior). + self.assertIn("

    4.18

    ", html_out) + self.assertIn("

    4.19

    ", html_out) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/test_jira_client.py b/plugins/edge-cve/scripts/test_jira_client.py new file mode 100644 index 00000000..8f173748 --- /dev/null +++ b/plugins/edge-cve/scripts/test_jira_client.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Unit tests for jira_client.search_jql pagination guards.""" + +from __future__ import annotations + +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Allow importing jira_client when requests isn't installed in the test env. +if "requests" not in sys.modules: + _requests = types.ModuleType("requests") + _requests.Session = MagicMock # type: ignore[attr-defined] + _requests.RequestException = Exception # type: ignore[attr-defined] + sys.modules["requests"] = _requests + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from lib import jira_client # noqa: E402 + + +def _resp(payload: dict, status: int = 200) -> MagicMock: + r = MagicMock() + r.status_code = status + r.raise_for_status = MagicMock() + r.json.return_value = payload + return r + + +class SearchJqlPaginationTests(unittest.TestCase): + def setUp(self): + self.cfg = { + "base_url": "https://example.atlassian.net", + "email": "u@example.com", + "token": "t", + } + + @patch.object(jira_client, "load_config") + def test_explicit_is_last_terminates(self, load_config): + load_config.return_value = self.cfg + sess = MagicMock() + sess.post.side_effect = [ + _resp({"issues": [{"key": "A"}], "isLast": False, "nextPageToken": "p2"}), + _resp({"issues": [{"key": "B"}], "isLast": True}), + ] + issues = jira_client.search_jql("project = X", session=sess, max_results=1) + self.assertEqual([i["key"] for i in issues], ["A", "B"]) + self.assertEqual(sess.post.call_count, 2) + + @patch.object(jira_client, "load_config") + def test_repeated_token_raises(self, load_config): + load_config.return_value = self.cfg + sess = MagicMock() + sess.post.side_effect = [ + _resp({"issues": [{"key": "A"}], "isLast": False, "nextPageToken": "same"}), + _resp({"issues": [{"key": "B"}], "isLast": False, "nextPageToken": "same"}), + ] + with self.assertRaises(RuntimeError) as ctx: + jira_client.search_jql("project = X", session=sess, max_results=1) + self.assertIn("failed to advance", str(ctx.exception)) + + @patch.object(jira_client, "load_config") + def test_is_last_false_without_token_raises(self, load_config): + load_config.return_value = self.cfg + sess = MagicMock() + sess.post.return_value = _resp({"issues": [{"key": "A"}], "isLast": False}) + with self.assertRaises(RuntimeError) as ctx: + jira_client.search_jql("project = X", session=sess, max_results=1) + self.assertIn("isLast=false but no nextPageToken", str(ctx.exception)) + + @patch.object(jira_client, "load_config") + def test_missing_metadata_on_full_page_raises(self, load_config): + load_config.return_value = self.cfg + sess = MagicMock() + # Full page, neither isLast nor nextPageToken - must not silently truncate. + sess.post.return_value = _resp({"issues": [{"key": "A"}, {"key": "B"}]}) + with self.assertRaises(RuntimeError) as ctx: + jira_client.search_jql("project = X", session=sess, max_results=2) + self.assertIn("refusing to truncate", str(ctx.exception)) + + @patch.object(jira_client, "load_config") + def test_missing_metadata_on_short_page_ok(self, load_config): + load_config.return_value = self.cfg + sess = MagicMock() + sess.post.return_value = _resp({"issues": [{"key": "A"}]}) + issues = jira_client.search_jql("project = X", session=sess, max_results=100) + self.assertEqual([i["key"] for i in issues], ["A"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/test_redact_parsed_for_analysis.py b/plugins/edge-cve/scripts/test_redact_parsed_for_analysis.py new file mode 100644 index 00000000..231c9f8d --- /dev/null +++ b/plugins/edge-cve/scripts/test_redact_parsed_for_analysis.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Tests for redact_parsed_for_analysis.py.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent / "redact_parsed_for_analysis.py" + + +class RedactParsedForAnalysisTest(unittest.TestCase): + def _run_with_parsed(self, workdir: Path, parsed: dict) -> subprocess.CompletedProcess[str]: + jira = workdir / "jira" + jira.mkdir(parents=True, exist_ok=True) + (jira / "cves-parsed.json").write_text(json.dumps(parsed), encoding="utf-8") + # S603: argv is sys.executable + fixed SCRIPT path + test-controlled args only. + return subprocess.run( # noqa: S603 + [sys.executable, str(SCRIPT), "--workdir", str(workdir)], + capture_output=True, + text=True, + check=False, + ) + + def test_redacts_private_keeps_public(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + result = self._run_with_parsed( + workdir, + { + "tickets": [ + { + "key": "OCPBUGS-1", + "summary": "public summary", + "cve_ids": ["CVE-2024-1"], + "is_private": False, + }, + { + "key": "OCPBUGS-2", + "summary": "SECRET EMBARGO DETAILS", + "cve_ids": ["CVE-2024-2"], + "is_private": True, + "url": "https://example/OCPBUGS-2", + }, + ] + }, + ) + self.assertEqual(result.returncode, 0, result.stderr) + out = workdir / "jira" / "cves-parsed-for-analysis.json" + payload = json.loads(out.read_text(encoding="utf-8")) + by_key = {t["key"]: t for t in payload["tickets"]} + self.assertEqual(by_key["OCPBUGS-1"]["summary"], "public summary") + self.assertNotIn("summary", by_key["OCPBUGS-2"]) + self.assertNotIn("cve_ids", by_key["OCPBUGS-2"]) + self.assertTrue(by_key["OCPBUGS-2"]["redacted"]) + self.assertEqual(payload["private_redacted_count"], 1) + + def test_non_list_tickets_exits_1(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + result = self._run_with_parsed(workdir, {"tickets": {"key": "OCPBUGS-1"}}) + self.assertEqual(result.returncode, 1) + self.assertIn("'tickets' must be a list", result.stderr) + self.assertFalse((workdir / "jira" / "cves-parsed-for-analysis.json").exists()) + + def test_missing_tickets_key_handled_gracefully(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + result = self._run_with_parsed(workdir, {"count": 0}) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads( + (workdir / "jira" / "cves-parsed-for-analysis.json").read_text(encoding="utf-8") + ) + self.assertEqual(payload["tickets"], []) + self.assertEqual(payload["count"], 0) + self.assertEqual(payload["private_redacted_count"], 0) + + def test_non_dict_ticket_entries_excluded(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + result = self._run_with_parsed( + workdir, + { + "tickets": [ + "not-a-ticket", + 42, + None, + { + "key": "OCPBUGS-3", + "summary": "kept", + "is_private": False, + }, + ] + }, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads( + (workdir / "jira" / "cves-parsed-for-analysis.json").read_text(encoding="utf-8") + ) + self.assertEqual(len(payload["tickets"]), 1) + self.assertEqual(payload["tickets"][0]["key"], "OCPBUGS-3") + self.assertEqual(payload["private_redacted_count"], 0) + + def test_empty_tickets_list_zero_private_count(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + result = self._run_with_parsed(workdir, {"tickets": []}) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads( + (workdir / "jira" / "cves-parsed-for-analysis.json").read_text(encoding="utf-8") + ) + self.assertEqual(payload["tickets"], []) + self.assertEqual(payload["private_redacted_count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/test_validate_grouped_cves.py b/plugins/edge-cve/scripts/test_validate_grouped_cves.py new file mode 100644 index 00000000..06a47499 --- /dev/null +++ b/plugins/edge-cve/scripts/test_validate_grouped_cves.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Tests for validate_grouped_cves.py.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent / "validate_grouped_cves.py" + +MINIMAL_GROUPED = { + "grouped_at": "2026-01-01T00:00:00+00:00", + "source": "cves-parsed.json", + "group_count": 1, + "ticket_count": 1, + "llm_review_count": 0, + "groups": [ + { + "group_id": "CVE-2024-1::Comp::stem", + "cve_id": "CVE-2024-1", + "component": "Comp", + "summary_stem": "stem", + "ticket_count": 1, + "ticket_keys": ["OCPBUGS-1"], + "versions": ["4.18"], + "repos": ["openshift/foo"], + "tickets": [{"key": "OCPBUGS-1", "cve_ids": ["CVE-2024-1"]}], + "needs_llm_review": False, + "llm_review_reasons": [], + } + ], +} + + +class ValidateGroupedCvesTest(unittest.TestCase): + def _run(self, *args: str) -> subprocess.CompletedProcess[str]: + # S603: argv is sys.executable + fixed SCRIPT path + test-controlled args only. + return subprocess.run( # noqa: S603 + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + check=False, + ) + + def test_missing_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + (workdir / "jira").mkdir() + schema = workdir / "jira" / "cves-grouped.json" + schema.write_text(json.dumps(MINIMAL_GROUPED), encoding="utf-8") + result = self._run("--workdir", str(workdir)) + self.assertEqual(result.returncode, 1) + self.assertIn("not found", result.stderr) + + def test_valid_reviewed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + jira = workdir / "jira" + jira.mkdir() + (jira / "cves-grouped.json").write_text( + json.dumps(MINIMAL_GROUPED), encoding="utf-8" + ) + (jira / "cves-grouped-reviewed.json").write_text( + json.dumps(MINIMAL_GROUPED), encoding="utf-8" + ) + result = self._run("--workdir", str(workdir)) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + + def test_invalid_schema(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + jira = workdir / "jira" + jira.mkdir() + (jira / "cves-grouped.json").write_text( + json.dumps(MINIMAL_GROUPED), encoding="utf-8" + ) + bad = {"groups": "not-a-list"} + (jira / "cves-grouped-reviewed.json").write_text( + json.dumps(bad), encoding="utf-8" + ) + result = self._run("--workdir", str(workdir)) + self.assertEqual(result.returncode, 1) + self.assertIn("stop without rebuilding", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-cve/scripts/validate_grouped_cves.py b/plugins/edge-cve/scripts/validate_grouped_cves.py new file mode 100644 index 00000000..4ad4f607 --- /dev/null +++ b/plugins/edge-cve/scripts/validate_grouped_cves.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Validate a grouped-CVE JSON file against the cves-grouped.json schema. + +Usage: + validate_grouped_cves.py --input FILE [--schema-ref FILE] + validate_grouped_cves.py --workdir DIR + # checks jira/cves-grouped-reviewed.json vs jira/cves-grouped.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +# Top-level keys always present on group_cves.py output. +TOP_LEVEL_REQUIRED = ( + "group_count", + "ticket_count", + "groups", +) + +GROUP_REQUIRED = ( + "group_id", + "cve_id", + "component", + "summary_stem", + "ticket_count", + "ticket_keys", + "versions", + "repos", + "tickets", + "needs_llm_review", + "llm_review_reasons", +) + +TICKET_REQUIRED = ( + "key", +) + + +def _err(msg: str) -> None: + print(f"Error: {msg}", file=sys.stderr) + + +def load_json(path: Path) -> Any: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def validate_grouped(data: Any, *, label: str) -> list[str]: + errors: list[str] = [] + if not isinstance(data, dict): + return [f"{label}: root must be a JSON object"] + + for key in TOP_LEVEL_REQUIRED: + if key not in data: + errors.append(f"{label}: missing top-level key '{key}'") + + groups = data.get("groups") + if "groups" in data and not isinstance(groups, list): + errors.append(f"{label}: 'groups' must be a list") + return errors + + if isinstance(groups, list): + if "group_count" in data and data["group_count"] != len(groups): + errors.append( + f"{label}: group_count ({data['group_count']}) != len(groups) ({len(groups)})" + ) + for i, group in enumerate(groups): + g_label = f"{label}.groups[{i}]" + if not isinstance(group, dict): + errors.append(f"{g_label}: must be an object") + continue + for key in GROUP_REQUIRED: + if key not in group: + errors.append(f"{g_label}: missing key '{key}'") + tickets = group.get("tickets") + if "tickets" in group and not isinstance(tickets, list): + errors.append(f"{g_label}: 'tickets' must be a list") + elif isinstance(tickets, list): + if "ticket_count" in group and group["ticket_count"] != len(tickets): + errors.append( + f"{g_label}: ticket_count ({group['ticket_count']}) " + f"!= len(tickets) ({len(tickets)})" + ) + for j, ticket in enumerate(tickets): + t_label = f"{g_label}.tickets[{j}]" + if not isinstance(ticket, dict): + errors.append(f"{t_label}: must be an object") + continue + for key in TICKET_REQUIRED: + if key not in ticket: + errors.append(f"{t_label}: missing key '{key}'") + return errors + + +def validate_against_schema_ref(candidate: Any, schema_ref: Any) -> list[str]: + """Ensure candidate uses the same top-level and group key sets as schema_ref.""" + errors: list[str] = [] + if not isinstance(schema_ref, dict) or not isinstance(candidate, dict): + return errors + + ref_top = set(schema_ref.keys()) + cand_top = set(candidate.keys()) + missing_top = ref_top - cand_top + if missing_top: + errors.append( + "missing top-level keys present in schema ref: " + + ", ".join(sorted(missing_top)) + ) + + ref_groups = schema_ref.get("groups") or [] + cand_groups = candidate.get("groups") or [] + if ref_groups and isinstance(ref_groups[0], dict) and cand_groups: + ref_group_keys = set(ref_groups[0].keys()) + for i, group in enumerate(cand_groups): + if not isinstance(group, dict): + continue + missing = ref_group_keys - set(group.keys()) + if missing: + errors.append( + f"groups[{i}]: missing keys present in schema ref: " + + ", ".join(sorted(missing)) + ) + return errors + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Validate grouped CVE JSON against cves-grouped schema" + ) + parser.add_argument("--workdir", default="") + parser.add_argument("--input", default="") + parser.add_argument( + "--schema-ref", + default="", + help="Reference cves-grouped.json (default: sibling or workdir/jira/cves-grouped.json)", + ) + args = parser.parse_args() + + if args.workdir: + workdir = Path(args.workdir) + input_path = Path(args.input) if args.input else workdir / "jira" / "cves-grouped-reviewed.json" + schema_path = ( + Path(args.schema_ref) + if args.schema_ref + else workdir / "jira" / "cves-grouped.json" + ) + elif args.input: + input_path = Path(args.input) + schema_path = Path(args.schema_ref) if args.schema_ref else input_path.parent / "cves-grouped.json" + else: + _err("--workdir or --input required") + sys.exit(2) + + if not input_path.is_file(): + _err(f"reviewed grouping not found: {input_path}") + _err("stop without rebuilding scan targets (avoid stale prepare outputs)") + sys.exit(1) + + try: + candidate = load_json(input_path) + except json.JSONDecodeError as exc: + _err(f"invalid JSON in {input_path}: {exc}") + sys.exit(1) + + errors = validate_grouped(candidate, label=str(input_path)) + + if schema_path.is_file(): + try: + schema_ref = load_json(schema_path) + except json.JSONDecodeError as exc: + _err(f"invalid schema ref JSON in {schema_path}: {exc}") + sys.exit(1) + errors.extend(validate_against_schema_ref(candidate, schema_ref)) + else: + _err(f"schema ref not found: {schema_path} (structural checks only)") + + if errors: + for msg in errors: + _err(msg) + _err("stop without rebuilding scan targets") + sys.exit(1) + + print( + json.dumps( + { + "ok": True, + "input": str(input_path), + "schema_ref": str(schema_path) if schema_path.is_file() else None, + "group_count": candidate.get("group_count"), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-cve/skills/investigate/SKILL.md b/plugins/edge-cve/skills/investigate/SKILL.md new file mode 100644 index 00000000..182a7e4f --- /dev/null +++ b/plugins/edge-cve/skills/investigate/SKILL.md @@ -0,0 +1,438 @@ +--- +name: edge-cve:investigate +argument-hint: "[--workdir DIR] [--dry-run] [--skip-scan] [--local] [--check-repo URL --ref REF]" +description: Investigate open Black CVE Jira tickets, run govulncheck scans, and produce actionable remediation reports +user-invocable: true +allowed-tools: Skill, Bash, Read, Write, Glob, Grep, Agent +--- + +# edge-cve:investigate + +## Synopsis + +```bash +/edge-cve:investigate +/edge-cve:investigate --dry-run +/edge-cve:investigate --skip-scan +/edge-cve:investigate --local +/edge-cve:investigate --check-repo https://github.com/openshift/lvm-operator --ref release-4.18 --cve CVE-2024-99999 +``` + +## Description + +Fetches open **Black** CVE tickets from Jira using the intersection of the +`All Open CVEs` and `All Open Black CVEs` saved filters, categorizes them by +component and version, resolves repository targets, launches OpenShift +govulncheck jobs, and produces a team notification report. + +For a quick one-off check outside the Jira-driven pipeline (e.g. "is this +repo/branch affected by anything, right now"), use `--check-repo` (see +[Ad-hoc single-repo check](#ad-hoc-single-repo-check) below) instead of the +full prepare/scan/finalize flow. + +**Deterministic scripts** handle Jira fetch, parsing, grouping, scan target +generation, job orchestration, and report generation. **LLM agents** are used +only for: + +1. Reviewing ambiguous CVE groups flagged by `group_cves.py` +2. Analyzing govulncheck results to decide if remediation is required +3. Refining remediation prompts for affected repositories + +## Arguments + +Parse `$ARGUMENTS` for optional flags: + +| Flag | Effect | +|------|--------| +| `--workdir DIR` | Override work directory. Default is a unique per-run dir from `mktemp -d "${TMPDIR:-/tmp}/edge-cve-workdir.XXXXXX"`. Overrides must be an absolute path, must not contain `..`, must not be a system/home root, and must be empty or nonexistent when starting a new investigation (`prepare` enforces this). | +| `--dry-run` | Run `prepare` and render OpenShift job manifests without applying them | +| `--skip-scan` | Skip scan launch/collection entirely; generate report from existing scan data | +| `--local` | Run the scan sequentially via podman (`scan-local`) instead of OpenShift Jobs; no cluster required | +| `--check-repo URL --ref REF` | Ad-hoc single-repo mode (see below): bypasses the whole Jira pipeline; `--cve ID` (repeatable, optional), `--ticket KEY` (repeatable, optional), `--jira-url`, `--summary`, `--component` add context | + +**Flag incompatibility**: `--local` and `--dry-run` must not be combined. `--dry-run` +applies only to OpenShift job manifest rendering; `--local` uses podman +(`scan-local`) and has no dry-run path. If both appear in `$ARGUMENTS`, stop +immediately with an error before any prepare/scan work: + +```text +Error: --local and --dry-run are incompatible; use one or the other +``` + +## Prerequisites + +| Requirement | Purpose | +|-------------|---------| +| `JIRA_BASE_URL` | Jira instance (default: `https://redhat.atlassian.net`) | +| `JIRA_EMAIL` or `JIRA_USERNAME` | Jira authentication | +| `JIRA_API_TOKEN` | Jira API token | +| `oc` + OpenShift login | Launch govulncheck jobs (unless `--skip-scan` or `--local`) | +| `podman` | Alternative local scan execution (`--local`), no cluster required | +| Python 3 + `requests` | Deterministic scripts | + +## Jira Query + +The default JQL is fixed to the Black CVE filter intersection: + +```jql +filter = "All Open CVEs" AND filter = "All Open Black CVEs" +``` + +Do NOT broaden this query. Only Black CVEs in this intersection are in scope. + +## Work Directory + +Compute once at the start of a new investigation. Prefer a unique per-run +directory (never reuse a shared daily path): + +```bash +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/edge-cve-workdir.XXXXXX")" +``` + +If `--workdir DIR` is provided instead, validate it **before** `prepare`: + +- absolute path (starts with `/`) +- does not contain `..` +- is not a system/home root (`/`, `/tmp`, `$HOME`, etc.) +- is nonexistent or empty (refuse to write into a non-empty directory) + +`cve-investigator.sh prepare` enforces the same checks. For `--skip-scan` on an +existing prior run, reuse that run's workdir as-is (it will already contain +outputs; do not re-run `prepare` against it). + +Prescribed outputs: + +| Path | Producer | +|------|----------| +| `jira/cves-raw.json` | `fetch_cves.py` | +| `jira/cves-parsed.json` | `parse_cves.py` | +| `jira/cves-grouped.json` | `group_cves.py` | +| `jira/cves-llm-review.json` | `group_cves.py` (ambiguous groups) | +| `jira/cves-grouped-reviewed.json` | Step 1b Agent (after validation) | +| `jira/cves-parsed-for-analysis.json` | `redact_parsed_for_analysis.py` (Step 3) | +| `scans/scan-targets.json` | `build_scan_targets.py` | +| `scans/govulncheck-results.json` | `collect_govulncheck_results.py` | +| `report-cve-investigation.md` | `generate_report.py` | +| `report-cve-investigation.html` | `generate_html_report.py` | +| `remediation-prompts.md` | `generate_report.py` | + +## Implementation Steps + +### Step 1: Prepare — Fetch, Parse, Group, Build Scan Targets + +**Goal**: Deterministically collect and structure all in-scope Black CVEs. + +**Actions**: + +1. Compute `` with `mktemp -d "${TMPDIR:-/tmp}/edge-cve-workdir.XXXXXX"`, + or validate a `--workdir` override (absolute, no `..`, not a system/home + root, empty or nonexistent) before continuing. +2. Run: + + ```text + bash plugins/edge-cve/scripts/cve-investigator.sh prepare --workdir + ``` + +3. Read the JSON printed by the script. Note `count`, `by_component`, and + `go_target_count`. +4. Read `/jira/cves-llm-review.json`. If `groups` is non-empty, + proceed to Step 1b. Otherwise skip to Step 2. + +**Error handling**: + +- Missing Jira credentials: show env var setup from `plugins/edge-cve/README.md` and stop. +- Zero issues returned: report "No open Black CVEs found" and stop. + +### Step 1b: LLM Review of Ambiguous Groups (Conditional) + +**Goal**: Confirm or adjust deterministic grouping for tickets flagged +`needs_llm_review`. + +**Actions**: + +1. Launch a single **foreground** Agent: + + ```text + Agent: subagent_type=generalPurpose, prompt="Review CVE grouping for edge-cve investigation. + Read /jira/cves-llm-review.json and /jira/cves-grouped.json. + + For each flagged group, decide whether tickets represent the same underlying + vulnerability across versions/components. If groups should be merged or split, + write an updated /jira/cves-grouped-reviewed.json with the same schema + as cves-grouped.json. + + If no changes are needed, copy cves-grouped.json to cves-grouped-reviewed.json + unchanged. + + Do NOT invent CVE IDs or repositories. Only reorganize existing ticket data. + Reply DONE when cves-grouped-reviewed.json is written." + ``` + +2. **Validate the reviewed grouping before rebuilding targets.** Require + `/jira/cves-grouped-reviewed.json` to exist and match the + `cves-grouped.json` schema. If it is missing or invalid, **stop** — do + **not** run `build_scan_targets.py` (that would leave or refresh targets from + stale pre-review data). + + ```text + python3 plugins/edge-cve/scripts/validate_grouped_cves.py --workdir + ``` + + This checks `jira/cves-grouped-reviewed.json` against + `jira/cves-grouped.json` (existence, JSON parse, required top-level/group/ + ticket keys, and key parity with the schema ref). Non-zero exit means stop. + +3. Rebuild scan targets from the reviewed grouping (only after validation + succeeds): + + ```text + python3 plugins/edge-cve/scripts/build_scan_targets.py \ + --workdir \ + --input /jira/cves-grouped-reviewed.json + ``` + +### Step 2: Run govulncheck Scans + +Skip this step when `--skip-scan` is set. + +**Before branching**: If both `--local` and `--dry-run` are set, stop with +`Error: --local and --dry-run are incompatible; use one or the other`. Do not +fall through to the `--local` branch (that would silently ignore `--dry-run`). + +**Actions**: + +1. If `--local` (podman, no cluster required — run one target at a time): + + **Warn the user before starting** that local scans use podman on their + machine (named containers + a shared `edge-cve-govulncheck-gocache` + volume). Do **not** run host-wide cleanup unless they explicitly approve + it — always pass `--no-prune` by default. Only add `--prune` (which runs + `podman system prune -f`) after the user confirms cleanup is OK. + + ```text + bash plugins/edge-cve/scripts/cve-investigator.sh scan-local --workdir --no-prune + ``` + + This writes `scans/govulncheck-results.json` directly; skip to Step 3 + (no separate collect step for local runs). + +2. Else if `--dry-run` (OpenShift, render manifests without applying): + + ```text + bash plugins/edge-cve/scripts/cve-investigator.sh scan --workdir --dry-run + ``` + + Report how many jobs would be created and stop before Step 3. + +3. Otherwise (OpenShift): + + ```text + bash plugins/edge-cve/scripts/cve-investigator.sh scan --workdir + ``` + + **Stop / continue after `scan`:** + + | Condition | Handling | + |-----------|----------| + | Non-zero exit (oc/login/RBAC/apply failure, missing `scan-targets.json`, etc.) | **Stop.** Do not run `collect`, Step 3, or claim a successful scan. Report the error. | + | Zero discovered Go targets (`No Go scan targets found…`, exit 0) | **Stop the scan path.** Do not run `collect` or Step 3. Skip to Step 4 only to report tickets with **no scan coverage**; do not invent empty "all clear" results. | + | Jobs applied successfully | Continue to `collect`. | + + ```text + bash plugins/edge-cve/scripts/cve-investigator.sh collect --workdir + ``` + + **Stop / continue after `collect`:** + + | Condition | Handling | + |-----------|----------| + | Non-zero exit (not logged in, `oc` failure, etc.) | **Stop.** Do not run Step 3. Do not finalize as if this run produced fresh complete results. | + | Missing `/scans/govulncheck-results.json` after collect | **Stop.** Do not analyze or finalize using an older file from a prior run in the same workdir. | + | File present but malformed JSON / not an object with a `results` array | **Stop.** Report parse error; do not analyze or finalize from corrupted output. | + | `wait.complete` is false (collection **timed out**) | **Note partial results and continue** (same as prior timeout guidance): proceed to Steps 3–4 only with explicit partial/incomplete status. Do **not** treat the run as fully collected. | + | `wait.complete` is true but `results` is empty while jobs were expected | Treat as **incomplete**, not not-affected. Continue to Step 4 with that caveat; skip Step 3 LLM analysis (nothing trustworthy to analyze). | + | Complete payload with `results` | Continue to Step 3. | + +4. Timeout reminder: when collect warns it timed out waiting for jobs, keep going + with whatever ConfigMaps were gathered, but label the investigation + **partial** in chat and in any user-facing summary. Never describe partial + or timed-out collection as a complete scan. + +### Step 3: Analyze govulncheck Results (LLM) + +**Goal**: Determine which scan results are truly actionable. + +**Gate**: Only enter this step when Step 2 produced a **current**, parseable +`govulncheck-results.json` for this run. If scan/collect stopped on failure, +missing file, or malformed JSON, skip analysis entirely. If results are +partial (timeout / incomplete wait), analyze only entries present in that file +and mark missing targets as unscanned — do not infer not-affected from absence. + +**Actions**: + +1. Read `/scans/govulncheck-results.json` (from this run only). +2. **Redact private tickets before any analysis subagent runs.** Never pass + `jira/cves-parsed.json` to the subagent when it may contain `is_private` + tickets (summaries/CVE IDs must not reach the LLM). Build a safe input: + + ```text + python3 plugins/edge-cve/scripts/redact_parsed_for_analysis.py --workdir + ``` + + This writes `jira/cves-parsed-for-analysis.json`: non-private tickets + unchanged; private tickets reduced to `key` / `url` / `is_private` / + `redacted` only. Use **only** that file for ticket context below. + +3. For each result where `affected` is true, `scan_incomplete` is true (the + scan was signal-killed, typically OOM - see below), or `scan_exit_code` is + non-zero with `finding_count` > 0, launch **foreground** Agents in a single + message (one per affected/incomplete target): + + ```text + Agent: subagent_type=generalPurpose, prompt="Analyze govulncheck output for CVE actionability. + Target: + Read the result entry in /scans/govulncheck-results.json. + Read related tickets ONLY from /jira/cves-parsed-for-analysis.json + (already redacted). Do NOT read jira/cves-parsed.json or any other raw Jira + export. Skip tickets with is_private/redacted true for summary/CVE context; + use only non-private ticket fields plus the govulncheck result. + + If scan_incomplete is true, the scan container was killed (typically OOM, + scan_exit_code 137) before govulncheck finished - do NOT interpret the + empty/partial findings as evidence of anything. Verdict must be + "inconclusive", with the recommended action being to re-run + run_govulncheck_podman.sh/run_govulncheck_jobs.sh with a higher --memory. + + Otherwise decide: affected_and_actionable | affected_but_transitive | false_positive | inconclusive + Explain using matched findings and non-private ticket context. + + Save a short analysis to /scans/analysis-.txt including: + - verdict + - evidence (module/path/CVE) + - recommended action (bump dep, vendor fix, not applicable) + + Reply DONE only." + ``` + +4. Do NOT use LLM for tickets already marked `not_affected` with exit code 0. + +### Step 4: Finalize — Generate Reports + +**IMPORTANT**: Run finalize when this run has usable ticket/grouping inputs, +including `--skip-scan` and **explicitly partial** collections. Do **not** run +finalize after a hard stop in Step 2 (scan/collect command failure, missing +results file, or malformed JSON) — that would present stale or incomplete scan +data as a finished investigation. When finalize does run on partial results, +state clearly in Step 5 that coverage is incomplete. + +```text +bash plugins/edge-cve/scripts/cve-investigator.sh finalize --workdir +``` + +This writes both `report-cve-investigation.md` (team notification markdown, +covering every ticket) and `report-cve-investigation.html` (browsable, +filterable, grouped by component → version, with govulncheck status per +ticket). The HTML is deliberately scoped to components listed in +`config/component-repos.json` - the Black CVE filter spans hundreds of +components across the whole org, so anything not in that config is dropped +before rendering (count reported on stdout as `dropped_unmapped_components`). +Tickets whose Jira +Security Level or labels indicate they're private/restricted (see +`lib.cve_extract.is_private_ticket`) are rendered in the HTML with **only** a +link back to the Jira ticket - no CVE ID, summary, or scan findings, since +any of those could leak details of an embargoed vulnerability. Do not +work around this redaction (e.g. by reading the raw JSON to describe a +private ticket's contents in chat) unless the user explicitly asks you to +after being made aware it's marked private. + +### Step 5: Report Completion + +Display: + +1. Path to `report-cve-investigation.md` and `report-cve-investigation.html` +2. Count of affected vs not-affected tickets (and how many were redacted as private) +3. Path to `remediation-prompts.md` for actionable items +4. Link to Jira filter for manual verification + +## Ad-hoc single-repo check + +When `--check-repo URL --ref REF` is given, skip Steps 1-5 entirely and run +this instead: + +```text +bash plugins/edge-cve/scripts/cve-investigator.sh check-repo \ + --repo-url --ref --no-prune \ + [--cve ...] [--ticket ...] \ + [--jira-url ] [--summary ] [--component ] \ + [--workdir ] [--memory ] [--cpus ] [--timeout ] +``` + +Pass `--no-prune` unless the user explicitly approved host podman cleanup +(same rule as `scan-local` above). Only use `--prune` after that confirmation. + +This deterministically (no LLM call needed for the base result): + +1. Clones `` at `` and runs `govulncheck` in a disposable podman + container (same hardening as `scan-local`: named container, wall-clock + timeout, cleanup on exit, shared module/toolchain cache). +2. If `--cve` is given, checks specifically for those CVE(s); if omitted, + reports any known vulnerability govulncheck finds at that ref. +3. Computes a `verdict` (`affected` | `not_affected` | `inconclusive` - the + last one for OOM-killed/incomplete scans) and prints a JSON object with a + `suggested_agent_prompt` field: a ready-to-use remediation prompt built + from a fixed template and the scan's own matched findings when + `action_required` is true, or `null` when the repo isn't affected. + +Display the printed JSON (particularly `verdict` and `suggested_agent_prompt`) +directly to the user. If `verdict` is `affected` and the user wants it fixed +now, offer to launch a **foreground** Agent with the `suggested_agent_prompt` +text as its prompt - it's stated in the base command's own words, so review it +first rather than editorializing on top of it. + +## Examples + +### Full investigation + +```bash +/edge-cve:investigate +``` + +### Dry-run (no cluster changes) + +```bash +/edge-cve:investigate --dry-run +``` + +### Re-generate report from existing scans + +```bash +/edge-cve:investigate --skip-scan +``` + +### Ad-hoc check: is this repo/branch affected by a specific CVE + +```bash +/edge-cve:investigate --check-repo https://github.com/openshift/lvm-operator --ref release-4.18 --cve CVE-2024-99999 +``` + +### Ad-hoc check: any known vulnerability at this ref (no specific CVE) + +```bash +/edge-cve:investigate --check-repo https://github.com/openshift/microshift --ref release-4.19 +``` + +## Related Skills + +- **microshift-dev:golang-cve-analyzer** — Single-ticket golang/Brew CVE check +- **microshift-ci:doctor** — Similar prepare/analyze/finalize orchestration pattern + +## Notes + +- Extend `plugins/edge-cve/config/component-repos.json` when new components need default repo mapping. +- Scan refs come from each ticket's Jira versions via `version_ref_template` + (e.g. `4.18` → `release-4.18`). Do not add `main`/`master` to + `version_ref_fallbacks` for versioned components - tip-of-tree captures far + more than the ticket is asking about. Tickets with a repo but no resolvable + release ref are skipped (`no_git_ref_resolved`), not pointed at `main`. +- Non-Go components are listed in `scan-targets.json` under `skipped_targets` and are not scanned by govulncheck. +- The investigation is read-only against Jira; it does not transition or comment on tickets.