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
16 changes: 15 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@ version: 2
updates:
- package-ecosystem: "nuget"
directory: "/src/"
# Routine "stay current" bumps run twice a year (first of January and July). Anything with a
# known advisory is handled promptly and separately by Dependabot security updates (enabled in
# repo Settings), which ignore this schedule and the cooldown below.
schedule:
interval: "monthly"
interval: "semiannually"
time: "23:00"
timezone: "UTC"
# Collapse every routine bump into a single grouped PR per run rather than one PR per package.
groups:
all-dependencies:
patterns:
- "*"
# Never ingest a release on its publish day: wait until a candidate version is at least
# 14 days old before opening a bump PR, so a same-day compromised or later-yanked publish
# ages out of the window before it can reach a PR. (Applies to these version updates only,
# not to security updates.)
cooldown:
default-days: 14
open-pull-requests-limit: 10
reviewers:
- "FrankRay78"
Expand Down
28 changes: 28 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,33 @@ jobs:
- name: Build
run: dotnet build src --no-restore

- name: Fail on vulnerable dependencies
# SCA gate: dotnet list package --vulnerable exits 0 even when advisories are found, so we
# inspect its output and fail the job ourselves. Parse the structured --format json (stable
# since the .NET 9 SDK) rather than matching an English sentence that could be localised or
# reworded between SDKs. Any package (top-level or transitive) with a non-empty
# `vulnerabilities` array fails the build. Runs after restore/build so transitive resolution
# is available; jq is preinstalled on ubuntu-latest.
run: |
set -euo pipefail
report=$(dotnet list src package --vulnerable --include-transitive --format json)
echo "$report"
vulnerable=$(echo "$report" | jq -r '
[ .projects[]? as $p
| $p.frameworks[]?
| (.topLevelPackages // []) + (.transitivePackages // [])
| .[]
| select((.vulnerabilities // []) | length > 0)
| { path: $p.path, id, resolvedVersion, vulnerabilities } ]
| if length == 0 then empty
else .[] | "\(.path): \(.id) \(.resolvedVersion) — " + ([.vulnerabilities[] | "\(.severity) \(.advisoryurl)"] | join("; "))
end')
if [ -n "$vulnerable" ]; then
echo "FAIL: one or more dependencies have known advisories:"
echo "$vulnerable"
exit 1
fi
echo "No vulnerable dependencies found."

- name: Test
run: dotnet test src --no-build --verbosity normal
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Hardening the Dependency Pipeline Against Supply-Chain Risk

**Intent:** Keep the automated dependency-update flow (human-gated, no auto-merge) but make it both *quiet* and *trustworthy* — routine bumps are infrequent and batched into a single PR so they stop being maintenance noise, a same-day compromised publish is never taken, and a known-vulnerable dependency is caught before release. Security advisories remain the fast path: they still trigger prompt, separate PRs regardless of the routine cadence. Chosen over the alternative of stopping automation and bumping by hand.

**Behaviour:**
- Given: routine (non-security) version updates are due, When: the schedule fires (twice a year), Then: all eligible bumps arrive as one grouped PR rather than one PR per package — and Dependabot security updates open their own prompt PRs independently of this schedule and the cooldown below.
- Given: Dependabot picks a candidate version for a routine bump, When: that version is younger than 14 days, Then: it is not chosen until the window elapses — the project never ingests a release on its publish day.
- Given: the build pipeline runs, When: any direct or transitive dependency has a known advisory, Then: CI fails the build (`dotnet list package --vulnerable --include-transitive`) and GitHub Dependabot security alerts flag it independently of the version-update PRs.

**Constraints:**
- The existing human gate stays: Dependabot opens PRs assigned to the maintainer; nothing auto-merges. Hardening adds assurance *around* that gate, it does not replace it.
- No new package feed is introduced, so package-source-mapping is out of scope (single implicit nuget.org feed = no dependency-confusion vector to close yet).
- CodeQL is SAST over our own code and does **not** cover dependency advisories; the SCA gate is additive, not a duplicate of it.

**Decisions:**
1. **Harden the automated flow, do not go manual.** Dependabot is not the attack surface — a malicious version reaches nuget.org regardless. Going manual trades away timely known-CVE patching (the common harm) to avoid a slice of the rare injection case that the human merge gate already covers. Rejected: disabling the chore workflow.
2. **Cooldown via Dependabot config over a bespoke delay mechanism.** Native `cooldown:` keeps the policy declarative in one file the maintainer already reviews. Rejected: custom scripting / Renovate migration (Renovate's `minimumReleaseAge` is equivalent but a larger tooling change than warranted).
3. **Cut routine cadence to twice a year, grouped, and lean on security updates for CVE response.** The monthly per-package PR stream was maintenance noise disproportionate to a project this size. Routine version updates now run semiannually and collapse into one grouped PR; the safety that matters — prompt patching of *known* advisories — is carried by Dependabot security updates (repo-settings, advisory-driven, unaffected by this schedule or the cooldown) plus the in-CI SCA gate. Trade-off: dependencies drift further between routine refreshes, accepted because security updates and the SCA gate cover the vulnerability path and a CLI this size tolerates non-security lag. Rejected: keeping monthly; disabling routine updates entirely (loses no-CVE bug/feature fixes with no offsetting quiet, since grouping already reduces the routine stream to one PR).
4. **14-day cooldown and a hard-failing SCA gate.** At a semiannual cadence a two-week soak costs no meaningful responsiveness while still stopping the scheduled run from grabbing a same-day compromised or later-yanked publish; the SCA check fails the build rather than warning, so a known-vulnerable dependency cannot be merged unnoticed. Trade-off: a newly-disclosed advisory on an existing dependency can turn an unrelated PR's CI red until the dep is addressed — accepted as the point of the gate.
5. **Lock files were attempted and dropped.** The original plan committed a `packages.lock.json` per project and restored `--locked-mode` on CI to prove that shipped artifacts contain exactly the reviewed dependency bitstream. In practice `IsAotCompatible=true` (on `NetPace.Core` and `NetPace.Console`) pulls in the SDK-implicit `Microsoft.NET.ILLink.Tasks`, whose content hash is specific to the .NET SDK build; with `global.json` on `rollForward: latestFeature`, CI floats to a newer SDK feature band than the lock was generated on (e.g. 10.0.301 vs a dev machine's 10.0.109) and locked-mode restore fails NU1403 on a legitimate tree. Making lock files viable would require pinning the SDK feature band across dev and CI — a larger, ongoing toolchain-coupling cost (every SDK bump becomes a lock-regeneration step) judged disproportionate for a project this size. Rejected: pin the SDK to keep lock files. The tamper-evidence lock files would have added is left to the human review gate plus cooldown; revisit if the SDK-pinning cost ever becomes worthwhile.

**Known residual:** Without lock files, the resolved transitive graph is not content-pinned — a compromised-but-not-yet-advisory version that is past the cooldown window would still be ingestible, caught only by the human review gate. Explicitly pinning the floating Roslynator `[4.15.0, )` range and package-source-mapping remain follow-ups (the latter needs a second feed to matter). No SLSA/build-attestation provenance is in scope here.

**Date:** 2026-07-13
Loading