Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/actions/security-container-scan-aggregate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Security Container Scan Aggregate Action

A companion action to [`security-container-scan`](../security-container-scan/) that consolidates per-image Grype reports from an entire workflow run into a single summary table.

Follows the same pattern as `publish-test-results`: the individual scan jobs do not post their own summaries, and this aggregator runs once after all scans complete to produce:

- A single consolidated table in `$GITHUB_STEP_SUMMARY` on the aggregator job page
- (Optional) a sticky PR comment on `pull-request/NNN` branches created by copy-pr-bot

## When to use

- Your workflow scans **multiple images in one run** (typically via matrix or a fan-out of reusable workflow calls) and you want one summary per PR instead of one per image.
- You are OK with the per-image scan jobs *also* writing their own summary (the default of `security-container-scan`). If you do not want that, pass `write-summary: 'false'` to each `security-container-scan` invocation.

## Prerequisites

- Each upstream `security-container-scan` job must upload a per-image artifact whose name matches the pattern passed via `artifact-pattern` (default `grype-*`). The built-in convention is `grype-<service>-<run_id>-<run_attempt>`; the aggregator strips those trailing numeric segments to display a clean service name.
- Each artifact must contain `grype-results.json` at its root (this is what `security-container-scan` uploads by default).
- If `post-pr-comment: true` is used, the **caller job** must grant `pull-requests: write` permission. A default `GITHUB_TOKEN` is used unless an override is supplied via `github-token`.

## PR comment scope

This action only posts PR comments on push events that target the `pull-request/NNN` branch pattern produced by copy-pr-bot. The native `pull_request` event is intentionally **not** supported, matching the policy that all PR CI on these repos must be driven by copy-pr-bot.

On any other ref (`main`, tags, `feat/**`, `fix/**`, etc.) the PR-comment step is a no-op.

## Usage

```yaml
jobs:
build-and-scan:
# ... your per-service build job with security-container-scan ...

container-scan-summary:
name: Container Scan Summary
needs: build-and-scan
if: always() # still summarise if some services failed
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # required for post-pr-comment: true
steps:
- uses: NVIDIA/dsx-github-actions/.github/actions/security-container-scan-aggregate@main
with:
post-pr-comment: 'true'
```

### Customising the artifact pattern

```yaml
- uses: NVIDIA/dsx-github-actions/.github/actions/security-container-scan-aggregate@main
with:
artifact-pattern: 'scan-report-*' # if your callers name artifacts differently
post-pr-comment: 'true'
```

## Inputs

| Input | Description | Required | Default |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ---------------- |
| `artifact-pattern` | Glob pattern forwarded to `actions/download-artifact` to pick which artifacts to aggregate. | No | `grype-*` |
| `download-path` | Directory the artifacts are downloaded into. | No | `.grype-aggregate` |
| `post-pr-comment` | Post/update a sticky PR comment on copy-pr-bot `pull-request/NNN` branches. No-op on other refs. | No | `false` |
| `github-token` | Token for listing, creating, and patching PR comments. | No | `${{ github.token }}` |

## Outputs

| Output | Description |
| -------------- | ------------------------------------------------------------------------------- |
| `summary-path` | Absolute path to the rendered markdown summary file on the runner. |
| `pr-number` | PR number extracted from a `pull-request/NNN` ref; empty string on other refs. |

## Output format

Rendered table columns:

| Service | Total | Critical | High | Medium | Low | Other |

`Other` merges the `Negligible` and `Unknown` severity buckets so the main table stays narrow; detail lives in the per-service artifacts.

By design, the summary contains **only severity counts** — no CVE IDs, packages, or versions — to avoid turning a public workflow run into an attacker roadmap. Drill-down into the per-service `grype-*` artifact (JSON + SARIF) is collaborator-only.

## Sticky comment marker

The PR comment is marked with the HTML comment `<!-- grype-scan-summary -->` so successive runs on the same PR update the same comment in place rather than appending new ones. Do not include this marker in other bot comments on the same PR.
117 changes: 117 additions & 0 deletions .github/actions/security-container-scan-aggregate/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

name: security-container-scan-aggregate
description: >-
Aggregate Grype JSON reports produced by multiple `security-container-scan`
jobs in the same workflow run into a single consolidated summary, written
to `$GITHUB_STEP_SUMMARY` and (optionally) posted as a sticky PR comment
on `pull-request/NNN` branches created by copy-pr-bot.

inputs:
artifact-pattern:
description: 'Glob pattern for Grype artifact names to aggregate (passed to actions/download-artifact).'
required: false
default: 'grype-*'
download-path:
description: 'Directory to download Grype artifacts into.'
required: false
default: '.grype-aggregate'
post-pr-comment:
description: 'Post/update a sticky PR comment with the consolidated table. Only acts on copy-pr-bot `pull-request/NNN` branches; a no-op on `main`, tags, and other branches. Requires `pull-requests: write` on the caller job.'
required: false
default: 'false'
github-token:
description: 'GitHub token used to read/create/patch PR comments.'
required: false
default: ${{ github.token }}

outputs:
summary-path:
description: 'Absolute path to the rendered markdown summary file.'
value: ${{ steps.render.outputs.summary-path }}
pr-number:
description: 'PR number detected from a `pull-request/NNN` ref; empty otherwise.'
value: ${{ steps.pr-ctx.outputs.pr }}

runs:
using: composite
steps:
- name: Download Grype artifacts
uses: actions/download-artifact@v4
with:
pattern: ${{ inputs.artifact-pattern }}
path: ${{ inputs.download-path }}

- name: Render consolidated summary
id: render
shell: bash
env:
DOWNLOAD_PATH: ${{ inputs.download-path }}
run: |
set -euo pipefail
out="${RUNNER_TEMP}/grype-aggregate-summary.md"
python3 "$GITHUB_ACTION_PATH/aggregate.py" --dir "$DOWNLOAD_PATH" > "$out"
cat "$out" >> "$GITHUB_STEP_SUMMARY"
echo "summary-path=$out" >> "$GITHUB_OUTPUT"

- name: Detect copy-pr-bot PR context
id: pr-ctx
shell: bash
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
pr=""
# Only copy-pr-bot's `pull-request/<N>` push pattern is supported here —
# native `pull_request` events are deliberately out of scope, matching
# the policy that all PR CI on these repos must be driven by copy-pr-bot.
if [[ "$REF_NAME" =~ ^pull-request/([0-9]+)$ ]]; then
pr="${BASH_REMATCH[1]}"
fi
echo "pr=${pr}" >> "$GITHUB_OUTPUT"

- name: Post or update sticky PR comment
if: inputs.post-pr-comment == 'true' && steps.pr-ctx.outputs.pr != ''
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
PR_NUMBER: ${{ steps.pr-ctx.outputs.pr }}
SUMMARY_PATH: ${{ steps.render.outputs.summary-path }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
MARKER="<!-- grype-scan-summary -->"

body_file="${RUNNER_TEMP}/grype-comment-body.md"
{
echo "${MARKER}"
echo
cat "$SUMMARY_PATH"
} > "$body_file"

existing_id=$(gh api "/repos/${REPO}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
2>/dev/null | head -1 || true)

if [ -n "$existing_id" ]; then
echo "Updating existing grype-scan-summary comment ${existing_id} on PR #${PR_NUMBER}"
gh api "/repos/${REPO}/issues/comments/${existing_id}" \
--method PATCH \
--field body=@"$body_file" >/dev/null
else
echo "Creating new grype-scan-summary comment on PR #${PR_NUMBER}"
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body_file"
fi
136 changes: 136 additions & 0 deletions .github/actions/security-container-scan-aggregate/aggregate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Aggregate multiple Grype JSON reports into a consolidated markdown table.

Expects the input directory to contain one subdirectory per downloaded
artifact (as produced by `actions/download-artifact@v4` with a pattern),
each holding a `grype-results.json` file. Subdirectory name is assumed
to follow `grype-<service>[-<run-id>-<run-attempt>]`; that convention
is what `security-container-scan` produces and what callers should use
for artifact names.
"""

import argparse
import glob
import json
import os
import re
import sys
from collections import Counter


def _load_matches(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as exc:
print(f"WARN: failed to parse {path}: {exc}", file=sys.stderr)
return None
matches = data.get("matches", [])
return matches if isinstance(matches, list) else []


def _service_from_dirname(dirname):
name = os.path.basename(dirname)
name = re.sub(r"^grype-", "", name, count=1)
# Strip trailing `-<run_id>-<run_attempt>` that security-container-scan
# callers use to keep artifact names unique across reruns.
name = re.sub(r"-\d+-\d+$", "", name)
return name or os.path.basename(dirname)


def _counts(matches):
counter = Counter()
for m in matches:
vuln = m.get("vulnerability") or {}
sev = vuln.get("severity") or "Unknown"
if not isinstance(sev, str):
sev = "Unknown"
counter[sev] += 1
return counter


def _render(rows):
lines = ["## 🔍 Container Scan Summary", ""]
if not rows:
lines.append("_No Grype artifacts were found to aggregate._")
lines.append("")
return "\n".join(lines)

lines.append("| Service | Total | Critical | High | Medium | Low | Other |")
lines.append("|---|---:|---:|---:|---:|---:|---:|")
totals = [0] * 6
for name, total, crit, high, med, low, other in rows:
lines.append(
f"| {name} | {total} | {crit} | {high} | {med} | {low} | {other} |"
)
for i, v in enumerate((total, crit, high, med, low, other)):
totals[i] += v

lines.append(
"| **TOTAL** | **{0}** | **{1}** | **{2}** | **{3}** | **{4}** | **{5}** |".format(
*totals
)
)
lines.append("")
lines.append(
"_Per-CVE detail lives in the per-service `grype-*` artifacts "
"(JSON + SARIF). Severity counts only — no CVE IDs published here._"
)
lines.append("")
return "\n".join(lines)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dir",
required=True,
help="Root directory containing `grype-*/grype-results.json` subtrees.",
)
args = parser.parse_args()

pattern = os.path.join(args.dir, "*", "grype-results.json")
report_paths = sorted(glob.glob(pattern))

rows = []
for path in report_paths:
matches = _load_matches(path)
if matches is None:
continue
name = _service_from_dirname(os.path.dirname(path))
counter = _counts(matches)
rows.append(
(
name,
len(matches),
counter.get("Critical", 0),
counter.get("High", 0),
counter.get("Medium", 0),
counter.get("Low", 0),
counter.get("Negligible", 0) + counter.get("Unknown", 0),
)
)

rows.sort(key=lambda r: r[0])
sys.stdout.write(_render(rows))
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading