|
| 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 |
0 commit comments