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
177 changes: 177 additions & 0 deletions .github/workflows/sbom-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
name: Reusable - SBOM Publish

# Ship a CycloneDX SBOM to the OneLiteFeather Dependency-Track instance, so the
# dependency inventory of a release keeps getting matched against new CVEs long
# after the release itself ran.
#
# The SBOM comes from one of two places:
# - an artifact uploaded by an upstream job in the same run (`artifact-name`),
# for example the CycloneDX Gradle plugin's build/reports/cyclonedx/bom.xml.
# Prefer this when the project already produces one: a build-tool generated
# SBOM resolves the dependency graph better than any external scanner can.
# - Trivy, which generates one from the checked-out repository when no
# artifact is given. Zero setup per repo, and it works for projects that
# have no SBOM generator of their own.
#
# Deliberately a separate job from whatever produced the artifact: Dependency-
# Track being unreachable, or rejecting the API key, must never take down the
# release that produced it.

on:
workflow_call:
inputs:
project-name:
description: "Dependency-Track project name, e.g. 'AntiRedstoneClock-Remastered'"
required: true
type: string
project-version:
description: "Version to record in Dependency-Track, e.g. '2.9.0' (without a leading 'v')"
required: true
type: string
artifact-name:
description: |
Optional name of an artifact (uploaded by an upstream job in the same
run) that contains the SBOM. Leave empty to have Trivy generate one
from the checked-out repository instead.
required: false
type: string
default: ""
sbom-path:
description: "Path of the SBOM inside the downloaded artifact. Ignored when Trivy generates the SBOM."
required: false
type: string
default: "bom.xml"
scan-ref:
description: "Path Trivy scans when it generates the SBOM itself."
required: false
type: string
default: "."
parent-uuid:
description: "Optional Dependency-Track parent project UUID to nest this project under."
required: false
type: string
default: ""
project-tags:
description: "Comma-separated Dependency-Track project tags, e.g. 'bukkit,paper,plugin'"
required: false
type: string
default: ""
autocreate:
description: |
Create the project/version in Dependency-Track when it does not exist
yet. This needs the API key's team to hold PROJECT_CREATION_UPLOAD in
addition to BOM_UPLOAD - without it the server answers 403 on the
first upload of every new version.
required: false
type: boolean
default: true
is-latest:
description: "Mark this version as the latest one in Dependency-Track."
required: false
type: boolean
default: false
runs-on:
description: "Runner image"
required: false
type: string
default: "ubuntu-latest"
secrets:
DEPENDENCYTRACK_HOSTNAME:
required: true
DEPENDENCYTRACK_APIKEY:
required: true

concurrency:
# Matches gradle-publish.yml: a durable side effect, never cancelled mid-flight.
group: sbom-publish-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
publish:
name: Publish SBOM
runs-on: ${{ inputs.runs-on }}
permissions:
contents: read
steps:
# Only needed for the Trivy path - in artifact mode there is nothing to
# scan and checking out would just cost time.
- name: Checkout
if: ${{ inputs.artifact-name == '' }}
uses: actions/checkout@v6

- name: Prepare SBOM directory
shell: bash
run: mkdir -p sbom

- name: Download SBOM artifact
if: ${{ inputs.artifact-name != '' }}
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: sbom

- name: Generate SBOM with Trivy
if: ${{ inputs.artifact-name == '' }}
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: ${{ inputs.scan-ref }}
format: cyclonedx
output: sbom/bom.json
# An SBOM is an inventory, not a finding list. Never fail here -
# security-scan.yml is what gates on vulnerabilities.
exit-code: '0'

- name: Resolve SBOM file
id: sbom
shell: bash
env:
ARTIFACT_NAME: ${{ inputs.artifact-name }}
SBOM_PATH: ${{ inputs.sbom-path }}
PROJECT_NAME: ${{ inputs.project-name }}
PROJECT_VERSION: ${{ inputs.project-version }}
run: |
if [ -n "$ARTIFACT_NAME" ]; then
file="sbom/$SBOM_PATH"
else
file="sbom/bom.json"
fi
if [ ! -s "$file" ]; then
echo "::error::No SBOM found at '$file'. When passing artifact-name, sbom-path must match the file's path inside that artifact."
echo "Contents of the sbom directory:"
ls -R sbom || true
exit 1
fi
echo "file=$file" >> "$GITHUB_OUTPUT"
{
echo "### SBOM"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Project | \`$PROJECT_NAME\` |"
echo "| Version | \`$PROJECT_VERSION\` |"
echo "| Source | ${ARTIFACT_NAME:+artifact \`$ARTIFACT_NAME\`}${ARTIFACT_NAME:-Trivy filesystem scan} |"
echo "| Size | $(wc -c < "$file") bytes |"
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload to Dependency-Track
uses: DependencyTrack/gh-upload-sbom@v4
with:
serverhostname: ${{ secrets.DEPENDENCYTRACK_HOSTNAME }}
apikey: ${{ secrets.DEPENDENCYTRACK_APIKEY }}
projectname: ${{ inputs.project-name }}
projectversion: ${{ inputs.project-version }}
projecttags: ${{ inputs.project-tags }}
parent: ${{ inputs.parent-uuid }}
bomfilename: ${{ steps.sbom.outputs.file }}
autocreate: ${{ inputs.autocreate }}
isLatest: ${{ inputs.is-latest }}

- name: Upload SBOM artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: sbom-${{ inputs.project-name }}-${{ inputs.project-version }}
path: sbom/**
if-no-files-found: ignore
retention-days: 90
209 changes: 209 additions & 0 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
name: Reusable - Security Scan

# Trivy scan whose findings land in GitHub code scanning instead of being
# buried in a log nobody reads.
#
# Report-only by default on purpose: onboarding a repo onto this should make
# its vulnerabilities visible, not turn its CI red on day one. Flip
# `fail-on-findings` once a repo is clean enough to be kept that way.
#
# Complements sbom-publish.yml rather than replacing it. This one is a
# point-in-time gate on the code as it is now; Dependency-Track keeps matching
# the shipped inventory against CVEs published later.
#
# The calling job must grant:
# security-events: write # to upload the SARIF report (skip when upload-sarif is false)
# contents: read
#
# Note for private repositories: code scanning SARIF upload needs GitHub
# Advanced Security. On plans without it, set `upload-sarif: false` and rely on
# the job summary and (optionally) `fail-on-findings`.

on:
workflow_call:
inputs:
scan-type:
description: "Trivy scan type: 'fs', 'rootfs' or 'image'"
required: false
type: string
default: "fs"
scan-ref:
description: "What to scan - a path for 'fs'/'rootfs', an image reference for 'image'. Ignored when artifact-name is set."
required: false
type: string
default: "."
ref:
description: "Git ref to check out before scanning. Defaults to whatever triggered the caller. Pin this to the released tag when gating a release."
required: false
type: string
default: ""
artifact-name:
description: |
Optional artifact (uploaded by an upstream job in the same run) to
download and scan instead of the repository. This is how you gate a
release on the artifact that actually ships.

Use `scan-type: rootfs` with it. For a shaded/fat JAR, `fs` reports
nothing at all while `rootfs` finds the bundled libraries - measured
on AntiRedstoneClock-Remastered, where `fs` saw 0 packages and
`rootfs` saw 12 plus a HIGH finding.
required: false
type: string
default: ""
artifact-path:
description: "Directory the artifact is downloaded into. Becomes the scan target when artifact-name is set."
required: false
type: string
default: "scan-target"
severity:
description: "Comma-separated severities to report, e.g. 'CRITICAL,HIGH'"
required: false
type: string
default: "CRITICAL,HIGH"
scanners:
description: "Comma-separated Trivy scanners, e.g. 'vuln,secret,misconfig'"
required: false
type: string
default: "vuln,secret"
ignore-unfixed:
description: "Report only vulnerabilities that actually have a fix available."
required: false
type: boolean
default: true
fail-on-findings:
description: |
Fail the job when something at or above `severity` is found. Off by
default so that adopting this workflow does not immediately break a
repository's CI.
required: false
type: boolean
default: false
upload-sarif:
description: "Upload the report to GitHub code scanning. Needs Advanced Security on private repositories."
required: false
type: boolean
default: true
trivyignores:
description: "Comma-separated paths to .trivyignore files."
required: false
type: string
default: ""
runs-on:
description: "Runner image"
required: false
type: string
default: "ubuntu-latest"

concurrency:
# Validation, not a durable side effect - a newer run supersedes this one.
group: security-scan-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
scan:
name: Trivy scan
runs-on: ${{ inputs.runs-on }}
permissions:
contents: read
security-events: write
steps:
- name: Checkout
if: ${{ inputs.scan-type != 'image' && inputs.artifact-name == '' }}
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}

- name: Download artifact to scan
if: ${{ inputs.artifact-name != '' }}
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ inputs.artifact-path }}

- name: Scan info
shell: bash
env:
SCAN_TYPE: ${{ inputs.scan-type }}
SCAN_REF: ${{ inputs.artifact-name != '' && inputs.artifact-path || inputs.scan-ref }}
SEVERITY: ${{ inputs.severity }}
SCANNERS: ${{ inputs.scanners }}
GATING: ${{ inputs.fail-on-findings }}
run: |
echo "Type : $SCAN_TYPE"
echo "Target : $SCAN_REF"
echo "Severity : $SEVERITY"
echo "Scanners : $SCANNERS"
echo "Gating : $GATING"

# Always exit 0 here. A non-zero exit would skip the SARIF upload and the
# findings would never reach code scanning - which is the whole point.
- name: Trivy scan (report)
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: ${{ inputs.scan-type }}
scan-ref: ${{ inputs.artifact-name != '' && inputs.artifact-path || inputs.scan-ref }}
scanners: ${{ inputs.scanners }}
severity: ${{ inputs.severity }}
ignore-unfixed: ${{ inputs.ignore-unfixed }}
trivyignores: ${{ inputs.trivyignores }}
format: sarif
output: trivy-results.sarif
exit-code: '0'
# Without this the SARIF report ignores `severity` and carries every
# severity, so code scanning would disagree with the gate below.
limit-severities-for-sarif: true

- name: Upload SARIF to code scanning
if: ${{ inputs.upload-sarif }}
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-results.sarif
category: trivy-${{ inputs.scan-type }}

- name: Summarise findings
if: always()
shell: bash
env:
SCAN_REF: ${{ inputs.artifact-name != '' && inputs.artifact-path || inputs.scan-ref }}
SEVERITY: ${{ inputs.severity }}
run: |
if [ ! -s trivy-results.sarif ]; then
echo "No SARIF report produced." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
count="$(python3 -c "
import json
with open('trivy-results.sarif') as fh:
data = json.load(fh)
print(sum(len(run.get('results', [])) for run in data.get('runs', [])))
")"
{
echo "### Trivy"
echo ""
echo "\`$count\` finding(s) at severity \`$SEVERITY\` in \`$SCAN_REF\`."
} >> "$GITHUB_STEP_SUMMARY"

# Second pass, only when gating. Re-uses the database the first pass
# already downloaded; prints a readable table instead of SARIF.
- name: Trivy scan (gate)
if: ${{ inputs.fail-on-findings }}
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: ${{ inputs.scan-type }}
scan-ref: ${{ inputs.artifact-name != '' && inputs.artifact-path || inputs.scan-ref }}
scanners: ${{ inputs.scanners }}
severity: ${{ inputs.severity }}
ignore-unfixed: ${{ inputs.ignore-unfixed }}
trivyignores: ${{ inputs.trivyignores }}
format: table
exit-code: '1'
skip-setup-trivy: true

- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: trivy-sarif-${{ inputs.scan-type }}
path: trivy-results.sarif
if-no-files-found: ignore
retention-days: 14
Loading
Loading