Skip to content

sec(iac/gcp): scope cleanup-function SA to the one secret it reads - #1682

Merged
cristim merged 3 commits into
mainfrom
sec/1614-gcp-cleanup-secret-scope
Aug 3, 2026
Merged

cristim merged 3 commits into
mainfrom
sec/1614-gcp-cleanup-secret-scope

Conversation

@cristim

@cristim cristim commented Jul 29, 2026

Copy link
Copy Markdown
Member

Closes #1614

What changed

  1. terraform/modules/compute/gcp/cleanup-function/main.tf: replaces the project-scope google_project_iam_member.cleanup_secrets with a google_secret_manager_secret_iam_member bound to var.db_password_secret_id, mirroring the migration already applied to the sibling Cloud Run module (compute/gcp/cloud-run/main.tf:262-267).
  2. scripts/check-gcp-secret-scope.sh + self-tests + a gating gcp-secret-scope CI job: fails when any Terraform file binds a roles/secretmanager.* role through a scope-wide IAM resource.

Reachability is narrower than the issue states

The issue describes this as live tenant-credential compromise. It is a real defect and worth fixing, but the exposure is latent rather than active, and the PR should not claim otherwise:

The GCP cleanup-function module is not instantiated anywhere in this repo. No module block sources it. terraform/environments/gcp/ wires only compute/gcp/cloud-run and compute/gcp/gke; the same is true of the .bak. So no service account currently holds project-wide secretAccessor as a result of this resource, and the issue's failure scenario ("anyone who compromises the cleanup function...") presumes a deployed function that this repo does not deploy.

What is real: the module is committed, its AWS and Azure counterparts exist, and the moment anyone wires it into an environment it ships a project-wide grant. That is a landmine, not an active breach. I would suggest re-triaging off priority/p0 / severity/critical on that basis; I have mirrored the issue's existing labels rather than unilaterally downgrading someone else's triage.

If the module was ever applied out of band, this change is still the right one and is safe: Terraform destroys the project-level binding and creates the per-secret binding, and the function keeps the only access it actually uses.

How the one-secret list was established

Narrowing IAM breaks things silently when the enumeration is wrong, so this is the evidence rather than an assertion:

  • cmd/cleanup-lambda/main.go (the cleanupExpiredRecords entry point named at main.tf:23) touches secrets only through database.OpenFromEnv. Its whole body is two SQL statements against sessions and purchase_executions.
  • internal/database/open_from_env.go builds a secret resolver only when dbConfig.PasswordSecret != "", and resolves that single value through NewConnection. No other secret is fetched on that path.
  • internal/secrets/gcp_resolver.go GetSecret issues AccessSecretVersion against the one named secret. ListSecrets exists but is not on this path, and secretAccessor does not grant list anyway.
  • The module's environment_variables block wires exactly one secret: DB_PASSWORD_SECRET = var.db_password_secret_id.

So the function reads one secret, and that is the one now bound.

Nothing referenced the removed resource address (outputs.tf exposes only the function URI/name/schedule and the SA email), so removing it is self-contained.

The guard

The bug survived because nothing looked for it: Cloud Run was migrated, its sibling was not, and there was no mechanism to notice. The guard closes that.

  • Catches google_{project,folder,organization}_iam_{member,binding} carrying a roles/secretmanager.* role. Folder and org scope are included because they are strictly broader than project scope.
  • Deliberately quiet on per-secret bindings and on scope-wide grants of non-Secret-Manager roles, so the existing roles/cloudsql.client project grants do not trip it.
  • Added to ci-success.needs, so it gates rather than merely reporting.
  • The terraform/ tree is clean today (the cleanup-function grant was the only project-scope Secret Manager binding in the repo), so no allowlist or suppression was needed. Nothing pre-existing is being masked.
  • Documented limitation: it is textual, not a policy engine. A role supplied via a variable is invisible to it. It is a ratchet against copy-paste reintroduction, which is how this arrived, not a proof of absence.

Verification

  • Regression proof: the guard exits 1 on the pre-fix cleanup-function/main.tf, naming the exact line, and exits 0 on the fixed file.
  • scripts/test-gcp-secret-scope.sh: 7/7 pass, covering both directions plus usage errors (exit 2) so a broken invocation is distinguishable from a real finding.
  • terraform fmt -check clean; terraform init -backend=false + terraform validate on the module: "The configuration is valid."
  • Full pre-commit on all changed files: Terraform format/validate/lint, trivy config, AWS secret scan and the rest all pass. No --no-verify.
  • Three review passes on the staged diff across Completeness, Correctness, Security, Bugs and Duplication. The one finding (the guard overclaimed its own strength) was fixed and re-verified.

Not in scope

The AWS sibling compute/aws/cleanup-lambda was checked and is already correctly scoped (Resource = var.db_password_secret_arn), so there is no sibling issue to file.

@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/s Hours type/security Security finding labels Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0a68ef4-58ee-4c24-b091-034984f33289

📥 Commits

Reviewing files that changed from the base of the PR and between b117634 and 7c32b01.

📒 Files selected for processing (10)
  • .github/workflows/ci.yml
  • scripts/check-gcp-secret-scope.sh
  • scripts/test-gcp-secret-scope.sh
  • scripts/testdata/gcp-secret-scope/clean.tf.fixture
  • scripts/testdata/gcp-secret-scope/folder-scope.tf.fixture
  • scripts/testdata/gcp-secret-scope/json-encoding.tf.json
  • scripts/testdata/gcp-secret-scope/org-scope.tf.fixture
  • scripts/testdata/gcp-secret-scope/project-binding.tf.fixture
  • scripts/testdata/gcp-secret-scope/project-scope.tf.fixture
  • terraform/modules/compute/gcp/cleanup-function/main.tf

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 55 minutes.

cristim added a commit that referenced this pull request Aug 3, 2026
Adversarial review of #1682 found the guard's self-test asserting a property the
guard does not check, and the scan root missing a whole tree. Both are the same
defect class the guard exists to catch: a green check standing in for a
condition nobody verified.

Scan root now covers `iac/` as well as `terraform/`. That is 20 further files,
including the customer-facing federation modules, which are exactly the code
that must not ship a scope-wide grant. Verified the extension reds nothing: 177
files under terraform/ plus 20 under iac/ = 197 scanned, exit 0.

The self-test previously asserted "repository terraform/ tree is clean". That
was false. `terraform/environments/gcp/ci-cd-permissions/service_account.tf`
grants project-scope `roles/secretmanager.admin` through `locals` + `for_each`,
which the per-resource-block scanner structurally cannot see, so the guard exits
0 on it. That grant is believed legitimate (the Terraform deploy service account
creates secrets), so the fix is to the claim, not the grant: the case now
asserts only what the guard verifies, and names the known gap. Resolving
`locals`/`for_each` is tracked in #1686. An allowlist entry was considered and
rejected: an entry for a grant the parser cannot detect would never be
exercised, which would be a second false assurance rather than a fix.

`.tf.json` is now discovered and REJECTED with exit 2 rather than scanned. The
scanner matches HCL block syntax, so reporting a JSON-encoded file as clean
would be the precise failure being guarded against. None exist today; if one is
added the guard stops instead of passing it over.

Test gaps closed: `google_project_iam_binding` had no fixture at all (it worked,
but untested), and folder and organization scope shared one fixture, so a guard
catching only one of them still passed. Split into separate cases.

Suite is 11/11, covering exit 0 (clean), exit 1 (each violating shape
individually) and exit 2 (usage error, unreadable encoding).

Follow-ups filed, deliberately not fixed here: #1686 locals/for_each blind spot,
#1687 google_project_iam_policy plus data block and the unindented-resource
bypass, #1688 ci-success not failing on skipped jobs.

Refs #1614
cristim added 3 commits August 3, 2026 15:24
`google_project_iam_member.cleanup_secrets` granted the cleanup function's
service account `roles/secretmanager.secretAccessor` at PROJECT scope, ungated.
That made every secret in the project readable by a session-cleanup job,
including the AES-256-GCM credential-encryption key that decrypts stored
customer cloud credentials, the JWT and session secrets, and the SendGrid API
key.

The function reads exactly one secret. `cmd/cleanup-lambda/main.go` only calls
`database.OpenFromEnv`, which builds a secret resolver solely when
`dbConfig.PasswordSecret` is non-empty and resolves that single value via
`AccessSecretVersion`; the module wires only `DB_PASSWORD_SECRET =
var.db_password_secret_id` into the function environment. No other secret is
reachable from that code path.

Replace the project-scope binding with a `google_secret_manager_secret_iam_member`
scoped to `var.db_password_secret_id`, mirroring the migration already applied
to the sibling Cloud Run module (compute/gcp/cloud-run/main.tf:262-267). The
cleanup function was never migrated when Cloud Run was.

Refs #1614
The over-broad grant fixed in the previous commit arrived by copy-paste and
survived because nothing looked for it: `compute/gcp/cloud-run` was migrated to
per-secret bindings and its sibling `compute/gcp/cleanup-function` was not, with
no mechanism to notice the gap.

Add `scripts/check-gcp-secret-scope.sh`, which fails when a Terraform file binds
a `roles/secretmanager.*` role through a scope-wide IAM resource
(`google_{project,folder,organization}_iam_{member,binding}`). Per-secret
bindings and scope-wide grants of non-Secret-Manager roles are left alone, so
the existing `roles/cloudsql.client` project grants stay quiet.

Wire it into CI as the `gcp-secret-scope` job, following the existing
azure-role-parity / aws-iam-parity shape, and add it to `ci-success.needs` so it
actually gates rather than reporting alongside.

`scripts/test-gcp-secret-scope.sh` exercises the guard in both directions:
clean input must exit 0, each violating shape must exit 1, and usage errors must
exit 2 so a broken invocation is distinguishable from a real finding. Verified
the guard fires on the pre-fix `cleanup-function/main.tf` at the exact line and
passes on the fixed one; the whole `terraform/` tree is clean today, so no
allowlist or suppression was needed.

The guard is textual, not a policy engine: a role supplied via a variable is
invisible to it. That limitation is documented in the script rather than implied
away.

Closes #1614
Adversarial review of #1682 found the guard's self-test asserting a property the
guard does not check, and the scan root missing a whole tree. Both are the same
defect class the guard exists to catch: a green check standing in for a
condition nobody verified.

Scan root now covers `iac/` as well as `terraform/`. That is 20 further files,
including the customer-facing federation modules, which are exactly the code
that must not ship a scope-wide grant. Verified the extension reds nothing: 177
files under terraform/ plus 20 under iac/ = 197 scanned, exit 0.

The self-test previously asserted "repository terraform/ tree is clean". That
was false. `terraform/environments/gcp/ci-cd-permissions/service_account.tf`
grants project-scope `roles/secretmanager.admin` through `locals` + `for_each`,
which the per-resource-block scanner structurally cannot see, so the guard exits
0 on it. That grant is believed legitimate (the Terraform deploy service account
creates secrets), so the fix is to the claim, not the grant: the case now
asserts only what the guard verifies, and names the known gap. Resolving
`locals`/`for_each` is tracked in #1686. An allowlist entry was considered and
rejected: an entry for a grant the parser cannot detect would never be
exercised, which would be a second false assurance rather than a fix.

`.tf.json` is now discovered and REJECTED with exit 2 rather than scanned. The
scanner matches HCL block syntax, so reporting a JSON-encoded file as clean
would be the precise failure being guarded against. None exist today; if one is
added the guard stops instead of passing it over.

Test gaps closed: `google_project_iam_binding` had no fixture at all (it worked,
but untested), and folder and organization scope shared one fixture, so a guard
catching only one of them still passed. Split into separate cases.

Suite is 11/11, covering exit 0 (clean), exit 1 (each violating shape
individually) and exit 2 (usage error, unreadable encoding).

Follow-ups filed, deliberately not fixed here: #1686 locals/for_each blind spot,
#1687 google_project_iam_policy plus data block and the unindented-resource
bypass, #1688 ci-success not failing on skipped jobs.

Refs #1614
@cristim
cristim force-pushed the sec/1614-gcp-cleanup-secret-scope branch from 4157147 to 7c32b01 Compare August 3, 2026 13:25
@cristim
cristim merged commit 6d854c8 into main Aug 3, 2026
20 checks passed
@cristim
cristim deleted the sec/1614-gcp-cleanup-secret-scope branch August 3, 2026 13:57
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Adversarial review record (merged)

Independent reviewer, distinct from the author. CodeRabbit's status on this PR read success while its description said "Review rate limited", so this pass is the substitute, not a confirmation of CR's.

The narrowed grant is provably sufficient — verified by enumerating the whole dependency closure rather than following the call chain. go list -deps ./cmd/cleanup-lambda returns five packages total; no credential-encryption, JWT/session or SendGrid package is reachable from this binary at all, so no other secret can be read regardless of control flow. Within that closure there is exactly one GetSecret call site. The binding and the env var also read the same variable, so they cannot drift apart in a later edit.

Reachability confirmed, and the issue is latent rather than live. No module block sources compute/gcp/cleanup-function; the only two references in the repo are .trivyignore and the guard's own comment. Two corroborations the author had not cited: main() is lambda.Start(...) — an AWS Lambda handler, not a GCP entrypoint — and the module's source object is placeholder.zip. This module has never been deployable as written, so #1614's p0/critical described a latent misconfiguration.

Six guard bypasses were proven, and one was live in the tree the guard certified clean: a project-scope roles/secretmanager.admin in terraform/environments/gcp/ci-cd-permissions/service_account.tf, invisible because the role literal sits in a locals block consumed via for_each. The others: for_each = toset(var.roles), role = var.secret_role, string-concatenation of the role name, google_project_iam_policy + a separate data "google_iam_policy" block, roles/owner, and an unindented resource block.

No false positives — clean on the real terraform/ tree, the whole iac/ tree, and the per-secret + roles/cloudsql.client fixture. Genuinely gating — no paths: filter, no if:, present in ci-success.needs, and the guard exits 1 on the pre-fix file naming the right line.

What the author changed in response, and why it was better than what was asked

I suggested adding an allowlist entry for the live ci-cd-permissions grant. The author declined, correctly: the parser structurally cannot see that grant, so an allowlist entry would match nothing, look like coverage, and activate silently only if someone later taught the parser about locals — a second false assurance rather than a fix. The claim was narrowed instead ("no directly expressed scope-wide grant"), with a LIMITATIONS block naming the live grant explicitly and pointing at LeanerCloud/cloud-commitments-platform#149.

I also suggested globbing .tf.json as "free". The author established it was actively harmful: the scanner matches HCL syntax, so a .tf.json file would be scanned, match nothing, and be reported clean. Those files are now rejected with exit 2, with a fixture containing a real violation proving the reject path fires.

Scan root extended to iac/ — 177 + 20 = 197 files scanned, exit 0, with the reported count matching the sum, which is how we know iac/ is genuinely walked rather than silently skipped.

Follow-ups filed

LeanerCloud/cloud-commitments-platform#149 (locals/for_each blind spot, includes the live grant), LeanerCloud/cloud-commitments-platform#150 (google_project_iam_policy + data block, unindented-resource bypass), #1688 (ci-success does not fail on skipped).

cristim added a commit that referenced this pull request Sep 27, 2026
…1682)

* sec(iac/gcp): scope cleanup-function SA to the one secret it reads

`google_project_iam_member.cleanup_secrets` granted the cleanup function's
service account `roles/secretmanager.secretAccessor` at PROJECT scope, ungated.
That made every secret in the project readable by a session-cleanup job,
including the AES-256-GCM credential-encryption key that decrypts stored
customer cloud credentials, the JWT and session secrets, and the SendGrid API
key.

The function reads exactly one secret. `cmd/cleanup-lambda/main.go` only calls
`database.OpenFromEnv`, which builds a secret resolver solely when
`dbConfig.PasswordSecret` is non-empty and resolves that single value via
`AccessSecretVersion`; the module wires only `DB_PASSWORD_SECRET =
var.db_password_secret_id` into the function environment. No other secret is
reachable from that code path.

Replace the project-scope binding with a `google_secret_manager_secret_iam_member`
scoped to `var.db_password_secret_id`, mirroring the migration already applied
to the sibling Cloud Run module (compute/gcp/cloud-run/main.tf:262-267). The
cleanup function was never migrated when Cloud Run was.

Refs #1614

* sec(ci): fail CI on project-scope Secret Manager grants in Terraform

The over-broad grant fixed in the previous commit arrived by copy-paste and
survived because nothing looked for it: `compute/gcp/cloud-run` was migrated to
per-secret bindings and its sibling `compute/gcp/cleanup-function` was not, with
no mechanism to notice the gap.

Add `scripts/check-gcp-secret-scope.sh`, which fails when a Terraform file binds
a `roles/secretmanager.*` role through a scope-wide IAM resource
(`google_{project,folder,organization}_iam_{member,binding}`). Per-secret
bindings and scope-wide grants of non-Secret-Manager roles are left alone, so
the existing `roles/cloudsql.client` project grants stay quiet.

Wire it into CI as the `gcp-secret-scope` job, following the existing
azure-role-parity / aws-iam-parity shape, and add it to `ci-success.needs` so it
actually gates rather than reporting alongside.

`scripts/test-gcp-secret-scope.sh` exercises the guard in both directions:
clean input must exit 0, each violating shape must exit 1, and usage errors must
exit 2 so a broken invocation is distinguishable from a real finding. Verified
the guard fires on the pre-fix `cleanup-function/main.tf` at the exact line and
passes on the fixed one; the whole `terraform/` tree is clean today, so no
allowlist or suppression was needed.

The guard is textual, not a policy engine: a role supplied via a variable is
invisible to it. That limitation is documented in the script rather than implied
away.

Closes #1614

* sec(ci): make the Secret Manager guard cover iac/ and stop overclaiming

Adversarial review of #1682 found the guard's self-test asserting a property the
guard does not check, and the scan root missing a whole tree. Both are the same
defect class the guard exists to catch: a green check standing in for a
condition nobody verified.

Scan root now covers `iac/` as well as `terraform/`. That is 20 further files,
including the customer-facing federation modules, which are exactly the code
that must not ship a scope-wide grant. Verified the extension reds nothing: 177
files under terraform/ plus 20 under iac/ = 197 scanned, exit 0.

The self-test previously asserted "repository terraform/ tree is clean". That
was false. `terraform/environments/gcp/ci-cd-permissions/service_account.tf`
grants project-scope `roles/secretmanager.admin` through `locals` + `for_each`,
which the per-resource-block scanner structurally cannot see, so the guard exits
0 on it. That grant is believed legitimate (the Terraform deploy service account
creates secrets), so the fix is to the claim, not the grant: the case now
asserts only what the guard verifies, and names the known gap. Resolving
`locals`/`for_each` is tracked in #1686. An allowlist entry was considered and
rejected: an entry for a grant the parser cannot detect would never be
exercised, which would be a second false assurance rather than a fix.

`.tf.json` is now discovered and REJECTED with exit 2 rather than scanned. The
scanner matches HCL block syntax, so reporting a JSON-encoded file as clean
would be the precise failure being guarded against. None exist today; if one is
added the guard stops instead of passing it over.

Test gaps closed: `google_project_iam_binding` had no fixture at all (it worked,
but untested), and folder and organization scope shared one fixture, so a guard
catching only one of them still passed. Split into separate cases.

Suite is 11/11, covering exit 0 (clean), exit 1 (each violating shape
individually) and exit 2 (usage error, unreadable encoding).

Follow-ups filed, deliberately not fixed here: #1686 locals/for_each blind spot,
#1687 google_project_iam_policy plus data block and the unindented-resource
bypass, #1688 ci-success not failing on skipped jobs.

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

Labels

effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(iac/gcp): cleanup-function SA gets project-wide Secret Manager access to the encryption key

1 participant