Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 29 additions & 25 deletions .github/workflows/ci-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:
workflow_dispatch:
inputs:
promote_production:
description: "Promote the already-built main commit to production"
description: "Record a human production approval for the already-staged release"
required: true
default: "false"
type: choice
Expand Down Expand Up @@ -254,36 +254,32 @@ jobs:
name: Classify main push
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
build_required: ${{ steps.classify.outputs.build_required }}
steps:
- name: Checkout release source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 2

- name: Decide whether this is a release build or GitOps-only promotion
id: classify
- name: Classify production desired-state update
shell: bash
run: |
set -euo pipefail
changed="$(git diff --name-only HEAD^1 HEAD || true)"
non_gitops="$(printf '%s\n' "$changed" | grep -Ev '^(k8s/values-staging.yaml|k8s/values-prod.yaml|k8s/values-azure.yaml)$' || true)"

if [[ -z "$non_gitops" ]]; then
echo "build_required=false" >> "$GITHUB_OUTPUT"
echo "Main push only changed GitOps values; production ArgoCD will sync without rebuilding."
echo "Main push only changed GitOps values; production ArgoCD will sync the promoted image without rebuilding."
exit 0
fi

echo "build_required=true" >> "$GITHUB_OUTPUT"
echo "Main push changed release source/config; build, sign, verify, scan, staging, DAST, and promotion are required."
echo "Main source/config changed after a reviewed release PR."
echo "Image build, signing, staging deployment, and DAST are performed from dev before promotion."

build-sign-verify-scan:
name: Build, sign, verify, and scan image
runs-on: ubuntu-latest
needs: classify-main-push
if: needs.classify-main-push.outputs.build_required == 'true'
needs: dev-integration-security-scan
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -396,17 +392,23 @@ jobs:
run: cosign sign --yes "${{ steps.push_acr.outputs.image_digest }}"

- name: Verify ECR signature identity and crypto policy
env:
IMAGE_DIGEST: ${{ steps.push_ecr.outputs.image_digest }}
SIGNATURE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/ci-pipeline.yml@${{ github.ref }}
run: |
bash scripts/verify-cosign-signature-policy.sh \
"${{ steps.push_ecr.outputs.image_digest }}" \
"https://github.com/${{ github.repository }}/.github/workflows/ci-pipeline.yml@refs/heads/main"
"$IMAGE_DIGEST" \
"$SIGNATURE_IDENTITY"

- name: Verify ACR signature identity and crypto policy
if: env.ACR_LOGIN_SERVER != ''
env:
IMAGE_DIGEST: ${{ steps.push_acr.outputs.image_digest }}
SIGNATURE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/ci-pipeline.yml@${{ github.ref }}
run: |
bash scripts/verify-cosign-signature-policy.sh \
"${{ steps.push_acr.outputs.image_digest }}" \
"https://github.com/${{ github.repository }}/.github/workflows/ci-pipeline.yml@refs/heads/main"
"$IMAGE_DIGEST" \
"$SIGNATURE_IDENTITY"

- name: Trivy image scan after signature verification
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
Expand All @@ -420,7 +422,7 @@ jobs:
name: Deploy staging through ArgoCD GitOps
runs-on: ubuntu-latest
needs: build-sign-verify-scan
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Checkout release source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
Expand Down Expand Up @@ -475,7 +477,7 @@ jobs:
name: DAST staging smoke and baseline scan
runs-on: ubuntu-latest
needs: deploy-staging-gitops
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Require staging URL
if: vars.STAGING_URL == ''
Expand Down Expand Up @@ -516,12 +518,12 @@ jobs:
needs:
- build-sign-verify-scan
- dast-staging
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 2
fetch-depth: 0

- name: Check whether promotion is needed
id: promote
Expand All @@ -530,7 +532,7 @@ jobs:
message="$(git log -1 --pretty=%s)"
changed="$(git diff --name-only HEAD^1 HEAD || true)"

if [[ "$message" == chore:\ promote\ images* ]]; then
if [[ "$message" == chore:\ promote\ staged\ images* ]]; then
echo "should_promote=false" >> "$GITHUB_OUTPUT"
exit 0
fi
Expand Down Expand Up @@ -578,13 +580,15 @@ jobs:
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c
with:
token: ${{ secrets.ACTIONS_PR_TOKEN || github.token }}
base: main
branch: promote/images-${{ github.sha }}
title: "chore: promote images for ${{ github.sha }}"
commit-message: "chore: promote images for ${{ github.sha }}"
title: "chore: promote staged images for ${{ github.sha }}"
commit-message: "chore: promote staged images for ${{ github.sha }}"
body: |
Promote signed images built from `${{ github.sha }}`.
Promote signed images built from dev commit `${{ github.sha }}`.

This PR updates the GitOps values files only. Merge it after reviewing the successful build, signature verification, image scan, SBOM, and DAST jobs.
This release PR carries the tested source changes plus the production GitOps values that pin the exact image digests verified in staging.
Merge it after reviewing the successful dev build, signature verification, image scan, SBOM, staging deploy, and DAST jobs.
labels: gitops,promotion

production-approval:
Expand All @@ -598,5 +602,5 @@ jobs:
run: |
echo "Production promotion approved for commit ${GITHUB_SHA}."
echo "This job is the human approval boundary."
echo "Production should promote a previously signed image digest, not rebuild or deploy from a direct main push."
echo "Production should promote a previously signed dev image digest, not rebuild or deploy from a direct main push."
echo "ArgoCD production sync is intentionally performed by GitOps, not by committing from CI."
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This repository is a production-inspired DevSecOps capstone for a small voting application. It combines application code, Terraform infrastructure, Kubernetes GitOps deployment, CI/CD security gates, image signing, admission policy, observability, runtime response, and disaster recovery.

The current implementation uses AWS as the active primary site and Azure as the warm standby site. AWS and Azure are connected by a route-based IPsec VPN with BGP. PostgreSQL data is replicated from AWS RDS to Azure PostgreSQL Flexible Server with native PostgreSQL logical replication.
The current implementation uses AWS as the active primary site and Azure as the warm standby site. AWS and Azure are connected by a route-based IPsec VPN with BGP. The codebase supports PostgreSQL logical replication from AWS RDS to Azure PostgreSQL Flexible Server; in the cost-capped demo, the Azure database standby can be enabled for a DR drill or left as a restore-required placeholder.

## Current Architecture

Expand All @@ -13,14 +13,14 @@ Developers
-> light feature security gate
-> merge to dev
-> integration security gate on combined work
-> release pull request from dev into main
-> full release gate on final code and deploy config
-> build images, generate SBOM, push immutable digests
-> Cosign keyless sign and verify identity plus crypto policy
-> Trivy image scan by digest
-> ArgoCD syncs staging from the staging GitOps branch
-> smoke test and OWASP ZAP DAST against staging
-> GitOps promotion PR updates production Helm values and image digests
-> promotion PR from tested dev commit into main
-> full release gate on final code and production deploy config
-> promotion PR updates production Helm values and image digests
-> ArgoCD syncs production from main after review
```

Expand All @@ -31,13 +31,13 @@ AWS primary site
Prometheus/Grafana, Loki/Promtail, Falco/Falcosidekick

Azure warm standby site
VNet, AKS, ACR, Azure Key Vault, Azure PostgreSQL Flexible Server
VNet, AKS, ACR, Azure Key Vault, optional Azure PostgreSQL Flexible Server
ArgoCD standby controller, External Secrets Operator

Cross-cloud path
AWS VPN Gateway <-> Azure Virtual Network Gateway
BGP routes carry private traffic between the two networks
PostgreSQL logical replication sends WAL deltas from AWS to Azure
Optional PostgreSQL logical replication sends WAL deltas from AWS to Azure during a DB standby drill
```

Route53 DNS failover support is implemented in `scripts/configure-route53-failover.ps1`, but it requires a real Route53 hosted zone/domain. The current AWS account has no hosted zone, so public DNS failover is ready as code but not live-configured.
Expand All @@ -49,17 +49,17 @@ On Azure, only the `vote` service is exposed with a public LoadBalancer by defau
| Area | Implementation |
| --- | --- |
| Local pre-commit | `.pre-commit-config.yaml` runs YAML hygiene, Gitleaks, Terraform format/validate, and optional local wrappers for Checkov, Hadolint, yamllint, and Semgrep when those CLIs are installed. CI remains the authoritative security gate. |
| PR security gates | Feature PRs into `dev` run fast feedback checks. Release PRs into `main` run full SAST, IaC, dependency, Helm render, and policy checks before release build is allowed. |
| PR security gates | Feature PRs into `dev` run fast feedback checks. Release/promotion PRs into `main` run full SAST, IaC, dependency, Helm render, and policy checks after staging has validated the built artifact. |
| Build security | Docker build for `vote`, `result`, and `worker`; Syft SPDX SBOM generation; Trivy image scan after signature verification. |
| Signing | Cosign keyless signing through GitHub Actions OIDC/Fulcio. Images are signed by digest, not mutable tag. CI verifies the certificate identity and rejects weak certificate/signature algorithms before scanning and promotion. |
| Registry | Images are pushed to AWS ECR and Azure ACR. ECR tags are immutable and scan-on-push is enabled. |
| GitOps staging | After main build/sign/verify/scan, CI updates the `staging` branch with `k8s/values-staging.yaml`. The AWS ArgoCD staging app tracks that branch and deploys to `voting-staging`. |
| GitOps production | After staging smoke test and ZAP DAST, CI opens a promotion PR that updates `k8s/values-prod.yaml` and `k8s/values-azure.yaml` on `main`. The AWS and Azure production ArgoCD apps deploy only after that PR is reviewed and merged. |
| GitOps staging | After `dev` build/sign/verify/scan, CI updates the `staging` branch with `k8s/values-staging.yaml`. The AWS ArgoCD staging app tracks that branch and deploys to `voting-staging`. |
| GitOps production | After staging smoke test and ZAP DAST, CI opens a promotion PR from the tested `dev` commit into `main`, updating `k8s/values-prod.yaml` and `k8s/values-azure.yaml`. The AWS and Azure production ArgoCD apps deploy only after that PR is reviewed and merged. |
| Admission policy | Gatekeeper rejects unsafe Kubernetes manifests. AWS EKS enforces signed ECR images with Sigstore policy-controller. |
| Secrets | External Secrets Operator syncs AWS Secrets Manager and Azure Key Vault into Kubernetes. Secrets are not committed in Helm values. |
| Runtime security | Falco detects suspicious runtime behavior and can open a GitHub incident workflow. Quarantine is a manual approval workflow. |
| Observability | kube-prometheus-stack, Grafana SLI/SLO dashboard, Loki, Promtail, and PrometheusRule resources. |
| DR | Azure warm standby, ArgoCD sync, PostgreSQL logical replication, and Route53 failover script when a hosted zone exists. |
| DR | Azure warm standby, ArgoCD sync, optional PostgreSQL logical replication or restore-required DB placeholder, and Route53 failover script when a hosted zone exists. |

## Repository Structure

Expand Down Expand Up @@ -168,7 +168,7 @@ kubectl --context arn:aws:eks:us-east-1:800557027783:cluster/voting-app-cluster
-n argocd get application voting-staging voting-production

kubectl --context devsecops-voting-aks `
-n argocd get application voting-azure-production
-n argocd get application voting-azure
```

Expected result: AWS staging and production should be `Synced Healthy`; Azure production should be healthy after the warm-standby sync succeeds.
Expand All @@ -180,6 +180,11 @@ Use the verified demo runbook:
```text
docs/teacher-aligned-demo-runbook.md
docs/devsecops-phase-model.md
docs/teacher-aligned-architecture.md
docs/architecture-conceptual.md
docs/devsecops-pipeline-diagram.md
docs/pipeline-frameworks.md
docs/demo-checklist.md
```

Useful scripts:
Expand Down Expand Up @@ -214,6 +219,6 @@ az network vnet-gateway list -o table

- Azure ACR uses Basic SKU. Premium-only controls such as private endpoints, geo-replication, image quarantine, and Defender integration are documented as production hardening items.
- Azure image verification with Kyverno is disabled by default because Kyverno cannot verify private ACR manifests without extra registry credentials. AWS EKS remains the enforced signed-image admission path through Sigstore policy-controller.
- Azure warm standby uses an in-cluster Redis service to avoid extra managed Redis cost. PostgreSQL state is still replicated with native logical replication.
- Azure warm standby uses an in-cluster Redis service to avoid extra managed Redis cost. PostgreSQL standby is optional in the cost-capped demo; when disabled, Azure runtime secrets intentionally point at a restore-required database host.
- Route53 failover requires a real hosted zone. The script exists, but no hosted zone is currently present in the AWS account.
- GitHub branch protection is configured, but repository owners/admins can still bypass unless admin bypass is explicitly disabled in GitHub rulesets.
Loading
Loading