ci: harden release workflows (SHA-pin actions, least-privilege token, TLS timestamper) - #64
ci: harden release workflows (SHA-pin actions, least-privilege token, TLS timestamper)#6407souravkunda wants to merge 1 commit into
Conversation
… TLS timestamper) Supply-chain hardening for the CI/CD workflows that build, sign and publish the BrowserStackLocal NuGet package. CWE-829 — pin third-party actions to immutable commit SHAs: cd.yml and ci.yml referenced actions/checkout, microsoft/setup-msbuild, actions/setup-dotnet, actions/setup-java and actions/upload-artifact by mutable tag. A tag can be repointed by whoever controls the action repo, which would run their code in the release job alongside NUGET_API_KEY and the GCP KMS signing credentials. Each ref is now a 40-char commit SHA with the version kept as a trailing comment. The pins resolve to exactly what the tags pointed at when this change was made, so the runner executes the same action code as before. Adds .github/dependabot.yml (github-actions, weekly) so the pins stay current. CWE-732 — declare least-privilege GITHUB_TOKEN permissions: Neither workflow declared a permissions block, so the token inherited the org/repo default. Both now declare `contents: read` at workflow level, so they fail closed regardless of that setting. No step needs more: checkout needs contents:read, publishing authenticates with NUGET_API_KEY, signing with GCP_SA_KEY, and upload-artifact@v4 uses the Actions runtime token. CWE-311 — request the signing timestamp over TLS: scripts/sign_nupkg.sh contacted the RFC 3161 timestamp authority over plain HTTP. Switched to https://timestamp.sectigo.com — same authority and policy OID, verified to issue tokens over TLS. No functional change: no C# source is touched and no packaged bytes change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| - package-ecosystem: "github-actions" | ||
| directory: "/" | ||
| schedule: | ||
| interval: "weekly" | ||
| commit-message: | ||
| prefix: "ci" |
07souravkunda
left a comment
There was a problem hiding this comment.
Pipeline security review — no blocking findings.
The diff is tightly scoped to the three weaknesses it claims (SHA pins, permissions:, TLS timestamper) plus the Dependabot config the remediation asks for; nothing unrelated rides along. I re-derived the load-bearing facts rather than taking the description's word for them:
- Pins are correct and behaviour-preserving — all five resolve to the live tag target in the right upstream repo (
actions/checkout0717577d,microsoft/setup-msbuildc26a08ba,actions/setup-dotnet55ec9447,actions/setup-javacf277c60,actions/upload-artifactea165f8d). All 9 refs across both files are 40-hex; no mutable ref remains. contents: readis sufficient — read every step in both workflows; none consumesGITHUB_TOKEN, and no job-level block widens the ceiling.http://is gone from the release path, and the HTTPS endpoint is the same TSA and policy OID, so this is transport-only.- Nothing packaged changes —
BrowserStackLocal.csproj:22includes onlyMIT-LICENSE.txtas a non-code file, so the.github/andscripts/edits can't reach the nupkg. - The description's claims about
master(nopermissions:block, noset-outputanywhere,nuget/setup-nugetremoved, plain-HTTP timestamper relocated toscripts/sign_nupkg.sh:40) all check out againstorigin/master, not a working tree. The severity corrections are also right — an RFC 3161 token is signed by the TSA and cannot be forged in transit, so "denial-of-validity / replay / hash disclosure" is the honest impact.
Three comments: one for a human to decide (dispatch ci.yml before merge — nothing has actually run yet), and two nits. Keeping this a Draft; approval is a human's call.
|
|
||
| steps: | ||
| - uses: actions/checkout@v2 | ||
| - uses: actions/checkout@0717577d45739eb3c851188b29f50ed6c0b2194e # v2 |
There was a problem hiding this comment.
[for-human] No workflow run has exercised this change — a human should decide whether to dispatch ci.yml on this branch before merge.
I re-verified independently everything that can be checked statically, and it all holds:
- All five pins equal what their tags resolve to right now, in the correct upstream repos:
actions/checkout@v2→0717577d,microsoft/setup-msbuild@v1.0.2→c26a08ba,actions/setup-dotnet@v3→55ec9447,actions/setup-java@v4→cf277c60,actions/upload-artifact@v4→ea165f8d. No pin points at a fork or an unrelated commit, so the runner executes the same action code as before. contents: readis sufficient: I read every step in both workflows and none of them consumesGITHUB_TOKEN— checkout needscontents: read; msbuild/dotnet/java setup make no API calls;sign_nupkg.shusesGCP_SA_KEY;dotnet nuget pushusesNUGET_API_KEY;upload-artifact@v4uses the Actions runtime token. No job-level block widens it.- Nothing packaged changes:
BrowserStackLocal.csproj:22includes onlyMIT-LICENSE.txtas a non-code file, so nothing under.github/orscripts/reaches the nupkg.
What that leaves genuinely unverified: no step of either workflow has actually run. The reason given for not running one is right for cd.yml (it publishes to nuget.org), but ci.yml was dispatchable on this branch and is the one check that would confirm the pins resolve and the narrowed token is enough. Dispatching a workflow on a public repo is a human's call rather than the fix agent's, so it's surfaced here rather than blocked — but please treat it as the pre-merge step, not an optional extra:
gh workflow run ci.yml --repo browserstack/browserstack-local-csharp --ref locsec/WI-9a6a0fac
A failure in checkout / setup-msbuild / setup-dotnet / upload-artifact would mean a bad pin; a 403 or Resource not accessible by integration would mean contents: read is too narrow.
One thing ci.yml will not cover: it has no signing step, so --tsaurl https://timestamp.sectigo.com under jsign on the runner's JVM truststore is first exercised by the next real release. That is the right place to watch it (sign_nupkg.sh runs under set -e, so it fails closed rather than publishing an untimestamped package).
| # is what stops those pins from going stale. | ||
| version: 2 | ||
| updates: | ||
| - package-ecosystem: "github-actions" |
There was a problem hiding this comment.
[nit] This config can't be scoped to cd.yml/ci.yml — worth knowing what its first run will actually produce.
The github-actions ecosystem only accepts directory: "/" and always scans everything under .github/workflows, so two consequences follow that the PR description doesn't anticipate:
1. It will propose the major bumps this PR deliberately left out. Dependabot offers the newest version, not the newest patch of the pinned major. Current latest upstream vs. what's pinned here:
| action | pinned | latest |
|---|---|---|
actions/checkout |
v2 | v7.0.1 |
microsoft/setup-msbuild |
v1.0.2 | v3 |
actions/setup-dotnet |
v3 | v6.0.0 |
actions/setup-java |
v4 | v6.0.0 |
actions/upload-artifact |
v4 | v7.0.1 |
So the first weekly run opens major-version PRs (capped at the default 5 open), which is exactly the "functional change, belongs on its own merits" work the description defers. That may well be what you want — but if the intent was only "keep the pins fresh within the pinned major", add an ignore:
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]2. Two of those PRs will target Semgrep.yml, which the org-wide security-tools rollout owns (actions/checkout@v3.5.3 → v7, github/codeql-action/upload-sarif@v2.20.0 → v3+, since codeql-action v2 is retired). Bumping this repo's copy drifts it from the managed template and is liable to be reverted by the next rollout. There's no per-file scoping, so the options are an ignore on github/codeql-action or just closing those PRs knowingly.
Also worth recording so nobody assumes otherwise later: this won't refresh Semgrep.yml:30's image: returntocorp/semgrep:1.166.0 either. A job-level container: image: sits outside Dependabot's docker ecosystem (dependabot-core#5819), so the tag-not-digest residual noted on the chain ticket stays a manual item regardless of this file.
|
|
||
| # Least privilege: no step in this workflow writes to the repo, | ||
| # releases or packages via GITHUB_TOKEN (publishing uses NUGET_API_KEY, | ||
| # signing uses GCP_SA_KEY). Fail closed regardless of the org default. |
There was a problem hiding this comment.
[nit] This comment is copied verbatim from cd.yml, but ci.yml has no signing step and never references GCP_SA_KEY or NUGET_API_KEY — it stops at pack + upload-artifact. The permissions block is right; only the justification is borrowed. Something like "no step in this workflow uses GITHUB_TOKEN; fail closed regardless of the org default" would fit both files without naming secrets this one doesn't hold.
Supply-chain hardening for the workflows that build, sign and publish the
BrowserStackLocalNuGet package. Three independent weaknesses, all in CI/CD config.CWE-829 — third-party actions pinned by mutable tag
cd.ymlandci.ymlreferenced five actions by tag (actions/checkout@v2,microsoft/setup-msbuild@v1.0.2,actions/setup-dotnet@v3,actions/setup-java@v4,actions/upload-artifact@v4). A tag can be repointed by whoever controls the action repository, which would execute their code inside the release job — the job that holdsNUGET_API_KEYand the GCP KMS signing credentials.Each reference is now a 40-character commit SHA with the version as a trailing comment.
These tags do move in practice — the same
uses:line has run different code over time:@vNresolves tovN.0.0actions/checkout0717577d(2026-07-16)722adc63(2019-12-13)actions/setup-dotnet55ec9447(2025-04-17)c7e7147f(2022-09-29)actions/upload-artifactea165f8d(2025-03-19)c7d193f3(2023-12-14)Every pin equals what its tag resolves to today, so the runner executes the same action code as before this change — the pin freezes behaviour rather than altering it. Adds
.github/dependabot.yml(github-actions, weekly) so the pins stay current.CWE-732 — no
permissions:blockNeither workflow declared one, so
GITHUB_TOKENinherited the org/repo default. Both now declarecontents: readat workflow level and fail closed regardless of that setting.Audited every step for a broader scope — none needs one: checkout needs
contents: read; msbuild/dotnet/java setup make no API calls;sign_nupkg.shauthenticates withGCP_SA_KEY;dotnet nuget pushauthenticates withNUGET_API_KEY;upload-artifact@v4uses the Actions runtime token, notGITHUB_TOKENscopes.CWE-311 — signing timestamp requested over plain HTTP
scripts/sign_nupkg.shcontacted the RFC 3161 timestamp authority athttp://timestamp.sectigo.com. Nowhttps://.To be accurate about severity: an RFC 3161 token is signed by the TSA and chains to a trusted root, so a transport-level attacker cannot forge a timestamp. What cleartext does leave open is response tampering/stripping (signing fails or is denied a timestamp), replay of a previously captured token, and disclosure of the artifact hash. Modest, but the fix is free.
Verified the HTTPS endpoint actually issues tokens rather than merely answering:
Same authority and policy OID as the HTTP endpoint — a transport change only.
Testing
Static validation; no workflow run was triggered.
cd.ymlpublishes to nuget.org and signs with the production key, so dispatching it to test a config change would cut a real release.bash -n scripts/sign_nupkg.shpasses.@tagrefs remain; all nineuses:refs are 40-hex; each pin verified equal to its live tag target.permissions == {contents: read}in both files, with no job-level widening.http://left under.github/orscripts/.permissionsand the action refs; the ordered list of action identities is unchanged.No C# source is touched and no packaged bytes change — the only non-code file the package includes is
MIT-LICENSE.txt.Suggested check before merge: dispatch
ci.ymlon this PR's branch to confirm the pins and the narrowed token still run green.cd.ymlis best exercised by the next real release.Follow-ups (not in this PR)
actions/checkout@v2, andactions/setup-java@v4is now deprecated upstream). Bumping majors is a functional change and belongs on its own merits.browserstack-local-ruby'sgem-push.ymlhas the same tag-pinned shape on its gem publish path.