Skip to content

ci: harden release workflows (SHA-pin actions, least-privilege token, TLS timestamper) - #64

Draft
07souravkunda wants to merge 1 commit into
masterfrom
locsec/WI-9a6a0fac
Draft

ci: harden release workflows (SHA-pin actions, least-privilege token, TLS timestamper)#64
07souravkunda wants to merge 1 commit into
masterfrom
locsec/WI-9a6a0fac

Conversation

@07souravkunda

@07souravkunda 07souravkunda commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Supply-chain hardening for the workflows that build, sign and publish the BrowserStackLocal NuGet package. Three independent weaknesses, all in CI/CD config.

CWE-829 — third-party actions pinned by mutable tag

cd.yml and ci.yml referenced 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 holds NUGET_API_KEY and 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:

action @vN resolves to first vN.0.0
actions/checkout 0717577d (2026-07-16) 722adc63 (2019-12-13)
actions/setup-dotnet 55ec9447 (2025-04-17) c7e7147f (2022-09-29)
actions/upload-artifact ea165f8d (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: block

Neither workflow declared one, so GITHUB_TOKEN inherited the org/repo default. Both now declare contents: read at 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.sh authenticates with GCP_SA_KEY; dotnet nuget push authenticates with NUGET_API_KEY; upload-artifact@v4 uses the Actions runtime token, not GITHUB_TOKEN scopes.

CWE-311 — signing timestamp requested over plain HTTP

scripts/sign_nupkg.sh contacted the RFC 3161 timestamp authority at http://timestamp.sectigo.com. Now https://.

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:

$ openssl ts -query -data data.txt -sha256 -cert -out req.tsq
$ curl -H "Content-Type: application/timestamp-query" --data-binary @req.tsq \
       https://timestamp.sectigo.com -o resp.tsr        # 200, 6635 bytes
$ openssl ts -reply -in resp.tsr -text
  Status: Granted.
  Policy OID: 1.3.6.1.4.1.6449.2.1.1
  TSA: /C=GB/O=Sectigo Limited/CN=Sectigo Public Time Stamping Signer R37

Same authority and policy OID as the HTTP endpoint — a transport change only.

Testing

Static validation; no workflow run was triggered. cd.yml publishes to nuget.org and signs with the production key, so dispatching it to test a config change would cut a real release.

  • All workflow YAML parses; bash -n scripts/sign_nupkg.sh passes.
  • No mutable @tag refs remain; all nine uses: refs are 40-hex; each pin verified equal to its live tag target.
  • permissions == {contents: read} in both files, with no job-level widening.
  • No http:// left under .github/ or scripts/.
  • Semantic diff of the parsed workflows: step graph identical apart from permissions and 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.yml on this PR's branch to confirm the pins and the narrowed token still run green. cd.yml is best exercised by the next real release.

Follow-ups (not in this PR)

  • The pinned versions are current-but-old upstream (actions/checkout@v2, and actions/setup-java@v4 is now deprecated upstream). Bumping majors is a functional change and belongs on its own merits.
  • browserstack-local-ruby's gem-push.yml has the same tag-pinned shape on its gem publish path.

… 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>
@07souravkunda 07souravkunda self-assigned this Aug 26, 2026
Comment thread .github/dependabot.yml
Comment on lines +7 to +12
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"

@07souravkunda 07souravkunda left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/checkout 0717577d, microsoft/setup-msbuild c26a08ba, actions/setup-dotnet 55ec9447, actions/setup-java cf277c60, actions/upload-artifact ea165f8d). All 9 refs across both files are 40-hex; no mutable ref remains.
  • contents: read is sufficient — read every step in both workflows; none consumes GITHUB_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 changesBrowserStackLocal.csproj:22 includes only MIT-LICENSE.txt as a non-code file, so the .github/ and scripts/ edits can't reach the nupkg.
  • The description's claims about master (no permissions: block, no set-output anywhere, nuget/setup-nuget removed, plain-HTTP timestamper relocated to scripts/sign_nupkg.sh:40) all check out against origin/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.

Comment thread .github/workflows/ci.yml

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@0717577d45739eb3c851188b29f50ed6c0b2194e # v2

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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@v20717577d, microsoft/setup-msbuild@v1.0.2c26a08ba, actions/setup-dotnet@v355ec9447, actions/setup-java@v4cf277c60, actions/upload-artifact@v4ea165f8d. No pin points at a fork or an unrelated commit, so the runner executes the same action code as before.
  • contents: read is sufficient: I read every step in both workflows and none of them consumes GITHUB_TOKEN — checkout needs contents: read; msbuild/dotnet/java setup make no API calls; sign_nupkg.sh uses GCP_SA_KEY; dotnet nuget push uses NUGET_API_KEY; upload-artifact@v4 uses the Actions runtime token. No job-level block widens it.
  • Nothing packaged changes: BrowserStackLocal.csproj:22 includes only MIT-LICENSE.txt as a non-code file, so nothing under .github/ or scripts/ 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).

Comment thread .github/dependabot.yml
# is what stops those pins from going stale.
version: 2
updates:
- package-ecosystem: "github-actions"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread .github/workflows/ci.yml

# 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants