Skip to content

Commit d8d3042

Browse files
committed
chore: set up security container scan
1 parent 82d0f14 commit d8d3042

2 files changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
name: security-container-scan
2+
description: Generate SBOM (Syft) and scan a locally-built container image with Grype, uploading JSON/SARIF reports and writing a summary.
3+
4+
inputs:
5+
image:
6+
description: 'Local container image reference to scan (must exist on the runner), e.g. "localbuild/testimage:latest"'
7+
required: true
8+
fail-on:
9+
description: 'Minimum severity to fail (passed to grype as --fail-on). One of: negligible, low, medium, high, critical'
10+
required: false
11+
default: high
12+
fail-build:
13+
description: 'If true, fail this step when Grype finds vulnerabilities at/above fail-on (or when SBOM/scan prerequisites fail)'
14+
required: false
15+
default: 'false'
16+
grype-image:
17+
description: 'Grype container image to run'
18+
required: false
19+
default: anchore/grype:latest
20+
report-json:
21+
description: 'Filename for JSON report'
22+
required: false
23+
default: grype-results.json
24+
report-sarif:
25+
description: 'Filename for SARIF report'
26+
required: false
27+
default: grype-results.sarif
28+
upload-artifact:
29+
description: 'Upload reports as a workflow artifact'
30+
required: false
31+
default: 'true'
32+
artifact-name:
33+
description: 'Artifact name for uploaded reports'
34+
required: false
35+
default: grype-container-scan
36+
generate-sbom:
37+
description: 'Generate and upload SBOM using anchore/sbom-action'
38+
required: false
39+
default: 'true'
40+
sbom-format:
41+
description: 'SBOM format for sbom-action'
42+
required: false
43+
default: spdx-json
44+
sbom-artifact-name:
45+
description: 'Artifact name for the SBOM uploaded by sbom-action'
46+
required: false
47+
default: container.spdx.json
48+
write-summary:
49+
description: 'Write a human-friendly summary into $GITHUB_STEP_SUMMARY'
50+
required: false
51+
default: 'true'
52+
53+
outputs:
54+
status:
55+
description: 'ok | high_or_error | image_not_found | pull_failed | sbom_failed | grype_unknown'
56+
value: ${{ steps.final.outputs.status }}
57+
detail:
58+
description: 'Free-form detail string for status'
59+
value: ${{ steps.final.outputs.detail }}
60+
report_json:
61+
description: 'Path to JSON report (if generated)'
62+
value: ${{ steps.final.outputs.report_json }}
63+
report_sarif:
64+
description: 'Path to SARIF report (if generated)'
65+
value: ${{ steps.final.outputs.report_sarif }}
66+
67+
runs:
68+
using: composite
69+
steps:
70+
- name: Precheck local image exists
71+
id: precheck
72+
shell: bash
73+
run: |
74+
set +e
75+
IMAGE="${{ inputs.image }}"
76+
docker image inspect "${IMAGE}" >/dev/null 2>&1
77+
rc=$?
78+
if [ $rc -eq 0 ]; then
79+
echo "image_exists=true" >> "$GITHUB_OUTPUT"
80+
else
81+
echo "image_exists=false" >> "$GITHUB_OUTPUT"
82+
fi
83+
exit 0
84+
85+
- name: Generate SBOM (Syft via sbom-action)
86+
id: sbom
87+
if: ${{ inputs.generate-sbom == 'true' && steps.precheck.outputs.image_exists == 'true' }}
88+
continue-on-error: true
89+
uses: anchore/sbom-action@v0
90+
with:
91+
image: ${{ inputs.image }}
92+
format: ${{ inputs.sbom-format }}
93+
artifact-name: ${{ inputs.sbom-artifact-name }}
94+
95+
- name: Run Grype scan (JSON + SARIF)
96+
id: grype
97+
if: ${{ steps.precheck.outputs.image_exists == 'true' }}
98+
shell: bash
99+
run: |
100+
set +e
101+
IMAGE="${{ inputs.image }}"
102+
REPORT_JSON="${{ inputs.report-json }}"
103+
REPORT_SARIF="${{ inputs.report-sarif }}"
104+
FAIL_ON="${{ inputs.fail-on }}"
105+
GRYPE_IMAGE="${{ inputs.grype-image }}"
106+
107+
echo "Pulling Grype scanner image..."
108+
docker pull "${GRYPE_IMAGE}" >/dev/null 2>&1
109+
pull_rc=$?
110+
if [ $pull_rc -ne 0 ]; then
111+
echo "status=pull_failed" >> "$GITHUB_OUTPUT"
112+
echo "exit_code=3" >> "$GITHUB_OUTPUT"
113+
exit 0
114+
fi
115+
116+
echo "Scanning image: ${IMAGE}"
117+
docker run --rm \
118+
-v /var/run/docker.sock:/var/run/docker.sock \
119+
-v "$HOME/.cache/grype:/root/.cache/grype" \
120+
"${GRYPE_IMAGE}" \
121+
"${IMAGE}" --fail-on "${FAIL_ON}" -o json > "${REPORT_JSON}"
122+
scan_rc=$?
123+
124+
docker run --rm \
125+
-v /var/run/docker.sock:/var/run/docker.sock \
126+
-v "$HOME/.cache/grype:/root/.cache/grype" \
127+
"${GRYPE_IMAGE}" \
128+
"${IMAGE}" -o sarif > "${REPORT_SARIF}" || true
129+
130+
echo "exit_code=${scan_rc}" >> "$GITHUB_OUTPUT"
131+
if [ $scan_rc -eq 0 ]; then
132+
echo "status=ok" >> "$GITHUB_OUTPUT"
133+
else
134+
echo "status=high_or_error" >> "$GITHUB_OUTPUT"
135+
fi
136+
exit 0
137+
138+
- name: Upload Grype reports
139+
if: ${{ inputs.upload-artifact == 'true' && steps.precheck.outputs.image_exists == 'true' }}
140+
uses: actions/upload-artifact@v4
141+
with:
142+
name: ${{ inputs.artifact-name }}
143+
path: |
144+
${{ inputs.report-json }}
145+
${{ inputs.report-sarif }}
146+
147+
- name: Write scan summary
148+
if: ${{ inputs.write-summary == 'true' }}
149+
shell: bash
150+
run: |
151+
set -euo pipefail
152+
echo "### 🔍 Container Scan (SBOM + Grype)" >> "$GITHUB_STEP_SUMMARY"
153+
echo "" >> "$GITHUB_STEP_SUMMARY"
154+
echo "- Image: \`${{ inputs.image }}\`" >> "$GITHUB_STEP_SUMMARY"
155+
echo "- Reports artifact: \`${{ inputs.artifact-name }}\` (sarif + json)" >> "$GITHUB_STEP_SUMMARY"
156+
157+
if [ -f "${{ inputs.report-json }}" ]; then
158+
python3 "$GITHUB_ACTION_PATH/grype_summary.py" --json "${{ inputs.report-json }}" --max-top 10 >> "$GITHUB_STEP_SUMMARY" || true
159+
fi
160+
161+
- name: Finalize status/outputs
162+
id: final
163+
if: always()
164+
shell: bash
165+
run: |
166+
set -euo pipefail
167+
168+
IMAGE_EXISTS="${{ steps.precheck.outputs.image_exists }}"
169+
SBOM_OUTCOME="${{ steps.sbom.outcome }}"
170+
GRYPE_STATUS="${{ steps.grype.outputs.status }}"
171+
GRYPE_EXIT="${{ steps.grype.outputs.exit_code }}"
172+
173+
status="ok"
174+
detail="ok"
175+
176+
if [ "${IMAGE_EXISTS}" != "true" ]; then
177+
status="image_not_found"
178+
detail="local docker image not found"
179+
elif [ "${{ inputs.generate-sbom }}" = "true" ] && [ "${SBOM_OUTCOME}" != "success" ]; then
180+
status="sbom_failed"
181+
detail="sbom-action failed"
182+
elif [ -z "${GRYPE_STATUS}" ]; then
183+
status="grype_unknown"
184+
detail="grype step did not produce status"
185+
elif [ "${GRYPE_STATUS}" = "ok" ]; then
186+
status="ok"
187+
detail="no vulnerabilities at/above fail-on=${{ inputs.fail-on }}"
188+
elif [ "${GRYPE_STATUS}" = "high_or_error" ]; then
189+
status="high_or_error"
190+
detail="grype exit_code=${GRYPE_EXIT} (fail-on=${{ inputs.fail-on }})"
191+
else
192+
status="${GRYPE_STATUS}"
193+
detail="grype exit_code=${GRYPE_EXIT}"
194+
fi
195+
196+
echo "status=${status}" >> "$GITHUB_OUTPUT"
197+
echo "detail=${detail}" >> "$GITHUB_OUTPUT"
198+
echo "report_json=${{ inputs.report-json }}" >> "$GITHUB_OUTPUT"
199+
echo "report_sarif=${{ inputs.report-sarif }}" >> "$GITHUB_OUTPUT"
200+
201+
if [ "${{ inputs.fail-build }}" = "true" ] && [ "${status}" != "ok" ]; then
202+
echo "Failing build due to status=${status}: ${detail}" 1>&2
203+
exit 1
204+
fi
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
from collections import Counter
5+
from typing import Any, Dict, List
6+
7+
8+
def _safe_load_json(path: str) -> Dict[str, Any]:
9+
with open(path, "r", encoding="utf-8") as f:
10+
return json.load(f)
11+
12+
13+
def _matches(data: Dict[str, Any]) -> List[Dict[str, Any]]:
14+
m = data.get("matches", [])
15+
return m if isinstance(m, list) else []
16+
17+
18+
def main() -> int:
19+
p = argparse.ArgumentParser(
20+
description="Render a Grype JSON report into Markdown summary."
21+
)
22+
p.add_argument(
23+
"--json",
24+
required=True,
25+
help="Path to grype JSON report (from `grype -o json`).",
26+
)
27+
p.add_argument(
28+
"--max-top",
29+
type=int,
30+
default=10,
31+
help="Max number of Critical/High rows to print.",
32+
)
33+
args = p.parse_args()
34+
35+
try:
36+
data = _safe_load_json(args.json)
37+
except Exception as e:
38+
print("")
39+
print("#### Grype Summary")
40+
print(f"Unable to parse `{args.json}`: {e}")
41+
return 0
42+
43+
matches = _matches(data)
44+
sev_list: List[str] = []
45+
rows: List[Dict[str, str]] = []
46+
47+
for m in matches:
48+
vuln = m.get("vulnerability") or {}
49+
artifact = m.get("artifact") or {}
50+
if not isinstance(vuln, dict) or not isinstance(artifact, dict):
51+
continue
52+
53+
sev = vuln.get("severity") or "Unknown"
54+
if not isinstance(sev, str):
55+
sev = "Unknown"
56+
sev_list.append(sev)
57+
58+
rows.append(
59+
{
60+
"severity": sev,
61+
"id": str(vuln.get("id") or ""),
62+
"pkg": str(artifact.get("name") or ""),
63+
"ver": str(artifact.get("version") or ""),
64+
}
65+
)
66+
67+
cnt = Counter(sev_list)
68+
69+
print("")
70+
print("#### Grype Summary")
71+
print(f"- Total matches: **{len(matches)}**")
72+
print(
73+
(
74+
"- Critical: **{c}**, High: **{h}**, Medium: **{m}**, "
75+
"Low: **{l}**"
76+
).format(
77+
c=cnt.get("Critical", 0),
78+
h=cnt.get("High", 0),
79+
m=cnt.get("Medium", 0),
80+
l=cnt.get("Low", 0),
81+
)
82+
)
83+
84+
top = [r for r in rows if r["severity"] in ("Critical", "High")]
85+
top = top[: max(args.max_top, 0)]
86+
if top:
87+
print("")
88+
print(f"#### Top Critical/High (first {len(top)})")
89+
print("")
90+
print("| Severity | Vulnerability | Package | Version |")
91+
print("|---|---|---|---|")
92+
for r in top:
93+
print(f"| {r['severity']} | {r['id']} | {r['pkg']} | {r['ver']} |")
94+
95+
return 0
96+
97+
98+
if __name__ == "__main__":
99+
raise SystemExit(main())

0 commit comments

Comments
 (0)