Skip to content

Commit d6c3131

Browse files
FlyM1ssclaude
andauthored
feat(security): environment-gate the prod deploy secrets (#329)
DEPLOY_SSH_KEY (prod SSH) and GHCR_READ_TOKEN were repo-scoped Actions secrets: any workflow on any ref could release them. They now live in the Production environment (deployment branch policy: main only), and all three deploy jobs (backend/heartbeat/concierge) declare environment: Production. The prod SSH key was also rotated to a dedicated CI-only keypair as part of the move -- the old secret doubled as a personal login key. The old value keeps working until the repo-scoped copies are deleted after this deploys green. A new grep-sentinel test pins the environment reference on the backend deploy job: once the repo-scoped secrets are gone, dropping the environment key would make the deploy-gate skip deploys silently, forever. Closes SEC Root F (finsearch-security-cicd-hardening-08). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f45d76e commit d6c3131

4 files changed

Lines changed: 29 additions & 0 deletions

File tree

.github/workflows/backend-deploy.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ jobs:
125125
deploy:
126126
runs-on: ubuntu-latest
127127
if: ${{ github.ref == 'refs/heads/main' }}
128+
# DEPLOY_SSH_KEY / GHCR_READ_TOKEN are ENVIRONMENT secrets (Production has a
129+
# deployment branch policy of main only), not repo-scoped: even a workflow
130+
# edited or dispatched on a non-main ref cannot release the prod SSH key.
131+
# This gates the secret itself, independently of the ref-gate above -- the
132+
# two layers fail separately (same stanza as the heartbeat/concierge jobs).
133+
environment:
134+
name: Production
135+
url: https://agenticfinsearch.org
128136
# Serialize deploys so two merges in quick succession can't land out of order,
129137
# leave the droplet on the older commit, or -- now that superseded images are
130138
# retired below -- rmi an in-flight peer's freshly pulled, still container-less

.github/workflows/concierge-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ jobs:
4949
if: ${{ github.ref == 'refs/heads/main' }}
5050
runs-on: ubuntu-latest
5151
needs: test
52+
# DEPLOY_SSH_KEY is an ENVIRONMENT secret (Production: deployment branch
53+
# policy = main only) -- non-main refs cannot release the prod SSH key even
54+
# if the ref-gate above is edited away (same stanza as backend/heartbeat).
55+
environment: Production
5256
# Serialize deploys so two merges in quick succession can't land out of order and
5357
# leave the droplet on the older commit.
5458
concurrency:

.github/workflows/heartbeat-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ jobs:
4343
if: ${{ github.ref == 'refs/heads/main' }}
4444
runs-on: ubuntu-latest
4545
needs: test
46+
# DEPLOY_SSH_KEY is an ENVIRONMENT secret (Production: deployment branch
47+
# policy = main only) -- non-main refs cannot release the prod SSH key even
48+
# if the ref-gate above is edited away (same stanza as backend/concierge).
49+
environment: Production
4650
# Serialize deploys so two merges in quick succession can't land
4751
# out of order and leave the droplet on the older commit.
4852
concurrency:

Main/backend/tests/test_dockerfile_nonroot.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,19 @@ def test_image_retirement_never_forces_and_never_prunes_all(self):
232232
self.assertIsNone(re.search(r"\bprune\b[^\n]*(\s-\w*a\b|\s--all\b)", code))
233233
self.assertIsNone(re.search(r"""--filter[= ]["']?until""", code))
234234

235+
def test_deploy_job_bound_to_production_environment(self):
236+
# DEPLOY_SSH_KEY (the prod SSH key) and GHCR_READ_TOKEN are ENVIRONMENT
237+
# secrets on Production, whose deployment branch policy is main-only. The
238+
# job must reference that environment or the secrets simply don't resolve
239+
# -- and, critically, a workflow edited/dispatched on a non-main ref can
240+
# never release them, independently of the ref-gate `if:`. Dropping this
241+
# key would silently fall back to... nothing (the repo-scoped copies are
242+
# deleted), so the deploy-gate step would skip deploys forever.
243+
wf = _read(DEPLOY_WORKFLOW)
244+
deploy_job = wf[wf.index("\n deploy:"):wf.index("Deploy to Fedora droplet")]
245+
self.assertIn("environment:", deploy_job)
246+
self.assertIn("name: Production", deploy_job)
247+
235248
def test_deploy_job_serialized_by_concurrency_group(self):
236249
# Without serialization, two quick-succession merges run overlapping deploy
237250
# scripts against the same droplet: they can land out of order, and the

0 commit comments

Comments
 (0)