Security Testing in Azure DevOps CI/CD
How to integrate security testing into Azure DevOps CI/CD pipelines. Covers SAST, DAST, dependency scanning with OWASP, secret detection, container.
Security testing in CI/CD means catching vulnerabilities before they reach production — not after a penetration test finds them six months later. Azure DevOps supports multiple security tools through Marketplace extensions and open-source integrations, and each can be added as a pipeline stage with a quality gate.
The DevSecOps pipeline model
PR Validation: Secret scanning, SAST, lint security rules
Build: Dependency vulnerability scan
Integration: DAST (dynamic analysis against staging)
Pre-release: Container scan, licence compliance
Production: Runtime monitoring (not pipeline — separate concern)
Each layer catches different vulnerability classes. Together they give defence in depth before code ships.
Stage 1: Secret scanning (every PR)
Prevent credentials from being committed. Use Gitleaks or detect-secrets:
YAML1- stage: SecretScan 2 displayName: Secret Detection 3 jobs: 4 - job: Gitleaks 5 steps: 6 - script: | 7 docker run --rm \ 8 -v $(Build.SourcesDirectory):/repo \ 9 zricethezav/gitleaks:latest \ 10 detect \ 11 --source /repo \ 12 --report-format sarif \ 13 --report-path gitleaks-report.sarif \ 14 --exit-code 1 15 displayName: Scan for secrets (Gitleaks) 16 17 - task: PublishPipelineArtifact@1 18 inputs: 19 targetPath: gitleaks-report.sarif 20 artifact: gitleaks-report 21 condition: always()
Add a .gitleaks.toml to the repo to allowlist known false positives (test credentials in fixture files):
TOML1[allowlist] 2 description = "Test fixture allowlist" 3 regexes = ['''testuser@example\.com''', '''Test@2025!'''] 4 paths = ['''tests/fixtures/.*''']
Stage 2: SAST — Static Application Security Testing
Microsoft Security DevLabs extension provides SAST tools:
YAML1- task: MicrosoftSecurityDevOps@1 2 displayName: SAST scan 3 inputs: 4 categories: 'secrets,code' 5 break: true # Fail pipeline if high severity found
For JavaScript/TypeScript, add ESLint security plugin as a code-level SAST:
BASH1npm install --save-dev eslint-plugin-security eslint-plugin-no-secrets
JSON1// .eslintrc.json 2{ 3 "plugins": ["security", "no-secrets"], 4 "extends": ["plugin:security/recommended"], 5 "rules": { 6 "no-secrets/no-secrets": "error" 7 } 8}
YAML1- script: npm run lint:security -- --format=junit --output-file=sast-results.xml 2 displayName: ESLint security rules 3 4- task: PublishTestResults@2 5 inputs: 6 testResultsFormat: JUnit 7 testResultsFiles: sast-results.xml 8 testRunTitle: SAST — $(Build.BuildNumber) 9 condition: always()
Stage 3: Dependency vulnerability scanning
OWASP Dependency-Check scans all npm/Maven/pip dependencies against the CVE database:
YAML1- task: dependency-check-build-task@6 2 displayName: OWASP Dependency Check 3 inputs: 4 projectName: '$(Build.Repository.Name)' 5 scanPath: '$(Build.SourcesDirectory)' 6 format: 'JUNIT' 7 failOnCVSS: '7' # Fail on CVSS score >= 7 (High/Critical) 8 suppressionPath: 'suppression.xml' # Known accepted vulnerabilities 9 10- task: PublishTestResults@2 11 inputs: 12 testResultsFormat: JUnit 13 testResultsFiles: dependency-check-junit.xml 14 testRunTitle: Dependency Scan — $(Build.BuildNumber) 15 condition: always()
For npm projects, also run the native npm audit:
YAML1- script: | 2 npm audit --audit-level=high --json > npm-audit.json || true 3 node -e " 4 const report = require('./npm-audit.json'); 5 const high = report.metadata.vulnerabilities.high; 6 const critical = report.metadata.vulnerabilities.critical; 7 if (high + critical > 0) { 8 console.error(\`Found \${critical} critical, \${high} high vulnerabilities\`); 9 process.exit(1); 10 } 11 " 12 displayName: npm audit gate
Stage 4: DAST — Dynamic Application Security Testing
Run OWASP ZAP against the staging environment after deployment:
YAML1- stage: DAST 2 displayName: Dynamic Security Testing 3 dependsOn: DeployStaging 4 jobs: 5 - job: ZAP 6 steps: 7 - script: | 8 docker run --rm \ 9 -v $(Build.ArtifactStagingDirectory):/zap/wrk \ 10 ghcr.io/zaproxy/zaproxy:stable \ 11 zap-baseline.py \ 12 -t $(STAGING_URL) \ 13 -r zap-report.html \ 14 -x zap-report.xml \ 15 -l WARN \ 16 -I # Don't fail on warnings, only errors 17 displayName: OWASP ZAP baseline scan 18 19 - task: PublishPipelineArtifact@1 20 inputs: 21 targetPath: $(Build.ArtifactStagingDirectory)/zap-report.html 22 artifact: zap-report 23 condition: always()
ZAP baseline scan checks for common OWASP Top 10 issues (missing security headers, XSS, SQL injection patterns) without active exploitation.
Stage 5: Container image scanning
If your application runs in Docker:
YAML1- task: trivy-azure-pipelines-task@1 2 displayName: Trivy container scan 3 inputs: 4 version: latest 5 image: '$(ACR_NAME).azurecr.io/$(IMAGE_NAME):$(Build.BuildId)' 6 severities: 'CRITICAL,HIGH' 7 ignoreUnfixed: true 8 exitCode: '1'
Or use Microsoft Defender for Containers if images are pushed to Azure Container Registry.
Security gate for production
Block production deployments when critical security issues exist:
YAML1- stage: SecurityGate 2 displayName: Security Quality Gate 3 dependsOn: [DAST, DependencyScan] 4 jobs: 5 - job: Gate 6 steps: 7 - script: | 8 CRITICAL=$(cat dependency-check-results.json | jq '.dependencies[].vulnerabilities[] | select(.severity == "CRITICAL") | .name' | wc -l) 9 if [ "$CRITICAL" -gt 0 ]; then 10 echo "##vso[task.logissue type=error]$CRITICAL critical vulnerabilities — blocking release" 11 exit 1 12 fi 13 displayName: Critical vulnerability gate
Common errors and fixes
Error: Gitleaks flags test fixtures as secret leaks
Fix: Create a .gitleaks.toml with [allowlist] entries for known test values, or exclude the tests/fixtures/ directory entirely.
Error: OWASP Dependency-Check takes 20+ minutes in the pipeline
Fix: Enable the --cacheToLocal option so the CVE database is downloaded once and cached. Use Cache@2 task to persist between pipeline runs.
Error: ZAP scan fails with authentication errors on staging
Fix: Pass authentication headers to ZAP: add --hook script that logs in first, or use ZAP's built-in form-based authentication configuration with zap-full-scan.py -z "-config replacer.full_list(0).description=auth".
Error: npm audit fails on known false positives with no fix available
Fix: Create an .npmrc audit suppression or use audit-ci with a config file: { "high": { "allowlist": ["GHSA-xyz-known-false-positive"] } }.
Share this article
Follow for more
Follow me on social media for more developer tips, tricks, and tutorials. Let's connect and build something great together!