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
91 changes: 91 additions & 0 deletions .github/actions/govulncheck-gate/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: govulncheck gate
description: >
Scan one Go module and fail only on reachable vulnerabilities in third-party
modules that are not allowlisted. Standard-library findings are reported and
never gate, because they track the runner's Go patch release rather than
anything in this repository.

inputs:
module:
description: Directory containing the go.mod to scan.
required: true
name:
description: >
Label for this module. Matched against the first column of the allowlist
file, and used to prefix the reported lines.
required: true
allowlist:
description: Path to the allowlist file.
required: false
default: .github/govulncheck-allowlist.txt
version:
description: govulncheck version to install.
required: false
default: v1.6.0

runs:
using: composite
steps:
- name: Scan ${{ inputs.name }}
shell: bash
working-directory: ${{ inputs.module }}
env:
MODULE_NAME: ${{ inputs.name }}
ALLOWLIST: ${{ github.workspace }}/${{ inputs.allowlist }}
GOVULNCHECK_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
go install "golang.org/x/vuln/cmd/govulncheck@$GOVULNCHECK_VERSION"

REPORT="$RUNNER_TEMP/govulncheck-$MODULE_NAME.json"
# JSON mode is meant for programmatic use and does not signal findings
# through its exit code. The parse below is what decides.
govulncheck -format json ./... > "$REPORT" || true

# A finding gates only when its trace reaches a called symbol. Findings
# that stop at module or package level mean the dependency is present
# but the vulnerable code never runs, which is Dependabot's job to
# report rather than a build gate's job to block on.
reachable() {
jq -sr --arg scope "$1" '
[ .[]
| select(has("finding")) | .finding
| select(.trace[0].function != null)
| select(if $scope == "stdlib"
then .trace[0].module == "stdlib"
else .trace[0].module != "stdlib" end)
| .osv ] | unique | .[]
' "$REPORT"
}

STDLIB=$(reachable stdlib)
MODULE=$(reachable module)
ALLOWED=$(grep -E "^$MODULE_NAME[[:space:]]" "$ALLOWLIST" 2>/dev/null | awk '{print $2}' | sort -u || true)
NEW=$(comm -23 <(printf '%s\n' "$MODULE" | sed '/^$/d') <(printf '%s\n' "$ALLOWED" | sed '/^$/d'))

# Report to both the job log and the run summary. The summary is for
# whoever opens the run page. Stdout is what `gh run view --log` gives
# you, and a step whose reasoning lives only in the summary reads as a
# step that did nothing.
emit() {
echo "govulncheck[$MODULE_NAME] $1"
echo "- \`$MODULE_NAME\` $1" >> "$GITHUB_STEP_SUMMARY"
}

if [ -n "$STDLIB" ]; then
emit "stdlib (toolchain, not gating): $(echo $STDLIB)"
fi
if [ -n "$ALLOWED" ]; then
emit "known backlog (allowlisted): $(echo $ALLOWED)"
fi

if [ -n "$NEW" ]; then
emit "**new reachable dependency vulnerabilities**: $(echo $NEW)"
echo "::error::new reachable dependency vulnerabilities in $MODULE_NAME: $(echo $NEW)"
echo "Fix by upgrading the dependency. To accept it for now, add it to"
echo "$ALLOWLIST with a reason."
govulncheck ./... || true
exit 1
fi

emit "no new reachable dependency vulnerabilities"
19 changes: 10 additions & 9 deletions .github/govulncheck-allowlist.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Known-reachable dependency vulnerabilities in the driver sub-modules.
# Known-reachable dependency vulnerabilities, per module.
#
# The govulncheck step in ci.yml's `drivers` job fails on any reachable
# vulnerability in a third-party module that is NOT listed here. This file is
# therefore a backlog, not an exemption: an entry means "we know, it is
# tracked, it does not block the next unrelated PR" — never "this is fine".
# ci.yml's `vulncheck` job scans every module in the repository through
# .github/actions/govulncheck-gate and fails on any reachable vulnerability in
# a third-party module that is NOT listed here. This file is therefore a
# backlog, not an exemption: an entry means "we know, it is tracked, it does
# not block the next unrelated PR", never "this is fine".
#
# The gate exists to stop NEW findings landing. Removing a line is how you
# enforce a fix: bump the dependency, delete the entry, and the job goes green
Expand All @@ -16,10 +17,10 @@
#
# Format: <driver> <OSV-ID>
#
# Audited 2026-08-13 against govulncheck v1.6.0. sftpdriver is deliberately
# absent: its x/crypto/ssh findings were reachable through ssh.Dial and
# ssh.ParsePrivateKey in Open, and were fixed by bumping to v0.52.0 rather
# than being listed here.
# Audited 2026-08-14 against govulncheck v1.6.0. Absent from this file, and
# passing on their own: root, extension, sftpdriver. sftpdriver's x/crypto/ssh
# findings were reachable through ssh.Dial and ssh.ParsePrivateKey in Open,
# and were fixed by bumping to v0.52.0 rather than being listed here.

# gcsdriver — all indirect, via cloud.google.com/go/storage's dependency tree.
gcsdriver GO-2026-6061 # grpc: xDS RBAC + HTTP/2 server. Needs grpc v1.82.1.
Expand Down
118 changes: 47 additions & 71 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,53 @@ jobs:
with:
go-versions: '["1.25"]'
os: '["ubuntu-latest"]'
# The shared security job gates on any govulncheck finding, including
# standard-library ones. Those describe the runner's Go patch release
# rather than this repository, so every Go release red-lined main for
# the window between the advisory landing and the toolchain reaching
# the setup-go manifest. Nobody here could act on it, and the failure
# cascaded: `ci` reports one conclusion for all its jobs, so the driver
# and extension jobs skipped with it.
#
# gosec still gates from that job. govulncheck now gates from the
# vulncheck job below, which reports stdlib findings and blocks only on
# reachable third-party ones.
govulncheck-fail-on-findings: false

# ─── Vulnerability Gate ───────────────────────────────────────────────
# govulncheck resolves imports per module, so the shared workflow's scan of
# the repository root never saw the sub-modules where every third-party
# dependency actually lives. Each module is scanned on its own here.
vulncheck:
name: Vulncheck (${{ matrix.module.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module:
- {name: root, dir: .}
- {name: extension, dir: extension}
- {name: azuredriver, dir: drivers/azuredriver}
- {name: gcsdriver, dir: drivers/gcsdriver}
- {name: s3driver, dir: drivers/s3driver}
- {name: sftpdriver, dir: drivers/sftpdriver}

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: ${{ matrix.module.dir }}/go.mod
cache: true
cache-dependency-path: ${{ matrix.module.dir }}/go.sum

- name: Scan
uses: ./.github/actions/govulncheck-gate
with:
module: ${{ matrix.module.dir }}
name: ${{ matrix.module.name }}

# ─── Driver Sub-Modules ──────────────────────────────────────────────
drivers:
Expand Down Expand Up @@ -77,77 +124,6 @@ jobs:
working-directory: drivers/${{ matrix.driver }}
run: go test -race -count=1 ./...

# govulncheck resolves imports per module, so the shared go-ci.yml
# `security` job — which runs at the repository root — never sees these
# sub-modules at all. Every third-party dependency the project ships
# lives here, so without this step they were entirely unscanned.
#
# Module findings gate; standard-library findings only report. A stdlib
# finding tracks the runner's Go patch release rather than anything in
# this repository, so gating on it reds every branch whenever a new Go
# release lands, regardless of whether this code is affected.
- name: Vulnerability scan (govulncheck)
working-directory: drivers/${{ matrix.driver }}
env:
GOVULNCHECK_VERSION: v1.6.0
DRIVER: ${{ matrix.driver }}
ALLOWLIST: ${{ github.workspace }}/.github/govulncheck-allowlist.txt
run: |
set -euo pipefail
go install "golang.org/x/vuln/cmd/govulncheck@$GOVULNCHECK_VERSION"

REPORT="$RUNNER_TEMP/govulncheck-$DRIVER.json"
# JSON mode is for programmatic use and does not signal findings
# through its exit code; the parse below is what decides.
govulncheck -format json ./... > "$REPORT" || true

# A finding gates only when its trace reaches a called symbol.
# Findings at module or package level mean the dependency is present
# but the vulnerable code is never invoked.
reachable() {
jq -sr --arg scope "$1" '
[ .[]
| select(has("finding")) | .finding
| select(.trace[0].function != null)
| select(if $scope == "stdlib"
then .trace[0].module == "stdlib"
else .trace[0].module != "stdlib" end)
| .osv ] | unique | .[]
' "$REPORT"
}

STDLIB=$(reachable stdlib)
MODULE=$(reachable module)
ALLOWED=$(grep -E "^$DRIVER[[:space:]]" "$ALLOWLIST" 2>/dev/null | awk '{print $2}' | sort -u || true)
NEW=$(comm -23 <(printf '%s\n' "$MODULE" | sed '/^$/d') <(printf '%s\n' "$ALLOWED" | sed '/^$/d'))

# Report to both the job log and the run summary. The summary is for
# humans reading the run page; stdout is what `gh run view --log`
# and anyone debugging a failure actually gets. A step whose
# reasoning appears only in the summary looks like it did nothing.
emit() {
echo "govulncheck[$DRIVER] $1"
echo "- \`$DRIVER\` $1" >> "$GITHUB_STEP_SUMMARY"
}

if [ -n "$STDLIB" ]; then
emit "stdlib (toolchain, not gating): $(echo $STDLIB)"
fi
if [ -n "$ALLOWED" ]; then
emit "known backlog (allowlisted): $(echo $ALLOWED)"
fi

if [ -n "$NEW" ]; then
emit "**new reachable dependency vulnerabilities**: $(echo $NEW)"
echo "::error::new reachable dependency vulnerabilities in $DRIVER: $(echo $NEW)"
echo "Fix by upgrading the dependency. To accept it for now, add it to"
echo ".github/govulncheck-allowlist.txt with a reason."
govulncheck ./... || true
exit 1
fi

emit "no new reachable dependency vulnerabilities"

# ─── Extension Module ─────────────────────────────────────────────────
extension:
name: Extension
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ All notable changes to Trove are documented in this file.
### Dependencies and Supply Chain

#### Fixed
- **A Go patch release no longer breaks `main`.** The shared workflow's `security` job failed on any govulncheck finding, standard-library ones included. Those name the version of Go the runner happens to have installed, so from the moment an advisory is published until the fixed toolchain reaches the setup-go manifest, every branch went red on something nobody in this repository could act on. `govulncheck-fail-on-findings` is now `false` for that job, which keeps gosec gating there, and govulncheck gates from the new `vulncheck` job instead. gosec is unaffected.
- **Every module is scanned, through one shared gate.** `.github/actions/govulncheck-gate` holds the scan and the pass/fail rule, and the `vulncheck` matrix runs it over the root module, `extension`, and all four drivers. Standard-library findings are reported; only reachable third-party findings that are absent from `.github/govulncheck-allowlist.txt` fail the build. The root module and `extension` have no reachable third-party findings, so neither appears in the allowlist. `vulncheck` deliberately does not declare `needs: ci`, so it cannot be skipped by a failure elsewhere.
- **`sftpdriver`: `golang.org/x/crypto` v0.37.0 → v0.52.0.** Five advisories in `x/crypto/ssh` were reachable, not merely present — `Open` calls `ssh.Dial`, `ssh.ParsePrivateKey`, and `sftp.NewClient` directly: infinite loop on large channel writes (GO-2026-5020), FIDO/U2F physical-interaction bypass (GO-2026-5019), DoS from pathological RSA/DSA parameters (GO-2026-5018), client-triggered server deadlock (GO-2026-5017), and a byte-arithmetic underflow panic (GO-2026-5013). The module's `go` directive is unchanged; x/crypto v0.52.0 requires only Go 1.25.0.

#### Added
Expand Down
Loading