cd ../blog
#DevSecOps#Azure DevOps#Trivy#Snyk#OPA#Security

DevSecOps Shift-Left in Azure DevOps: A Production Blueprint

October 8, 202510 min readSuhail Ahmed Inayathulla

DevSecOps Shift-Left in Azure DevOps: A Production Blueprint

Security as a last gate before production is dead. In my work at Revantage Asia and across Accenture engagements, I've baked security into every pipeline stage. Here's the exact blueprint.

The Shift-Left Mindset

Traditional: Dev → Test → Stage → [Security Scan] → Prod ← too late

Shift-left: [Sec] Dev → [Sec] Build → [Sec] Test → [Sec] Deploy → Prod

Cost of fixing a bug: Dev ($1) → Test ($10) → Prod ($100) → Breach ($10,000+)

The Five Security Layers

1. SAST — Static Application Security Testing

# azure-pipelines.yml
- task: Bash@3
  displayName: 'SAST - Semgrep Scan'
  inputs:
    targetType: 'inline'
    script: |
      docker run --rm -v $(Build.SourcesDirectory):/src \
        returntocorp/semgrep semgrep --config=auto \
        --sarif --output=/src/semgrep-results.sarif /src
      
- task: PublishTestResults@2
  inputs:
    testResultsFormat: 'JUnit'
    searchFolder: '$(Build.SourcesDirectory)'
    testResultsFiles: '**/semgrep-results.sarif'

2. SCA — Software Composition Analysis (Snyk)

- task: SnykSecurityScan@1
  displayName: 'SCA - Snyk Dependency Scan'
  inputs:
    serviceConnectionEndpoint: 'snyk-connection'
    testType: 'app'
    targetFile: 'requirements.txt'
    severityThreshold: 'high'
    failOnIssues: true
    monitorWhenNoIssues: true
    additionalArguments: '--all-projects --policy-path=.snyk'

3. Container Scanning (Trivy)

- task: Bash@3
  displayName: 'Container Scan - Trivy'
  inputs:
    targetType: 'inline'
    script: |
      # Install Trivy
      wget -qO- https://aquasecurity.github.io/trivy-repo/deb/public.key | \
        sudo apt-key add -
      sudo apt-get install -y trivy

      # Scan with fail on HIGH/CRITICAL
      trivy image \
        --exit-code 1 \
        --severity HIGH,CRITICAL \
        --format sarif \
        --output trivy-results.sarif \
        $(containerRegistry)/$(imageRepository):$(Build.BuildId)

- task: PublishBuildArtifacts@1
  condition: always()
  inputs:
    PathtoPublish: 'trivy-results.sarif'
    ArtifactName: 'SecurityReports'

4. IaC Security (Checkov + tfsec)

- task: Bash@3
  displayName: 'IaC Scan - Checkov'
  inputs:
    targetType: 'inline'
    script: |
      pip install checkov
      checkov -d ./terraform \
        --framework terraform \
        --output sarif \
        --output-file-path checkov-results.sarif \
        --compact \
        --skip-check CKV_AZURE_33,CKV_AZURE_44  # documented exceptions only

- task: Bash@3  
  displayName: 'IaC Scan - tfsec'
  inputs:
    targetType: 'inline'
    script: |
      docker run --rm -v $(Build.SourcesDirectory):/src \
        aquasec/tfsec /src/terraform \
        --format sarif \
        --out /src/tfsec-results.sarif

5. OPA Policy Gates

This is where policy-as-code prevents mis-configured K8s deployments:

# policies/k8s-security.rego
package kubernetes.security

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.securityContext.runAsNonRoot
  msg := sprintf("Container '%v' must run as non-root", [container.name])
}

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  container.securityContext.privileged
  msg := sprintf("Container '%v' must not be privileged", [container.name])
}

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.resources.limits.memory
  msg := sprintf("Container '%v' must have memory limits", [container.name])
}
- task: Bash@3
  displayName: 'Policy Gate - OPA/Conftest'
  inputs:
    targetType: 'inline'
    script: |
      conftest test ./kubernetes/manifests/ \
        --policy ./policies/ \
        --output tap

Full Pipeline YAML

trigger:
  branches:
    include: ['main', 'develop', 'release/*']

variables:
  containerRegistry: 'acr-revantage.azurecr.io'
  imageRepository: 'api-service'

stages:
- stage: SecurityGates
  displayName: 'Security Scan'
  jobs:
  - job: SAST
    pool:
      vmImage: 'ubuntu-latest'
    steps:
    - template: templates/sast-scan.yml
    
  - job: SCA
    pool:
      vmImage: 'ubuntu-latest'
    steps:
    - template: templates/snyk-scan.yml

- stage: Build
  dependsOn: SecurityGates
  condition: succeeded()
  jobs:
  - job: BuildAndScan
    steps:
    - task: Docker@2
      inputs:
        command: 'buildAndPush'
        repository: $(imageRepository)
    - template: templates/trivy-scan.yml

- stage: IaCSecurity
  dependsOn: Build
  jobs:
  - job: TerraformSecurity
    steps:
    - template: templates/checkov-tfsec.yml
    - template: templates/opa-gate.yml

- stage: Deploy
  dependsOn: IaCSecurity
  condition: succeeded()
  jobs:
  - deployment: Production
    environment: 'production'
    strategy:
      runOnce:
        deploy:
          steps:
          - template: templates/deploy.yml

SARIF Reports in Azure DevOps

SARIF (Static Analysis Results Interchange Format) integrates directly into Azure DevOps Security scans tab — developers see issues inline with code:

- task: PublishBuildArtifacts@1
  condition: always()
  inputs:
    PathtoPublish: '$(Build.SourcesDirectory)'
    ArtifactName: 'CodeAnalysisLogs'

# This enables the Security tab in Azure DevOps
- task: PublishSecurityAnalysisLogs@3
  condition: always()
  inputs:
    ArtifactName: 'CodeAnalysisLogs'
    ArtifactType: 'Container'

Results from Production

After implementing this across enterprise pipelines:

  • 93% of CVEs caught before staging
  • Zero HIGH/CRITICAL CVEs in production (12 months)
  • Developer adoption: Initially 40% pushback → 95% after seeing inline feedback
  • Mean time to remediate: 4 days → 6 hours (issues caught earlier = easier fixes)

The complete pipeline blueprint is at github.com/suhail39ahmed/devsecops-pipeline-blueprint