

Three years ago, two days before a production push to Azure, I was working through a pull request on a Node.js service. Routine stuff. Then I spotted something in a commit that had been merged two weeks earlier. Already past review, already on the main branch. A Stripe secret key. Not obfuscated, not in a config file. Plain text, right there in the source. Build was green. Tests passed. Nobody had caught it.
That incident changed how I think about CI/CD. The problem wasn’t that my team was careless. The problem was that our pipeline had no opinion about security. It checked code quality. It ran tests. It deployed. But it never asked whether what we were shipping was safe.
DevSecOps is the answer to that question, but the term gets thrown around loosely. At its core, it just means one thing: Security checks run automatically, in the pipeline, on every change, the same way unit tests do. If a security check fails, the build fails. No exceptions, no “we’ll fix it in the next sprint.”
This article builds exactly that kind of pipeline on Azure using GitHub Actions: Static analysis, secrets detection, dependency auditing, deployment gates and audit logging — all wired into one workflow that runs on every PR and push to main. The YAML here is real; swap tool names to match your stack and it deploys.
The Pipeline at a Glance
Here’s the full sequence before we dive into each piece:
- Trigger: PR opened or push to main
- Job 1: SAST scan (CodeQL) — static analysis for code vulnerabilities
- Job 2: Secrets scan (Gitleaks) — catch leaked credentials before they land in the repo
- Job 3: Dependency check — npm audit or Snyk scanning third-party packages for CVEs
- Job 4: Build and test — standard compilation, unit tests, integration tests
- Job 5: Deploy to staging slot — push to Azure App Service staging, run smoke tests
- Job 6: Production gate — required reviewer must approve before anything touches prod
- Job 7: Slot swap and audit log — promote staging to production, write a deployment record to Azure Monitor
Running the first three jobs in parallel, before the build starts, isn’t arbitrary. Waiting on a slow build only to hit a security failure at the end is the worst possible timing. Usually, it hits when a release window is closing and the pressure to just push it anyway is at its highest. Security failures caught early get fixed. Security failures caught late get deferred.
SAST With CodeQL
SAST works on source code before anything is executed. No running server, no network call. It scans for coding patterns behind vulnerabilities such as SQL injection, XSS and insecure deserialization. These are genuinely hard to catch in a code review, especially when the team is under release pressure. GitHub’s CodeQL does this well, and it’s free for public repos.
Create .github/workflows/sast.yml:
name: SAST — CodeQL
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Analyze
uses: github/codeql-action/analyze@v3
with:
category: '/language:javascript'
fail-on-severity: high
Results land in the Security tab under Code Scanning Alerts. The fail-on-severity: high setting means the pipeline blocks on critical and high findings but surfaces medium and low as warnings you can triage later. That’s a pragmatic starting point. You can tighten it over time.
One thing I found: CodeQL’s JavaScript queries catch a surprising number of prototype pollution bugs and unsafe eval() patterns that conventional linting misses. It’s not a replacement for a manual security review, but it catches the mechanical stuff reliably.
Secrets Detection With Gitleaks
Here’s a number that should worry you: According to GitGuardian’s 2024 report, a secret is exposed on GitHub every five seconds — API keys, database URLs, JWT signing secrets, OAuth tokens. Developers commit them accidentally more than you’d think, often in a ‘quick fix’ commit made under pressure.
Gitleaks scans your commits for credential patterns before they reach the remote. Add it as a required check on every PR:
name: Secrets Scan — Gitleaks
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The fetch-depth: 0 is important. By default, actions/checkout does a shallow clone. Gitleaks needs the full commit history to scan past commits. A secret from six weeks ago in an old branch is still a secret.
Add a .gitleaks.toml at the repo root to handle false positives. Test fixtures that deliberately contain fake credentials are a common source of noise:
[allowlist]
paths = [
'''^test/fixtures/'''
'''^docs/examples/'''
]
One hard-learned lesson: If Gitleaks flags a real secret, deleting the commit isn’t enough. Rotate the credential immediately and assume it was already scraped. Bots index GitHub continuously, and there’s no way to know how long the exposure window was.
Dependency Auditing
Open your package-lock.json and scroll for a moment. A typical Node.js project carries several hundred packages, most of which you never explicitly installed. Every package you depend on is a potential attack surface, and new CVEs are disclosed every week. Without automated auditing, you won’t know about them until someone finds them in your app.
npm audit covers the basics. Snyk adds richer CVE data and tracks transitive vulnerabilities more thoroughly. Here’s a job that runs both:
name: Dependency Audit
on:
pull_request:
branches: [main]
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # weekly Monday scan
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: npm audit
run: npm audit --audit-level=high
- name: Snyk scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
The schedule trigger is the part most teams skip. It catches CVEs disclosed after your last deployment. A package you installed three months ago might have a critical vulnerability published yesterday. The weekly scan makes sure you don’t find out about it from a penetration tester.
Azure Deployment Gates
Code that passes every automated check still needs a human decision before it goes to production. Not for every deployment, that would kill velocity, but for production-bound changes, a required reviewer is your last line of defense.
GitHub Environments handle this cleanly. Go to your repo Settings > Environments > New environment, name it ‘production’ and add required reviewers. Any job targeting that environment pauses until someone on the list approves.
Pair that with Azure App Service deployment slots and you get a safe promotion workflow:
jobs:
deploy-staging:
runs-on: ubuntu-latest
steps:
- uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy to staging slot
uses: azure/webapps-deploy@v3
with:
app-name: my-app
slot-name: staging
package: ./dist
- name: Smoke tests
run: npm run test:smoke -- --url https://my-app-staging.azurewebsites.net
promote-to-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # triggers reviewer gate
steps:
- uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Swap staging to production
uses: azure/cli@v1
with:
inlineScript: |
az webapp deployment slot swap
--name my-app
--resource-group my-rg
--slot staging
--target-slot production
The slot swap is near-instant and zero-downtime. If something breaks in production after the swap, you can reverse it in the Azure portal in about 30 seconds — no redeploy, no rollback drama.
Audit Logging
After an incident, two questions come up immediately: What changed and who did it? GitHub Actions answers this for free through its workflow run history. Centralizing that data in Azure Monitor gives you a single place to query across your entire infrastructure — not just GitHub.
Add this to your deploy job:
- name: Write deployment record
uses: azure/cli@v1
with:
inlineScript: |
az monitor activity-log alert create
--name "deploy-${{ github.run_id }}"
--resource-group my-rg
--description "Actor: ${{ github.actor }} | SHA: ${{ github.sha }} | Ref: ${{ github.ref }}"
This creates a timestamped record in Azure Monitor for every production deployment: Who triggered it, from which commit and which branch. Pair it with Application Insights for runtime telemetry and you have end-to-end traceability — from code commit to live behavior.
Mistakes I See Teams Make
A few patterns that consistently undermine DevSecOps pipelines, based on implementations I’ve been part of or reviewed:
- Running Security Jobs After the Build: If your SAST scan comes last, engineers learn to ignore it. When it’s slow — and it sometimes is — it becomes the step that gets skipped under deadline pressure. Move security to the front.
- Storing Credentials in GitHub Variables Instead of Secrets: Environment variables show up in run logs. GitHub Actions encrypted secrets don’t. This is not a subtle distinction.
- Treating Low-Severity Findings as Permanent To-Dos: Low-severity findings pile up fast and teams mute them eventually. A vulnerability sitting in the backlog for eight months tends to show up during a live incident. 30 minutes a week to clear the queue stops it getting there.
- Single-Environment Deployments: If staging and production use the same service principle or the same deployment workflow without gates, a misconfigured commit goes straight to users. Separate credentials, separate environments, separate gates.
- Shallow Git Clones in Secrets Scanning: The default actions/checkout behavior is fetch-depth: 1. Gitleaks scanning only the latest commit will miss anything committed earlier. Always use fetch-depth: 0 for secrets scans.
Where to Go From Here
The pipeline described here covers the fundamentals. Done consistently, they prevent most incidents. No SIEM budget required, no AppSec hire. What actually changes the outcome is automated gates that run on every change and stop the build when something fails.
Start with CodeQL and Gitleaks. They’re free, they integrate cleanly with GitHub Actions and they’ll catch real problems within the first week. Add Snyk once you have a handle on your dependency surface. Deployment gates and audit logging can come in parallel — neither requires significant setup on Azure.
The shift-left principle isn’t complicated: Fix security problems when they’re cheap to fix, which is before the code is deployed. A pipeline that enforces this automatically is what makes that principle real.