diff --git a/.github/workflows/sbom-publish.yml b/.github/workflows/sbom-publish.yml new file mode 100644 index 0000000..1bbac36 --- /dev/null +++ b/.github/workflows/sbom-publish.yml @@ -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 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..f8956b5 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -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 diff --git a/README.md b/README.md index 44c05ac..96d70c6 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ repositories by referencing a tagged release of this repo. | `.github/workflows/release-please.yml` | Run [release-please](https://github.com/googleapis/release-please) for a repository. | | `.github/workflows/close-invalid-prs.yml` | Close PRs opened from a fork's default branch with a configurable message. | | `.github/workflows/markdown-lint.yml` | Lint Markdown files with [`markdownlint-cli2`](https://github.com/DavidAnson/markdownlint-cli2-action) and check links with [`lychee`](https://github.com/lycheeverse/lychee-action). | +| `.github/workflows/sbom-publish.yml` | Publish a CycloneDX SBOM to the OneLiteFeather [Dependency-Track](https://dependencytrack.org/) instance, so the shipped dependency inventory keeps being matched against CVEs published later. Takes the project's own SBOM via an artifact, or generates one with [Trivy](https://trivy.dev/) when the project has none. | +| `.github/workflows/security-scan.yml` | Scan a filesystem or container image with [Trivy](https://trivy.dev/) and surface the findings in GitHub code scanning. Report-only by default, optionally gating. | ## Defaults at a glance @@ -245,6 +247,116 @@ jobs: A `.markdownlint.json` and optional `.lycheeignore` (regex per line) at the repo root configure rules and skip-lists. +### Publish an SBOM to Dependency-Track + +Two shapes, depending on whether the project already generates an SBOM. + +**The project generates its own** (preferred — a build tool resolves the +dependency graph better than any external scanner). Upload it as an artifact, +then hand the artifact name over: + +```yaml +jobs: + publish: + # ... your existing build/publish job, ending with: + # - uses: actions/upload-artifact@v4 + # with: + # name: sbom + # path: build/reports/cyclonedx/bom.xml + + sbom: + needs: publish + uses: OneLiteFeatherNET/workflows/.github/workflows/sbom-publish.yml@v2.6.0 + with: + project-name: "MyProject" + project-version: "1.2.3" + artifact-name: "sbom" + sbom-path: "bom.xml" + secrets: inherit +``` + +**The project generates nothing** — leave `artifact-name` empty and Trivy +produces a CycloneDX SBOM from the checked-out repository: + +```yaml +jobs: + sbom: + uses: OneLiteFeatherNET/workflows/.github/workflows/sbom-publish.yml@v2.6.0 + with: + project-name: "MyProject" + project-version: "1.2.3" + secrets: inherit +``` + +Run it as its own job, not as a step inside the publish job: Dependency-Track +being unreachable should never take down the release that produced the +artifact. + +`autocreate` defaults to `true`, which needs the API key's team to hold +**`PROJECT_CREATION_UPLOAD`** on top of `BOM_UPLOAD`. Without it the server +answers `403` the first time any new version is uploaded. + +### Scan for vulnerabilities (Trivy) + +```yaml +name: Security +on: + pull_request: + schedule: + - cron: '0 6 * * 1' # new CVEs land against unchanged code + +jobs: + scan: + permissions: + contents: read + security-events: write # SARIF upload to code scanning + uses: OneLiteFeatherNET/workflows/.github/workflows/security-scan.yml@v2.6.0 +``` + +Report-only by default: adopting it makes findings visible in code scanning +without turning a repository's CI red on day one. Set `fail-on-findings: true` +once a repo is clean enough to keep it that way. + +On **private** repositories the SARIF upload needs GitHub Advanced Security. +Without it, set `upload-sarif: false` and rely on the job summary plus +`fail-on-findings`. + +#### Gate a release before anything goes public + +To stop a vulnerable build from ever reaching a registry, put the scan in its +own job between the build and everything that publishes. Have the build upload +the artifact, gate on it, and let the publishing job depend on the gate: + +```yaml +jobs: + build: # produces the artifact, publishes nothing + # - uses: actions/upload-artifact@v4 + # with: { name: plugin-jar, path: build/libs/*.jar } + + security-gate: + needs: build + permissions: + contents: read + security-events: write + uses: OneLiteFeatherNET/workflows/.github/workflows/security-scan.yml@v2.6.0 + with: + scan-type: rootfs # see the warning below + artifact-name: plugin-jar + fail-on-findings: true + secrets: inherit + + publish: + needs: security-gate # nothing public happens until the gate is green + # ... +``` + +> **Use `rootfs`, not `fs`, for built JVM artifacts.** Trivy's `fs` scanner +> ignores JAR contents. Measured on AntiRedstoneClock-Remastered's shaded jar: +> `fs` reported 0 packages and 0 findings, `rootfs` reported 12 packages and a +> HIGH finding. `fs` on the source tree of a Gradle project without a +> `gradle.lockfile` also finds nothing — a gate on it looks green because it +> checked nothing at all. + ## Required secrets Workflows that publish or read from the OneLiteFeather Maven repository expect @@ -260,6 +372,13 @@ these secrets to be available in the caller repository (and forwarded via - `HARBOR_USERNAME` - `HARBOR_PASSWORD` +`sbom-publish` talks to Dependency-Track, so it expects: + +- `DEPENDENCYTRACK_HOSTNAME` — host only, no scheme, e.g. `dependency-track.onelitefeather.dev` +- `DEPENDENCYTRACK_APIKEY` — the key's team needs `BOM_UPLOAD`, plus `PROJECT_CREATION_UPLOAD` while `autocreate` is on + +`security-scan` needs no secrets at all. + Signing is keyless (cosign + GitHub OIDC) — no signing secrets. The calling job just needs `permissions: id-token: write` when `sign: true` (the default).