--- name: report-findings description: Generate triage reports and post findings to Jira and Slack --- ## When to Use Use this skill when Phase 3 of the `node-cve:triage` command needs to generate the triage report, post comments to Jira tracker issues, and send Slack notifications. ## Prerequisites - `jira` CLI (for `--notify-jira`) - `curl` (for `--notify-slack`) - Environment variables: `JIRA_API_TOKEN` (for Jira) - For Slack: either `SLACK_API_TOKEN` + `SLACK_CHANNEL` (preferred, enables threading) or `SLACK_WEBHOOK` (simpler, no threading) ## Node Team Component Safeguard (CRITICAL) **This skill may ONLY post comments to trackers whose component is a Node team component.** Many CVEs (especially Go stdlib and vendored-dependency vulnerabilities) span 50-200+ tracker issues across dozens of OpenShift teams (HyperShift, Storage, Networking, Installer, Monitoring, Cloud providers, etc.). Node-specific reachability analysis is meaningless — and confusing — on another team's tracker. The canonical Node team component list lives in the [node-team shared components reference](../../../node-team/skills/node/references/shared/components.md) ("Jira Components (OCPBUGS)" section, plus Driver Toolkit and Machine Config Operator). **Do not hardcode or duplicate that list here or anywhere else** — always read it from the shared reference so it stays in sync as Node team components change. Note: the shared reference also documents `pscomponent:` label mappings, but those are for Phase 1 CVE discovery and Phase 2 repo mapping only — the posting-time validation in Step 2 below checks the tracker's COMPONENT field exclusively (see Step 2 for why). **Never do this (this is exactly what caused the 2026-07-15 incident where analysis was posted to ~200 non-Node trackers):** - Do NOT run an ad-hoc/one-off Jira search scoped only by CVE ID (e.g. `summary ~ "CVE-XXXX-XXXXX"`) to find trackers to comment on. A CVE ID alone is not enough to scope a search — it will return trackers for every team affected by that CVE. - Do NOT write a separate "batch posting script" that re-queries Jira outside of the `tracker_keys` produced by the `query-open-cves` skill in Phase 1. - Do NOT assume that because Phase 1 already filtered by component, it is safe to skip validation here. Always re-validate at posting time (defense in depth) — Phase 1's `tracker_keys` may have been supplemented, cached from a stale run, or copy-pasted into a manual follow-up. **Only post to tracker keys that are:** 1. Present in the `tracker_keys` list of the CVE record produced by `query-open-cves` (Phase 1), AND 2. Re-validated against the Node team component list immediately before posting (see Step 2 below), AND 3. Re-validated against the active OCP version filter (see "OCP Version Safeguard" below and Step 2). ## OCP Version Safeguard **This skill may ONLY post comments to trackers whose OCP version matches the auto-detected latest version.** The OCP sustaining team owns triage and remediation for all versions except the latest in-development release. Posting Node-team reachability analysis to older-version trackers creates noise for the sustaining team and duplicates their work. The `query-open-cves` skill (Phase 1) auto-detects the latest OCP version and filters trackers to it, but this skill re-validates at posting time as defense-in-depth — the same principle as the component safeguard above. The auto-detected version is passed through from Phase 1 via the `version_filter` field in the CVE query results. **At posting time, re-validate each tracker's OCP version** (extracted from the tracker summary via regex `\[openshift-([^\]]+)\]`) against `version_filter`. If the tracker's version does not match (after stripping `.z` suffixes from both sides for comparison), skip it and log the reason in the posting audit log. If you ever find yourself constructing a new JQL query or Jira search specifically to find trackers to comment on, STOP — that is the anti-pattern that caused this incident. Reuse the already-filtered tracker list from Phase 1 instead. ## Implementation Steps ### Step 1: Generate markdown report Write the report to `.work/node-cve/triage-YYYY-MM-DD/report.md`: ```markdown # Node CVE Triage Report - YYYY-MM-DD **Version scope:** OCP (auto-detected) ## Summary | Metric | Count | |--------|-------| | Total unique CVEs | N | | Reachable | N | | Present | N | | Unaffected | N | | Uncertain | N | ## Action Required List CVEs that are Reachable or Uncertain with unassigned owners. These need immediate attention. ## Detailed Findings ### CVE-XXXX-XXXXX: | Field | Value | |-------|-------| | Component | Node / CRI-O | | Repository | openshift/cri-o | | Overall classification | Reachable / Present but not exploitable / Present but not reachable / Unaffected / Uncertain | | Overall confidence | High / Medium / Low | | Assignee | | | OCP version | 5.0 | | Tracker issues | [OCPBUGS-XXXXX](https://redhat.atlassian.net/browse/OCPBUGS-XXXXX) | **Analysis result:** | Branch | OCP Version | Classification | Confidence | |--------|-------------|----------------|------------| | release-1.36 | 5.0 | Reachable | High | **Evidence:** **Recommended action:** --- (repeat for each CVE) ``` ### Step 2: Post Jira comments (if --notify-jira) **VALIDATION (MANDATORY, before posting anything):** For each unique CVE, take its `tracker_keys` list from Phase 1 (`query-open-cves`) and re-validate every tracker against both the component list and the version filter immediately before posting — do not trust cached or upstream filtering alone: ```bash # component_is_node_team() checks the tracker's COMPONENT field against the # canonical Node team component list from the shared components reference # (link above), NOT a hardcoded list. # version_matches_filter() checks the tracker's OCP version (from its summary) # against version_filter from Phase 1, after stripping .z suffixes from both. for tracker_key in $TRACKER_KEYS; do # Single API call per tracker to minimize rate-limiting risk output=$(jira issue view "$tracker_key" --plain --no-headers --columns COMPONENT,SUMMARY | tail -1) component=$(echo "$output" | awk -F'\t' '{print $1}') summary=$(echo "$output" | awk -F'\t' '{print $2}') ocp_version=$(echo "$summary" | grep -oP '\[openshift-\K[^\]]+') if ! component_is_node_team "$component"; then echo "⚠️ SKIPPING $tracker_key: component '$component' is not a Node team component" | tee -a "$SKIPPED_LOG" sleep 1 continue fi if ! version_matches_filter "$ocp_version" "$VERSION_FILTER"; then echo "⚠️ SKIPPING $tracker_key: OCP version '$ocp_version' does not match version filter '$VERSION_FILTER'" | tee -a "$SKIPPED_LOG" sleep 1 continue fi # Component and version validated — proceed with posting for $tracker_key sleep 1 done ``` **Component validation:** A component is considered a Node team component only if it matches an entry in the "Jira Components (OCPBUGS)" list (plus Driver Toolkit, Machine Config Operator) in the shared components reference. **Check the COMPONENT field only — do not treat a `pscomponent:` label as an alternative pass condition.** `pscomponent:` labels are used in Phase 1/Phase 2 for CVE discovery and repo mapping, not for determining tracker ownership; a non-Node tracker (e.g. component "Security") could incidentally carry a `pscomponent:cri-o` label, and accepting that as a pass would silently reintroduce the exact cross-team contamination this safeguard exists to prevent. **Version validation:** A tracker's OCP version is extracted from its summary using regex `\[openshift-([^\]]+)\]`. The version must match `version_filter` from Phase 1, after stripping `.z` suffixes from both sides for comparison (so tracker version `4.14.z` matches filter `4.14`, and `version_filter` itself is already stored in stripped `major.minor` form). If a tracker fails either check, **skip it and log the reason** — never post "just in case." Both checks must run even when `--component` was explicitly passed, and even if Phase 1 already filtered, since this is the last line of defense before an irreversible write. Rate limit: sleep 1 second between validation calls, same as the posting calls below, since a CVE with N trackers makes N validation calls before posting even starts. For each unique CVE, post a comment on every **validated** tracker issue. Each tracker receives the analysis result for the latest OCP version. Use Atlassian wiki markup (not Markdown): ```bash jira issue comment add OCPBUGS-XXXXX "$(cat <<'COMMENT' h3. Automated CVE Reachability Analysis ||Field||Value|| |CVE|CVE-XXXX-XXXXX| |Repository|[openshift/cri-o|https://github.com/openshift/cri-o]| |Branch|release-1.36| |OCP Version|5.0| |Classification|Reachable / Present but not exploitable / Present but not reachable / Unaffected / Uncertain| |Confidence|High / Medium / Low| h4. Evidence {noformat} {noformat} h4. Recommended Action ---- _AI-generated analysis by [node-cve:triage|https://github.com/openshift-eng/ai-helpers/tree/main/plugins/node-cve]. Always review prior to use._ COMMENT )" ``` Use the footer line exactly as shown. The "AI-generated" label and review notice are required by Red Hat's medium-risk AI agent controls (TR-01, HU-01). Do not append a date; the Jira comment timestamp already covers that. **Deduplication:** Before posting a comment, check for existing `node-cve:triage` comments on the issue: ```bash jira issue comment list OCPBUGS-XXXXX --plain --no-headers ``` Search the output for comments containing `[node-cve:triage|`. This pattern anchors on the Jira wiki-markup link syntax and matches both the current and legacy footer formats. If a prior comment exists: - If the classification or evidence has changed, edit the existing comment rather than adding a new one - If the result is unchanged, skip posting to avoid spam **Important:** - Rate limit: sleep 1 second between Jira API calls to avoid HTTP 429 throttling - Post to every **validated** tracker issue for a CVE - If commenting fails on a specific issue (e.g., permissions), log a warning and continue **POST-POSTING AUDIT (MANDATORY when --notify-jira is used):** Write an audit log to `.work/node-cve/triage-$(date +%Y-%m-%d)/posting-audit.log` summarizing what was posted and what was skipped, so cross-team or cross-version contamination is caught immediately instead of discovered days later: ```bash { echo "=== Node CVE Triage Posting Audit — $(date +%Y-%m-%d) ===" echo "Version filter: $VERSION_FILTER (auto-detected)" echo "CVEs processed: $CVE_COUNT" echo "Trackers commented: $POSTED_COUNT" echo "Trackers skipped (non-Node component): $SKIPPED_COMPONENT_COUNT" echo "Trackers skipped (version mismatch): $SKIPPED_VERSION_COUNT" echo "" echo "Skipped trackers (tracker, component/version, reason):" cat "$SKIPPED_LOG" } > ".work/node-cve/triage-$(date +%Y-%m-%d)/posting-audit.log" ``` If any trackers were skipped, print a visible warning in the command summary output (Phase 4) so the operator notices immediately, e.g. "⚠️ Skipped N trackers during posting (K non-Node-component, J wrong version) — see posting-audit.log". A non-zero skip count is expected and healthy — for component skips it means the cross-team safeguard is working as intended, and for version skips it means older-version trackers are being left to the sustaining team as required. ### Step 3: Send Slack notification (if --notify-slack) Two modes are supported depending on which credentials are available: **Mode A: Slack API (`$SLACK_API_TOKEN` + `$SLACK_CHANNEL`)** - enables threaded messages. **Step 3a: Post summary (main message)** ```bash RESPONSE=$(curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_API_TOKEN" \ -H "Content-Type: application/json" \ -d "$(cat <<'SLACK' { "channel": "$SLACK_CHANNEL", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Node CVE Triage (N CVEs analyzed)" } }, { "type": "section", "text": { "type": "mrkdwn", "text": ":red_circle: Reachable: ( unassigned)\n:large_yellow_circle: Present: ( unassigned)\n:large_green_circle: Unaffected: \n:grey_question: Uncertain: " } }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": ":robot_face: AI-generated by node-cve:triage. Always review prior to use." } ] } ] } SLACK )" ``` Build the JQL URLs by collecting all tracker keys per classification group. Use `key in (OCPBUGS-XXXXX, OCPBUGS-YYYYY, ...)` as the filter. For the unassigned link, add `AND (assignee is EMPTY OR assignee = "ocp-sustaining-blocked-trackers")` to also count placeholder assignees as unassigned. URL-encode the JQL query. Omit the "(M unassigned)" part when M is 0. Omit empty classification lines. On subsequent runs with cached results, change the header to "Node CVE Triage (N CVEs, M new)" or "Node CVE Triage (N CVEs, M new, K updated)". Extract `ts` from the JSON response (`jq -r '.ts'`) to use as `thread_ts` for the reply. **Step 3b: Post detailed findings (thread reply)** ```bash curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_API_TOKEN" \ -H "Content-Type: application/json" \ -d "$(cat <<'SLACK' { "channel": "$SLACK_CHANNEL", "thread_ts": "", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Reachable (action required):*\n• - . (CRI-O, high confidence, N trackers[, M unassigned])\n\n*Present (no action needed):*\n• - . (kubernetes, high confidence, 1 tracker)\n\n*Unaffected:*\n• ...\n\n*Uncertain:*\n• ..." } }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": ":robot_face: AI-generated by node-cve:triage. Always review prior to use." } ] } ] } SLACK )" ``` **Mode B: Webhook (`$SLACK_WEBHOOK`)** - simpler setup, no threading. Post a single message containing both summary and detailed findings: ```bash curl -s -X POST "$SLACK_WEBHOOK" \ -H "Content-Type: application/json" \ -d "$(cat <<'SLACK' { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Node CVE Triage (N CVEs analyzed)" } }, { "type": "section", "text": { "type": "mrkdwn", "text": ":red_circle: Reachable: ( unassigned)\n:large_yellow_circle: Present: \n:large_green_circle: Unaffected: " } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Reachable (action required):*\n• - . (CRI-O, high confidence, N trackers[, M unassigned])\n\n*Present (no action needed):*\n• ...\n\n*Unaffected:*\n• ..." } }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": ":robot_face: AI-generated by node-cve:triage. Always review prior to use." } ] } ] } SLACK )" ``` **Both modes:** Omit empty classification sections. If the total text exceeds the Slack character limit (3000 chars per text block), split across multiple blocks or truncate with "... and N more. See full report." If Slack returns a non-200 status, log a warning but do not fail the command. ### Step 4: Save structured data Write `cves.json` to `.work/node-cve/triage-YYYY-MM-DD/cves.json` containing the full analysis results in machine-readable format: ```json { "date": "YYYY-MM-DD", "version_filter": "5.0", "total_cves": 6, "cves": [ { "cve_id": "CVE-XXXX-XXXXX", "summary": "...", "components": ["Node / CRI-O"], "repo": "https://github.com/openshift/cri-o", "overall_classification": "REACHABLE", "overall_confidence": "HIGH", "assignee": "...", "tracker_keys": ["OCPBUGS-XXXXX"], "affected_versions": ["5.0"], "branch": "release-1.36", "ocp_version": "5.0", "evidence_summary": "...", "recommended_action": "..." } ] } ``` ### Step 5: Verify artifacts Ensure all generated files exist under `.work/node-cve/triage-YYYY-MM-DD/`: - `report.md` - full report - `cves.json` - structured CVE data (for programmatic consumption) - `posting-audit.log` - only when `--notify-jira` was used (see Step 2); do not expect this file otherwise - `--analysis.md` - per-CVE per-branch source code analysis (from Phase 2) ## Return Value ```json { "skill": "report-findings", "status": "success", "version_filter": "5.0", "report_path": ".work/node-cve/triage-2026-05-20/report.md", "jira_comments_posted": 6, "jira_comments_failed": 0, "jira_trackers_skipped_non_node_component": 0, "jira_trackers_skipped_version_mismatch": 0, "slack_notified": true, "artifacts": [ ".work/node-cve/triage-2026-05-20/report.md", ".work/node-cve/triage-2026-05-20/cves.json", ".work/node-cve/triage-2026-05-20/posting-audit.log" ] } ``` `posting-audit.log` is only produced when `--notify-jira` is used (see Step 2). When present, add it to the `artifacts` list; omit it entirely on runs without `--notify-jira` rather than reporting a file that was never written. ## Error Handling - Non-Node-team component detected on a tracker: skip that tracker and log it in the audit log (see Step 2). Never post to it. Do not fail the entire command — other trackers for the same CVE may still be valid Node team trackers. - OCP version mismatch detected on a tracker: skip that tracker and log it in the audit log (see Step 2). The sustaining team owns older versions. Do not fail the entire command — this is expected behavior when the auto-detected latest version filters out older trackers. - Jira comment failures: log and continue. Do not fail the entire command because one tracker issue is inaccessible. - Slack failure: log warning. Common causes: invalid token/webhook URL, missing channel permissions, network issues, payload too large (Slack limit: 3000 chars per text block). - If the Slack payload exceeds the character limit, truncate the CVE list and add "... and N more. See full report." - File write failures: these are critical. Exit with error if the work directory is not writable.