46 min readMehdi Hadeli

Designing a DevSecOps GitHub Actions Pipeline for .NET: Quality, SAST, SCA, Signing, and DAST

Introduction

Once a .NET pipeline grows beyond restore, build, and test, the hard part is no longer adding tools. The hard part is deciding where each control belongs and how the stages should fit together.

A serious DevSecOps pipeline usually needs several different controls at same time:

  • code quality checks
  • secret detection
  • SAST
  • IaC and pipeline misconfiguration scanning
  • application SCA and image SCA
  • SBOM generation
  • supply-chain signing and provenance
  • post-deploy smoke, k6, and DAST against the real target runtime

If all of that gets pushed into one large GitHub Actions job, failures turn noisy and triage gets expensive. This article walks through a cleaner design: a split CI/CD model, narrow jobs, explicit artifact boundaries, separate security surfaces, and a trust chain that continues all the way to target-runtime verification.

What this article covers

  • explain actual GitHub Actions stages in current sample
  • explain why sample now splits CI and CD into separate workflows
  • show where repo-local composite actions now carry the repeated setup, evidence, signing, and metadata logic
  • show why each step exists and what it gates
  • keep application and image evidence separate
  • publish security findings into GitHub Security tab where possible
  • preserve SBOMs and supply-chain trust metadata as first-class outputs
  • show how main deploys to dev, git tags deploy to staging, and production is promoted only after successful staging checks and manual approval
  • show how the sample supports Azure Container Apps, AKS direct deploy, and AKS plus Flux from the same CD workflow
  • show how target URL safety is enforced so tokens and signed query strings are not leaked through workflow outputs or evidence artifacts
  • provide reusable sample references for readers who want to inspect full code

Architecture overview

The pipeline follows three simple rules.

First, cheap deterministic checks run early and fail fast.

Second, security evidence follows artifact boundaries. Source code, published app output, final container image, published registry artifact, and deployed runtime are related, but they are not the same surface.

Third, deploy only happens from signed CI output. CI builds and signs trusted artifacts. CD verifies the signed digest again before touching an environment.

One clarification before the walkthrough: the numbered sections below are an explanation order, not one giant workflow file. The sample now uses two workflows. ci.yaml handles source validation, SCA, publish, signing, attestation, and CI evidence. cd.yaml starts from workflow_run, reads that CI evidence, decides whether deployment is allowed, then deploys to dev or staging, runs smoke plus k6 plus ZAP, and promotes to production only when the staging path passes and the GitHub production environment approval gate is satisfied.

Rendering diagram...

Repository shape

The sample now uses one CI workflow, one CD workflow, and repo-local composite actions.

Main pieces:

Workflow triggers and inputs

The sample now has two trigger models.

ci.yaml supports push, pull_request, and optional workflow_dispatch for manual image publication tests.

cd.yaml supports workflow_run on completed CI runs, then exits early unless that CI run concluded successfully and the CI metadata enables deployment.

The promotion policy is now explicit:

  • pushes or merges to main publish a short-SHA image and mark it for dev
  • git tag pushes publish an image tagged with the git tag and mark it for staging
  • production is never targeted directly from CI evidence; it is promoted later from the CD workflow after successful staging validation
  • pull requests stop in CI and never auto-deploy

That version rule is centralized in the repo-local resolve-version-metadata action. Tagged builds use the tag name. Everything else uses the short commit SHA. The same build_version then feeds image naming, Dependency-Track project versions, Snyk target-reference, and CI evidence output.

This is the core of that reusable action:

runs:
  using: composite
  steps:
    - name: Resolve metadata
      id: resolve
      shell: bash
      run: |
        image_name="$(echo "$INPUT_REPOSITORY_NAME" | tr '[:upper:]' '[:lower:]')"
        short_sha="${GITHUB_SHA::7}"

        if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
          build_version="$GITHUB_REF_NAME"
        else
          build_version="$short_sha"
        fi

        echo "image_name=${image_name}" >> "$GITHUB_OUTPUT"
        echo "image_tag=${build_version}" >> "$GITHUB_OUTPUT"
        echo "build_version=${build_version}" >> "$GITHUB_OUTPUT"
        echo "image_ref=${INPUT_REGISTRY}/${image_name}:${build_version}" >> "$GITHUB_OUTPUT"

So when the workflow says uses: ./.github/actions/resolve-version-metadata, this is not magic. It is a thin normalization layer that keeps the tag-or-short-SHA rule consistent everywhere.

That decision is written into the CI evidence artifact as machine-readable metadata:

{
  "build": {
    "version": "abc1234"
  },
  "artifacts": {
    "signedImageWithDigest": "ghcr.io/owner/repo@sha256:..."
  },
  "deployment": {
    "autoDeploy": true,
    "targetEnvironment": "dev",
    "source": "main"
  }
}

That file is now produced by the repo-local create-ci-evidence action rather than by ad hoc shell embedded in the workflow.

The action logic is also worth seeing directly because it encodes the deployment intent:

- name: Generate evidence bundle
  shell: bash
  run: |
    AUTO_DEPLOY="false"
    TARGET_ENVIRONMENT=""
    DEPLOY_SOURCE="none"

    if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/main" ]]; then
      AUTO_DEPLOY="true"
      TARGET_ENVIRONMENT="dev"
      DEPLOY_SOURCE="main"
    elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF_TYPE}" == "tag" ]]; then
      AUTO_DEPLOY="true"
      TARGET_ENVIRONMENT="staging"
      DEPLOY_SOURCE="tag"
    fi

    cat > "$OUTPUT_DIRECTORY/build-metadata.json" <<EOF
    {
      "build": { "version": "${BUILD_VERSION}" },
      "deployment": {
        "autoDeploy": ${AUTO_DEPLOY},
        "targetEnvironment": "${TARGET_ENVIRONMENT}",
        "source": "${DEPLOY_SOURCE}"
      }
    }
    EOF

That is why the CD workflow can stay small. The deployment decision is already computed once in CI and preserved as artifact data.

cd.yaml downloads that CI evidence bundle, reads the metadata, verifies the signed digest again, and only then deploys.

Why this matters in DevSecOps:

  • it keeps pull requests build-only while allowing trusted branches to promote automatically
  • it creates clear main -> dev and tag -> staging release semantics
  • it makes CD consume signed CI output instead of rebuilding during deployment
  • it lets dev, staging, and production environment settings live in GitHub environment-scoped secrets and variables

The workflows also have a few top-level controls that are easy to miss when people focus only on jobs:

  • paths filters stop the pipeline from running on unrelated documentation-only changes
  • top-level permissions: {} forces every job to ask only for the scopes it actually needs
  • CI concurrency cancels older in-flight runs for same branch or pull request
  • CD concurrency keeps each deployment run isolated from other CI completions
  • shared env values keep registry name, image tar path, and blocking Trivy severities consistent across jobs

Those pieces are not scanners, but they still matter. They cut noise, reduce accidental token exposure, and make release behavior easier to reason about.

Stage 1: quality check

quality-check is the cheap gate. It should fail the branch before heavier scanners consume runner time.

Current job does five things:

  • checkout full history for secret scan context
  • validate formatting
  • validate style and warnings-as-errors policy
  • run analyzers
  • run Gitleaks and Hadolint
quality-check:
  steps:
    - name: Checkout code
      uses: actions/checkout@...
      with:
        fetch-depth: 0

    - name: Validate formatting
      uses: ./.github/actions/format

    - name: Validate style
      uses: ./.github/actions/style

    - name: Run analyzers
      uses: ./.github/actions/analyzers

    - name: Run Gitleaks secret scan
      uses: gitleaks/gitleaks-action@...

    - name: Lint Dockerfile
      run: docker run --rm -i hadolint/hadolint < Dockerfile

This is the first trust gate. It answers a basic question before the pipeline spends more runner time: is this branch clean enough to deserve the heavier jobs? Format, style, analyzers, secret scanning, and Dockerfile linting are all cheap compared to deeper security analysis and deployment.

Why this matters in DevSecOps:

  • formatting and style keep code review noise low so security-relevant changes stand out
  • analyzer failures often catch risky API usage before scanners do
  • Gitleaks catches credential leaks before artifacts are built or pushed
  • Hadolint catches container hardening mistakes early, such as weak base-image practices or unsafe Dockerfile patterns
  • fail-fast gates keep expensive security jobs focused on branches that already meet minimum engineering discipline

Why separate format, style, and analyzers instead of one dotnet build?

  • formatting drift has different fix path than compiler or analyzer failures
  • logs stay narrow
  • teams can tighten one policy without changing all others
  • reusable composite actions stay simple and local to repository

Stage 2: IaC and pipeline misconfiguration scan

sast-iac-checkov covers the gap between code SAST and package SCA. It looks at the workflow and Docker build surfaces themselves.

In this sample I used Checkov for:

  • GitHub Actions hardening checks
  • Dockerfile checks
  • secrets-style policy rules
sast-iac-checkov:
  permissions:
    contents: read
    security-events: write
  needs: [quality-check]
  steps:
    - name: Prepare Checkov artifacts
      uses: ./.github/actions/prepare-directories

    - name: Run Checkov IaC scan
      id: checkov
      continue-on-error: true
      uses: bridgecrewio/checkov-action@...
      with:
        directory: .
        framework: github_actions,dockerfile,secrets
        quiet: true
        output_format: cli,sarif
        output_file_path: console,artifacts/security/checkov.sarif

    - name: Upload Checkov SARIF
      if: always()
      uses: ./.github/actions/upload-sarif
      with:
        sarif-file: artifacts/security/checkov.sarif
        category: checkov

    - name: Fail on Checkov findings
      if: steps.checkov.outcome == 'failure'
      run: |
        echo "::error::Checkov found IaC or pipeline misconfigurations."
        exit 1

This stage treats pipeline and container build files as production code. That matters because a secure application can still be shipped through an insecure workflow. Checkov covers the security posture of the delivery system itself.

Why this matters in DevSecOps:

  • GitHub Actions files can introduce over-broad permissions, unsafe triggers, or weak secret handling
  • Dockerfiles can encode bad defaults that app scanners will never see clearly
  • IaC-style checks move operational security left instead of waiting for cloud runtime review
  • SARIF upload pushes these findings into same GitHub Security experience developers already use for code scanning

Checkov runs after quality gate, but before build and test. Reason: misconfigured workflow or risky Dockerfile should block pipeline even if application compiles fine. The current workflow also separates SARIF upload from fail logic, so findings can still land in GitHub Security even when the job ends as failed.

That upload-sarif helper is intentionally tiny:

runs:
  using: composite
  steps:
    - name: Upload SARIF report
      uses: github/codeql-action/upload-sarif@...
      with:
        sarif_file: ${{ inputs.sarif-file }}
        category: ${{ inputs.category }}

So the local action is not adding hidden behavior. It simply standardizes the call shape and keeps all SARIF uploads consistent across Checkov, Trivy, Grype, Semgrep, and optional Snyk.

Stage 3: SAST with Semgrep, CodeQL, and optional Sonar

Static analysis stays split into parallel jobs.

  • sast-semgrep gives fast broad coverage
  • sast-codeql gives deeper semantic analysis for C#
  • sast-sonar adds optional quality-gate and security-hotspot analysis when Sonar secrets are configured and Sonar CI is enabled for the run

Semgrep excerpt:

- name: Run Semgrep SAST
  run: |
    docker run --rm \
      -v "$PWD:/src" \
      -w /src \
      semgrep/semgrep:latest \
      semgrep scan \
      --config p/security-audit \
      --error \
      --metrics=off \
      --exclude .git \
      --exclude artifacts \
      --exclude tests \
      --sarif \
      --output artifacts/security/semgrep.sarif \
      .

This gives you fast, pattern-based SAST coverage. It is useful for catching dangerous coding patterns, insecure defaults, and suspicious constructs without waiting for slower semantic analysis.

Why this matters in DevSecOps:

  • fast checks preserve developer feedback speed
  • Semgrep often catches broad cross-language patterns, not only compiler-visible defects
  • containerized execution keeps runner setup simple and reproducible
  • pinned scan policy such as p/security-audit keeps the lane intentionally security-focused instead of acting like generic lint
  • SARIF output means findings become actionable in repository security workflows, not buried in raw logs

CodeQL excerpt:

- name: Initialize CodeQL
  uses: github/codeql-action/init@...
  with:
    languages: csharp
    build-mode: manual
    queries: security-and-quality

- name: Build solution for CodeQL
  uses: ./.github/actions/build

- name: Analyze with CodeQL
  uses: github/codeql-action/analyze@...
  with:
    category: /language:csharp

This adds semantic SAST. Unlike pattern scanning alone, CodeQL understands code flow, library usage, and language structure more deeply.

Why this matters in DevSecOps:

  • it helps find higher-value issues such as injection paths, tainted data flow, and dangerous framework usage
  • it produces GitHub-native code scanning results that integrate well with repository security views
  • manual build mode makes scan reflect actual compiled code path instead of shallow source-only guess
  • pairing CodeQL with Semgrep balances depth and speed instead of over-trusting single scanner

Why both?

  • Semgrep is fast and easy to gate
  • CodeQL gives stronger language-aware analysis
  • separate jobs run in parallel and produce clearer ownership when one fails

Sonar excerpt:

sast-sonar:
  needs: [quality-check]
  steps:
    - name: Set up JDK
      uses: actions/setup-java@...
      with:
        distribution: temurin
        java-version: '17'

    - name: Setup .NET Core
      uses: actions/setup-dotnet@...
      with:
        global-json-file: global.json

    - name: Restore local tools
      run: dotnet tool restore

    - name: Configure Husky
      run: dotnet husky install

    - name: Restore solution
      env:
        SOLUTION_PATH: DevSecOpsPipelineSample.slnx
      run: dotnet tool run husky -- run --name setup-solution-restore

    - name: Setup SonarQube Scanner
      uses: ./.github/actions/sonar-scanner-setup

    - name: Resolve Sonar configuration
      env:
        SONAR_CI_ENABLED: ${{ github.event_name == 'workflow_dispatch' && (inputs.sonar_enabled && 'true' || 'false') || vars.SONAR_CI_ENABLED || 'true' }}
      run: |
        if [[ -z "${SONAR_TOKEN}" ]]; then
          echo "enabled=false" >> "$GITHUB_OUTPUT"
          exit 0
        fi
        if [[ "$(printf '%s' "${SONAR_CI_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')" != "true" ]]; then
          echo "enabled=false" >> "$GITHUB_OUTPUT"
          exit 0
        fi
        echo "enabled=true" >> "$GITHUB_OUTPUT"

    - name: Sonarqube Begin
      if: steps.sonar_config.outputs.enabled == 'true'
      run: ./.sonar/scanner/dotnet-sonarscanner begin ...

    - name: Build
      if: steps.sonar_config.outputs.enabled == 'true'
      run: dotnet build DevSecOpsPipelineSample.slnx --configuration Release --no-restore

    - name: Test
      if: steps.sonar_config.outputs.enabled == 'true'
      env:
        TEST_RESULTS_DIRECTORY: TestResults/Sonar
        TEST_COVERAGE_OUTPUT_FORMAT: xml
        TEST_COVERAGE_OUTPUT: coverage.xml
        TEST_ADDITIONAL_COVERAGE_OUTPUT_FORMAT: ''
        TEST_ADDITIONAL_COVERAGE_OUTPUT: ''
        TEST_NO_BUILD: 'true'
      run: dotnet tool run husky -- run --name test

    - name: Sonarqube End
      if: always() && steps.sonar_config.outputs.enabled == 'true'
      run: ./.sonar/scanner/dotnet-sonarscanner end ...

This gives you a Sonar lane without turning Sonar into a hard dependency for every clone of the sample. If the Sonar secrets are missing, or if Sonar CI is disabled for a manual run through the sonar_enabled dispatch input or SONAR_CI_ENABLED variable, the job exits cleanly and the rest of the pipeline still works.

One detail is easy to miss if you only look at the begin command. begin does not do the full scan by itself. It prepares scanner state and injects Sonar targets. The real local analysis starts during dotnet build on the CI runner. After that, the Husky-backed test step produces .trx and XML coverage files for import, and end uploads the collected analysis so SonarCloud or SonarQube can finish processing and compute the quality gate.

Sonar quality gate view

Why this matters in DevSecOps:

  • some teams already govern code health and release readiness through Sonar quality gates
  • Sonar adds broader code quality, maintainability, and security-hotspot visibility around same build
  • running it in parallel preserves overall pipeline shape instead of serializing all static analysis behind one tool
  • keeping it optional avoids forcing external platform dependency into base sample
  • the current workflow explicitly waits for the Sonar quality gate result, so branch or PR coverage thresholds can still block the Sonar lane when enabled
  • using the repo's Husky test task for Sonar keeps local and CI test invocation aligned while still allowing Sonar-specific coverage settings
  • explicit configuration validation catches missing SONAR_PROJECT_KEY or SONAR_ORGANIZATION early instead of failing later in a less obvious scanner step

Why not replace CodeQL and Semgrep with Sonar?

  • Sonar is broader than pure SAST and serves different governance purpose
  • CodeQL remains stronger for GitHub-native semantic security analysis
  • Semgrep remains cheaper and faster for broad blocking checks
  • using Sonar as additional lane gives better layered analysis than picking only one engine

One orchestration detail changed in the current sample: dotnet-build-test runs after the blocking static-analysis jobs sast-iac-checkov, sast-semgrep, and sast-codeql, but not after sast-sonar. Sonar stays in parallel and gets enforced later by security-gate. That is a better trade in practice. Developers get compile and test feedback sooner, but Sonar still counts before release.

Stage 4: build and test

This job compiles and tests after quality, IaC, Semgrep, and CodeQL passed. Sonar intentionally stays parallel so developers get functional feedback sooner.

dotnet-build-test:
  needs:
    - quality-check
    - sast-iac-checkov
    - sast-semgrep
    - sast-codeql
  steps:
    - name: Build solution
      uses: ./.github/actions/build

    - name: Test solution
      uses: ./.github/actions/test
      with:
        test-filter-queries: |
          /[TestSuite=Unit]
          /[TestSuite=EndToEnd]
        no-build: true
        skip-setup: true
        generate-coverage-report: true
        publish-coverage-summary: true
        upload-coverage-artifact: true
        upload-test-artifact: true
        upload-coveralls: true

This stage proves the application still works after the early policy gates pass. That sounds obvious, but it is easy to lose sight of when a pipeline gets security-heavy. DevSecOps still has to deliver working software.

This is more than a bare dotnet test step. The shared test action delegates to the repo's Husky test task, which calls a small shell script around dotnet test. That script now runs two solution-level passes filtered by xUnit v3 traits, one for Unit and one for EndToEnd, then standardizes Microsoft Testing Platform coverage collection, writes native .coverage output, optionally converts it to Cobertura through dotnet-coverage, and lets CI decide whether to generate HTML, Markdown, and lcov reports or upload coverage to Coveralls.

Why this matters in DevSecOps:

  • secure pipeline that ships broken app is still failed delivery system
  • test artifacts create audit trail for what was validated before release
  • native Microsoft Testing Platform coverage plus converted Cobertura keeps one test run usable for both .NET-native tooling and external coverage/reporting tools
  • Coveralls adds historical coverage visibility and lightweight PR feedback without changing the local test path
  • coverage output helps teams reason about how much of changed code had executable verification
  • gating later security stages on successful build and test avoids mixing functional failures with vulnerability triage

That is deliberate. If tests fail, there is no reason to pay for app SCA, image SCA, signing, or deployment.

Stage 5: application SCA, SBOM, and GitHub Security uploads

This job scans the application surface, not the final container surface.

In workflow dependency order, dotnet-app-sca-security runs right after dotnet-build-test. image-build starts after that, and image-sca-security follows the produced image artifact.

Important implementation details:

  • app is first published into artifacts/publish
  • CycloneDX generates app SBOM from solution graph
  • trusted non-PR runs sign app SBOM as detached Sigstore bundle and verify it immediately
  • Trivy scans filesystem and configuration
  • Grype scans published output
  • optional Snyk overlay runs only when SNYK_TOKEN exists
  • SARIF goes into GitHub Security tab
  • SBOM can be pushed into Dependency-Track
- name: Publish app for scan surface
  run: >-
    dotnet publish src/DevSecOpsPipelineSample.Api/DevSecOpsPipelineSample.Api.csproj
    -c Release
    -o artifacts/publish
    --no-restore

- name: Generate app SBOM
  run: >-
    dotnet tool run dotnet-CycloneDX DevSecOpsPipelineSample.slnx
    -o artifacts/sbom/app
    -j

- name: Sign app SBOM
  run: >-
    cosign sign-blob --yes
    --bundle artifacts/sbom/app/bom.sigstore.json
    artifacts/sbom/app/bom.json

- name: Verify app SBOM signature
  run: >-
    cosign verify-blob
    --bundle artifacts/sbom/app/bom.sigstore.json
    --certificate-oidc-issuer https://token.actions.githubusercontent.com
    artifacts/sbom/app/bom.json

- name: Run Trivy app scan JSON
  uses: aquasecurity/trivy-action@...
  with:
    scan-type: fs
    scan-ref: .
    scanners: vuln,secret,misconfig
    format: json
    output: artifacts/security/trivy-app.json
    severity: ${{ env.TRIVY_FAIL_SEVERITY }}
    exit-code: '1'

- name: Run Snyk app scan
  env:
    SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
  run: |
    snyk test \
      --file=src/DevSecOpsPipelineSample.Api/DevSecOpsPipelineSample.Api.csproj \
      --severity-threshold=high \
      --sarif-file-output=artifacts/security/snyk-app.sarif

This is the application-side SCA flow. dotnet publish creates realistic application output, CycloneDX captures dependency inventory, trusted runs sign and verify the detached SBOM bundle, Trivy scans both source and published context, Grype adds a second package view over published output, and optional Snyk adds managed vulnerability intelligence.

Snyk application project view

Why this matters in DevSecOps:

  • published output is closer to what ships than raw source tree alone
  • SBOM gives durable inventory, not only point-in-time scan result
  • detached bundle signing keeps bom.json usable for Dependency-Track while still adding verifiable provenance
  • Trivy covers vulnerabilities, secrets, and misconfigurations in one pass
  • Snyk overlay is useful for teams that want managed policies or additional advisory coverage
  • application-layer scanning finds issues in project dependencies before they are hidden inside container context

Why keep app SCA separate from image SCA?

  • app job is best place for NuGet and source-config view
  • image job is best place for base OS and runtime-layer view
  • findings remain easier to classify in GitHub Security
  • two SBOMs stay independently useful in Dependency-Track

The sample does not stop at generating BOM files. It also uploads both of them when Dependency-Track credentials are configured.

Exact CI locations:

  • app BOM upload: dotnet-app-sca-security -> Upload app SBOM to Dependency-Track
  • image BOM upload: image-sca-security -> Upload image SBOM to Dependency-Track

Both jobs call the same reusable action:

uses: ./.github/actions/upload-dependency-track-bom
with:
  dependency-track-url: ${{ secrets.DEPENDENCY_TRACK_URL }}
  dependency-track-api-key: ${{ secrets.DEPENDENCY_TRACK_API_KEY }}

App upload payload:

bom-file: artifacts/sbom/app/bom.json
project-name: ${{ env.APP_SBOM_PROJECT_NAME }}
project-version: ${{ steps.version_meta.outputs.build_version }}

Image upload payload:

bom-file: artifacts/sbom/image/image.cdx.json
project-name: ${{ env.APP_SBOM_PROJECT_NAME }}-image
project-version: ${{ needs.image-build.outputs.build-version }}

That is an important current detail. Dependency-Track no longer sees an always-raw full SHA as the project version. It now receives the same shared build version the rest of the pipeline uses: git tag for tagged builds, otherwise short SHA.

If you want to inspect those SBOMs in a local governance stack, the sample repository now also includes a ready-to-run Dependency-Track environment with PostgreSQL and a Trivy server:

The Trivy part matters because Dependency-Track's Trivy datasource works in client/server mode. In this sample stack, Dependency-Track runs as one container, Trivy runs as another, and the analyzer is configured to use http://trivy:8080 on the internal Docker Compose network.

Snyk grouped project targets

There are now two setup paths:

  • manual UI setup in Administration -> Analyzers -> Trivy
  • optional API bootstrap through deployments/dependency-track/bootstrap-trivy.sh
version: '3.9'

services:
  dtrack-apiserver:
    image: dependencytrack/apiserver:latest
    depends_on:
      postgres-db:
        condition: service_healthy
      trivy:
        condition: service_started

  dtrack-frontend:
    image: dependencytrack/frontend:latest

  trivy:
    image: aquasec/trivy:latest
    command:
      - server
      - --listen
      - 0.0.0.0:8080
      - --token
      - ${TRIVY_SERVER_TOKEN}

  dtrack-bootstrap:
    image: curlimages/curl:8.14.1
    environment:
      DT_API_BASE_URL: http://dtrack-apiserver:8080
      TRIVY_BASE_URL: http://trivy:8080

  postgres-db:
    image: postgres:16

If you provide DEPENDENCY_TRACK_API_KEY in .env, the bootstrap container waits for http://dtrack-apiserver:8080/api/openapi.json and http://trivy:8080/healthz, then posts the Trivy analyzer settings to POST /api/v1/configProperty/aggregate.

That bootstrap configures these exact Dependency-Track properties:

  • scanner.trivy.enabled=true
  • scanner.trivy.base.url=http://trivy:8080
  • scanner.trivy.api.token=${TRIVY_SERVER_TOKEN}
  • scanner.trivy.ignore.unfixed=${TRIVY_IGNORE_UNFIXED}

If you prefer UI configuration, enable the Trivy analyzer in Administration -> Analyzers -> Trivy, set Base URL to http://trivy:8080, and use the same token value you placed in .env as TRIVY_SERVER_TOKEN.

Warm up Dependency-Track with app and image SBOMs

Before wiring the full GitHub Actions upload path to a reachable Dependency-Track instance, it is useful to warm up the local stack with the exact two BOM shapes produced by CI.

Use the same kebab-case project names CI uses when it uploads SBOMs automatically:

  • devsecops-pipeline-sample
  • devsecops-pipeline-sample-image

Use the same shared build version CI uses: git tag for tagged builds, otherwise short SHA.

If you want to upload them through the UI:

  1. open http://localhost:8080
  2. create or open the target project
  3. click Upload BOM
  4. upload bom.json to devsecops-pipeline-sample
  5. upload image.cdx.json to devsecops-pipeline-sample-image

If you want to upload them through the local API instead, use the same endpoint the reusable action ultimately targets:

curl -fsS -X POST "http://localhost:8081/api/v1/bom" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -F "autoCreate=true" \
  -F "projectName=devsecops-pipeline-sample" \
  -F "projectVersion=BUILD_VERSION" \
  -F "bom=@path/to/bom.json"

curl -fsS -X POST "http://localhost:8081/api/v1/bom" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -F "autoCreate=true" \
  -F "projectName=devsecops-pipeline-sample-image" \
  -F "projectVersion=BUILD_VERSION" \
  -F "bom=@path/to/image.cdx.json"

The best source for those files is the CI artifact produced by the Record and notify job and Upload CI evidence bundle step.

That gives you a tighter warmup loop:

  1. open a successful CI run
  2. download the artifact published by Record and notify
  3. extract it locally
  4. upload these exact files into your local Dependency-Track stack

Expected paths inside the downloaded artifact:

  • app SBOM: artifacts/sbom/app/bom.json
  • image SBOM: artifacts/sbom/image/image.cdx.json

That is better than rebuilding locally when your goal is to validate the real pipeline output, because you are testing the exact BOM files generated by CI.

This is worth doing even if you plan to automate later, because it gives you a quick visual check that the app and image BOMs produce different project views.

  • the app project usually looks like a dependency inventory with far fewer CVEs
  • the image project usually exposes many more OS and runtime package findings
  • the difference becomes obvious in Components, Vulnerabilities, and Dependency Graph

That separation is exactly why this pipeline keeps app SBOM and image SBOM distinct instead of flattening everything into one upload.

The screenshots below show that split in practice. The application project mostly acts as a component inventory, while the image project is where runtime and OS package CVEs become much more visible.

Dependency-Track app project view

Dependency-Track image project view

When those SARIF uploads land successfully, GitHub surfaces them in the Security and quality tab under Code scanning. That gives the team one place to review Trivy, Grype, CodeQL, Semgrep, and other uploaded findings instead of hunting through raw workflow logs.

GitHub Security Tab Code Scanning Results

In this sample, that view is especially useful because multiple tools contribute findings for the same repository. GitHub keeps the alerts grouped by rule, severity, tool, and branch so engineers can triage CVEs and security issues from the pipeline in a single UI.

Stage 6: image build and image SCA

The container image gets built once for the scanning lane, exported as an artifact, and scanned in a separate job.

That pattern matters because it avoids rebuilding the scan target inside every security job. After the security gate passes, the workflow uses Docker's official publish actions to rebuild and push the release image with richer tags and labels.

image-build:
  needs:
    - dotnet-app-sca-security
  outputs:
    image-name: ${{ steps.meta.outputs.image_name }}
    image-ref: ${{ steps.meta.outputs.image_ref }}
    image-tag: ${{ steps.meta.outputs.image_tag }}
    build-version: ${{ steps.meta.outputs.build_version }}
  steps:
    - name: Resolve image and version metadata
      id: meta
      uses: ./.github/actions/resolve-version-metadata

    - name: Build container image
      run: docker build -t "${{ steps.meta.outputs.image_ref }}" .

    - name: Save container image artifact
      run: docker save "${{ steps.meta.outputs.image_ref }}" | gzip > "${IMAGE_TAR_PATH}"

This creates the scan target for downstream jobs. Instead of rebuilding the image repeatedly during image security analysis, the pipeline builds once, captures shared metadata through a dedicated action, and reuses the same artifact there.

Why this matters in DevSecOps:

  • repeatable artifact handling reduces “it passed in one job but not another” drift inside the scanning lane
  • downstream image scans inspect one fixed local image artifact
  • saved image artifact keeps the image-SCA lane isolated from registry concerns
  • normalized image naming avoids registry mismatches and case-sensitivity surprises in GHCR

One tradeoff changed in current sample. The publish job now rebuilds with docker/setup-buildx-action, docker/metadata-action, and docker/build-push-action instead of pushing the saved tarball directly. That gives a cleaner GHCR publish experience and better tag management, but it does mean strict byte-for-byte scan-to-publish identity is no longer guaranteed by the workflow alone.

Image security job then loads same artifact and scans it.

- name: Generate image SBOM
  uses: anchore/sbom-action@...
  with:
    image: ${{ needs.image-build.outputs.image-ref }}
    format: cyclonedx-json
    output-file: artifacts/sbom/image/image.cdx.json

- name: Run Trivy image scan JSON
  uses: aquasecurity/trivy-action@...
  with:
    image-ref: ${{ needs.image-build.outputs.image-ref }}
    scanners: vuln,secret,misconfig
    format: json
    output: artifacts/security/trivy-image.json
    severity: ${{ env.TRIVY_FAIL_SEVERITY }}
    exit-code: '1'

- name: Run Snyk image scan
  env:
    SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
  run: |
    snyk container test "${{ needs.image-build.outputs.image-ref }}" \
      --severity-threshold=high \
      --sarif-file-output=artifacts/security/snyk-image.sarif

This covers the runtime artifact, not the project graph. The image SBOM and image scanners answer a different question: what is inside the final container that will actually execute in the environment?

Snyk container image findings

Why this matters in DevSecOps:

  • base image packages and OS libraries do not show up clearly in app-level scanning
  • container image may include extra runtime files, shells, package-manager remnants, or weak defaults
  • image SBOM supports downstream governance and incident response when a base layer CVE appears later
  • trusted non-PR runs sign and verify the image SBOM as detached bundle before publication stages continue
  • blocking Trivy plus advisory Grype plus optional Snyk gives layered SCA view without forcing all tools to gate equally

Grype remains advisory second opinion in this sample. Trivy remains blocking primary image scanner. Snyk stays optional managed overlay.

One detail worth calling out: both app and image security jobs upload their artifacts even on failure by using if: always(). That makes post-failure triage much better because SBOMs, SARIF, and JSON evidence still survive when a gate blocks release.

Stage 7: security gate

This stage centralizes the release decision instead of leaving pass or fail logic scattered across several scanner jobs.

The workflow now evaluates Sonar, application security, and image security together, writes a gate report artifact, and only then allows image publication to continue.

security-gate:
  if: always()
  needs:
    - sast-sonar
    - dotnet-app-sca-security
    - image-sca-security
  outputs:
    gate-status: ${{ steps.evaluate.outputs.gate_status }}

It does not replace scanner failures. It turns them into one explicit policy checkpoint.

Why this matters in DevSecOps:

  • release promotion now depends on one named policy decision, not only on reading several job results
  • the gate produces durable audit output with Sonar result, app result, image result, commit, and run URL
  • teams can later extend this stage with thresholds, waivers, or approval logic without redesigning whole pipeline
  • one summary point makes troubleshooting easier when several scanners are involved

Stage 8: CI publish, sign, verify, and attest

This is where CI stops being only validation and starts promoting artifacts.

Flow is:

  • rebuild and publish release image to GHCR with Docker Buildx and Docker metadata tags
  • attach image SBOM to published GHCR image as OCI evidence
  • sign image with Cosign using the repo-local sign-published-image helper
  • verify signature inside CI with the repo-local verify-keyless-image-signature helper
  • generate provenance attestation in parallel from the signed image metadata
  • rely on earlier jobs to sign and verify detached app and image SBOM bundles without mutating the SBOM JSON files

Because those are reusable actions, it is worth looking at the actual helper code instead of only the calling workflow.

The important release-policy change is in image tagging:

  • main pushes publish short SHA tag plus dev, main, and latest
  • git tag pushes publish the git tag value
  • CI evidence records whether CD should auto-deploy and which environment it should target

Publish excerpt:

image-publish:
  needs:
    - security-gate
    - image-sca-security
  steps:
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@...

    - name: Login to GitHub Container Registry
      uses: ./.github/actions/login-ghcr
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Generate Docker metadata
      id: meta
      uses: docker/metadata-action@...
      with:
        images: ${{ env.REGISTRY }}/${{ needs.image-sca-security.outputs.image-name }}
        tags: |
          type=raw,value=${{ needs.image-sca-security.outputs.image-tag }}
          type=raw,value=dev,enable=${{ github.ref == 'refs/heads/main' }}
          type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }}
          type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

    - name: Build and push Docker image
      id: build_and_push
      uses: docker/build-push-action@...
      with:
        context: .
        file: Dockerfile
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}

The login wrapper is also deliberately thin:

runs:
  using: composite
  steps:
    - name: Login to container registry
      uses: docker/login-action@...
      with:
        registry: ${{ inputs.registry }}
        username: ${{ inputs.username }}
        password: ${{ inputs.password }}

That helps article readers see that the repository is mostly standardizing repeated registry auth, not hiding custom publish behavior inside a large composite action.

This is the controlled promotion point. CI does not publish the image until image scanning has passed and the centralized security gate has reported PASS.

Why this matters in DevSecOps:

  • registry should not become dumping ground for unreviewed artifacts
  • GITHUB_TOKEN plus packages: write is enough for same-repository GHCR publishing, so workflow avoids separate long-lived registry secret
  • Docker metadata action gives one canonical tag plus convenience tags like dev, main, and latest
  • digest capture is critical because signing and attestation should bind to immutable content, not mutable tag alone
  • main pushes without git tag still publish short SHA tag, and tag pushes publish the git tag itself

This is also where the current sample makes its biggest tradeoff. The publish job now rebuilds after the security gate so it can use Docker's standard GHCR actions and richer metadata tags. That is operationally cleaner, but less strict than pushing the already-scanned tarball directly.

The repository also contains a simpler local publish composite action for straightforward build-and-push cases, but the main CI workflow intentionally keeps the richer direct publish path because it needs Docker metadata labels, cache settings, digest capture, and multi-tag publication semantics.

Attestation is separate on purpose. After signing, the workflow fans out into two downstream jobs: verify-image-signature and attest. CI verification produces artifact evidence immediately, and CD repeats verification before deploy. Attestation produces GitHub-native provenance from the same signed digest.

Why keep signing, verification, and attestation separate?

  • signing proves workload identity approved a digest
  • verification turns that proof into release policy
  • attestation adds structured provenance that other GitHub or downstream policy engines can consume

Cosign signing excerpt:

image-sign:
  steps:
    - name: Install Cosign
      uses: ./.github/actions/setup-cosign

    - name: Sign published image with GitHub OIDC
      uses: ./.github/actions/sign-published-image
      with:
        image-publish-ref: ${{ needs.image-publish.outputs.image-ref }}
        image-publish-digest: ${{ needs.image-publish.outputs.image-digest }}
        image-security-artifacts-path: ghcr-evidence/image-security

Inside that reusable action, the important logic is this:

- name: Attach image SBOM
  shell: bash
  run: |
    IMAGE_REPOSITORY="$IMAGE_PUBLISH_REF"
    IMAGE_REPOSITORY="${IMAGE_REPOSITORY%:*}"
    IMAGE_DIGEST="$IMAGE_PUBLISH_DIGEST"
    IMAGE_WITH_DIGEST="${IMAGE_REPOSITORY}@${IMAGE_DIGEST}"
    IMAGE_SBOM_FILE="$(find "$IMAGE_SECURITY_ARTIFACTS_PATH" -type f \( -name 'image.cdx.json' -o -name '*.cdx.json' \) | head -n 1)"

    cosign attach sbom --sbom "${IMAGE_SBOM_FILE}" "${IMAGE_WITH_DIGEST}"

- name: Sign image
  shell: bash
  run: |
    cosign sign --yes "${IMAGE_WITH_DIGEST}"
    echo "image_with_digest=${IMAGE_WITH_DIGEST}" >> "$GITHUB_OUTPUT"

So the helper does two concrete things: find the generated image SBOM in the downloaded evidence, attach it to the published digest, then sign that immutable digest and return it to later jobs.

Cosign installation is wrapped too, but again the wrapper is intentionally minimal:

runs:
  using: composite
  steps:
    - name: Install Cosign
      uses: sigstore/cosign-installer@...
      with:
        cosign-release: ${{ inputs.cosign-release }}

That matters for maintainability more than logic. The workflow can pin one Cosign release in one helper and reuse that exact setup across app SBOM signing, image SBOM verification, image signing, and digest verification steps.

This adds GitHub-native supply-chain evidence around the same published artifact. The image SBOM is attached to GHCR, then Cosign keyless signing says more than “job succeeded.” It cryptographically ties the published image digest to the workload identity that created it. Detached SBOM bundle verification happened earlier so the raw SBOM files stayed compatible with Dependency-Track while still gaining provenance proof.

Why this matters in DevSecOps:

  • GHCR remains source of truth not only for image tag, but also for attached SBOM evidence around same digest
  • signatures help detect tampering between build and deployment
  • keyless OIDC avoids long-lived signing key management for sample pipeline
  • digest-based signing ensures exact artifact is trusted, not any later retagged image
  • this is core supply-chain control, especially when registries, runners, and deployment systems are separate components

Verification excerpt:

verify-image-signature:
  steps:
    - name: Verify keyless image signature
      uses: ./.github/actions/verify-keyless-image-signature
      with:
        workflow-ref: ${{ github.workflow_ref }}
        image-with-digest: ${{ needs.image-sign.outputs.image-with-digest }}

And the reusable verification action is intentionally small:

- name: Verify signature
  shell: bash
  run: |
    cosign verify \
      --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
      --certificate-identity "https://github.com/$WORKFLOW_REF" \
      "$IMAGE_WITH_DIGEST"
    echo "image_with_digest=$IMAGE_WITH_DIGEST" >> "$GITHUB_OUTPUT"

That small wrapper matters because it standardizes the certificate identity check. Readers can now inspect the exact trust boundary instead of inferring it from prose.

This turns signing from ceremony into an enforceable control. Many teams sign artifacts but never verify them at all. In this sample, CI verification gives immediate artifact evidence, then CD performs verification again before deploy. That second verification is the actual environment gate.

One subtle but important difference exists between CI and CD verification now. CI verifies against the exact current workflow ref. CD verifies against a stricter certificate identity pattern that only trusts signatures originating from .github/workflows/ci.yaml on refs/heads/main or on tags. That keeps deployment trust anchored to the CI workflow identity, not only to the current CD run.

Why this matters in DevSecOps:

  • it proves artifact was signed by expected GitHub workflow identity
  • it blocks deploy if image was replaced, tampered with, or signed by wrong source
  • it gives concrete trust gate before runtime exposure
  • it prepares pipeline for later admission-control or cluster policy integration

This gives stronger trust story than simple docker push followed by deploy. CD now depends on signed and re-verified image metadata, not only on successful push.

Stage 9: CD deploy to dev or staging, validate runtime, then promote staging to production

CD now starts from workflow_run instead of manual dispatch. It downloads the ci-evidence artifact from the completed CI run, reads build-metadata.json, and decides whether deployment is enabled.

prepare-deployment:
  steps:
    - name: Download CI metadata
      run: |
        gh api "repos/$REPOSITORY/actions/artifacts/$artifact_id/zip" > ci-evidence.zip
        unzip -q ci-evidence.zip -d ci-evidence

    - name: Decide environment and digest
      run: |
        target_environment="$(jq -r '.deployment.targetEnvironment' ci-evidence/build-metadata.json)"
        image_with_digest="$(jq -r '.artifacts.signedImageWithDigest' ci-evidence/build-metadata.json)"

That handoff is what makes the split design useful. CI decides intent. CD consumes intent and deploys only trusted output.

Deployment itself still stays intentionally narrow. It does not rebuild or rescan. It deploys an artifact that has already passed quality, SAST, SCA, signing, and verification, and it deploys a verified immutable digest rather than a mutable tag.

The current CD flow has three important decisions:

  • choose the environment from CI evidence: dev for main, staging for tags
  • choose the deployment target from environment variables: Azure Container Apps, AKS direct, or AKS plus Flux
  • choose whether production is allowed: only after a successful staging deploy plus smoke, k6, and ZAP, and only after GitHub Environment approval

The deploy job reflects that directly:

deploy:
  environment: ${{ needs.prepare-deployment.outputs.target-environment }}
  steps:
    - name: Check deploy configuration
      env:
        DEPLOY_TARGET: ${{ vars.DEPLOY_TARGET }}
        AKS_DEPLOY_MODE: ${{ vars.AKS_DEPLOY_MODE }}
        TARGET_API_URL: ${{ vars.TARGET_API_URL }}
        AKS_TARGET_API_URL: ${{ vars.AKS_TARGET_API_URL }}

    - name: Deploy container image to Azure Container Apps
      if: steps.deploy_config.outputs.deploy_target == 'aca'

    - name: Deploy manifests to AKS directly
      if: steps.deploy_config.outputs.deploy_target == 'aks' && steps.deploy_config.outputs.deploy_mode == 'direct'

    - name: Update Flux-tracked AKS manifest in GitOps repo
      if: steps.deploy_config.outputs.deploy_target == 'aks' && steps.deploy_config.outputs.deploy_mode == 'flux'

Why this matters in DevSecOps:

  • deployment should consume trusted artifact, not create new one
  • separating deploy from build keeps release evidence easier to audit
  • Azure login through federated identity avoids baking long-lived credentials into pipeline where possible
  • environment-scoped variables such as AZURE_RESOURCE_GROUP, CONTAINER_APP_NAME, and optional TARGET_API_URL keep dev, staging, and production configuration separate
  • main and tag promotions can share the same deploy logic while still landing in different environments
  • target selection through DEPLOY_TARGET=aca|aks and AKS_DEPLOY_MODE=direct|flux lets one CD workflow cover ACA, AKS direct, and AKS GitOps
  • post-deploy runtime validation now includes smoke plus k6 plus ZAP, not only ZAP

After deploy, CD runs smoke checks first, then a short k6 pass, then passive ZAP baseline scanning.

post-deploy-smoke:
  needs: [deploy]
  steps:
    - name: Run smoke checks against target API

post-deploy-k6:
  needs: [deploy, post-deploy-smoke]
  steps:
    - name: Run k6 target endpoint check

zap-baseline:
  needs: [deploy, post-deploy-smoke, post-deploy-k6]
  steps:
    - name: Run ZAP baseline scan
      uses: zaproxy/action-baseline@...
      with:
        target: ${{ needs.deploy.outputs.app-url }}

That runtime chain gives you three progressively more realistic checks:

  • smoke proves the deployed endpoint is reachable and returns the expected JSON shape
  • k6 adds a simple latency and stability gate against the real published URL
  • ZAP checks the deployed HTTP surface from the attacker’s point of view

Why this matters in DevSecOps:

  • some findings only appear once application is reachable over network
  • smoke failures stop the lane before you waste time on heavier runtime checks
  • k6 catches obvious stability or latency regressions on the actual deployed address
  • passive DAST can catch headers, cookies, transport, and basic web exposure issues missed by static stages
  • baseline mode keeps the pipeline practical for shared environments without a full authenticated test harness
  • failing on alerts makes runtime posture part of release decision, not post-release surprise

The URL handling also changed in an important way. CD now uses TARGET_API_URL and AKS_TARGET_API_URL instead of the older staging-specific names, and it rejects URLs that contain embedded credentials, query strings, or fragments before writing them to workflow outputs or evidence artifacts.

Production is then a second deployment path, not a direct CI target:

deploy-production:
  if: >-
    needs.prepare-deployment.outputs.target-environment == 'staging' &&
    needs.zap-baseline.result == 'success'
  environment: production

post-deploy-smoke-production:
  needs: [deploy-production]

post-deploy-k6-production:
  needs: [deploy-production, post-deploy-smoke-production]

zap-baseline-production:
  needs: [deploy-production, post-deploy-smoke-production, post-deploy-k6-production]

That means a git tag does not jump straight to production. It lands in staging, proves the artifact works there, then pauses for the GitHub production environment approval before the same verified digest is promoted again.

Stage 10: Split Evidence, Notifications, and Workflow Handoff

The sample no longer has one shared finalizer. It now has one finalizer in CI and one finalizer in CD.

record-and-notify:
  if: always()

CI record-and-notify downloads test artifacts, security artifacts, the gate report, and then publishes ci-evidence. That bundle contains build-metadata.json, pipeline-summary.md, and pipeline-summary.json.

The current workflow uses a dedicated download-ci-evidence-artifacts composite action to restore the standard artifact layout before create-ci-evidence assembles the final handoff bundle.

That helper is deliberately boring, which is good. It only restores a predictable directory structure based on upstream job results:

- name: Download test results
  if: inputs.test-results-result == 'success'
  uses: ./.github/actions/download-workflow-artifact
  with:
    name: test-results
    path: ${{ inputs.base-path }}/test-results

- name: Download app security artifacts
  if: inputs.app-security-result != 'skipped'
  uses: ./.github/actions/download-workflow-artifact
  with:
    name: app-security-artifacts
    path: ${{ inputs.base-path }}/app-security

- name: Download image security artifacts
  if: inputs.image-security-result != 'skipped'
  uses: ./.github/actions/download-workflow-artifact
  with:
    name: image-security-artifacts
    path: ${{ inputs.base-path }}/image-security

That means the later evidence step does not need to know which earlier jobs ran, skipped, or failed. It can assume one stable folder layout and focus only on producing the final bundle.

CD record-and-notify downloads the ZAP report, publishes cd-evidence, and writes a deployment summary for the environment run.

The important detail is that ci-evidence/build-metadata.json is now more than a summary file. It is also the machine-readable handoff between workflows.

  • build-metadata.json for machine-readable release context and deployment intent
  • pipeline-summary.md for humans reading the artifact bundle or job summary
  • pipeline-summary.json for any later automation that wants one compact CI status document
  • cd-evidence/deployment-summary.* for runtime deployment and ZAP results

Why this matters in DevSecOps:

  • audit evidence becomes first-class output, not incidental collection of scattered logs
  • build metadata captures commit, branch, run, gate result, published digest, and intended target environment in one machine-readable document
  • maintainers get one concise CI step summary and preserved evidence bundle, while operators get a separate CD deployment summary
  • pull request runs still preserve CI evidence without triggering deployment
  • later compliance or incident response work starts from preserved CI and CD evidence bundles instead of re-running pipeline mentally
  • using if: always() in both final jobs means evidence can still be assembled from partial success and failure paths instead of disappearing with the first blocking stage

Why These Stage Boundaries Work

What this split buys you:

  • quality failures stay distinct from security failures
  • IaC failures do not hide inside SAST or SCA noise
  • app findings stay separate from image findings
  • security gate turns multiple security signals into one explicit release decision
  • publish never runs before the same source revision has passed app and image security gates
  • deploy never runs on unsigned image
  • CI and CD can evolve independently without collapsing back into one giant workflow file
  • post-deploy runtime validation runs against the deployed artifact, not against a local guess
  • release evidence is preserved as artifact instead of disappearing into transient job logs
  • least-privilege job permissions reduce blast radius when one action or token is compromised
  • path filters and concurrency keep the pipeline focused on meaningful changes instead of wasting runner time

It also scales well when team later adds policy exceptions, promotion environments, or admission control.

Tool Comparison

Quick comparisons help when readers want to understand why this sample uses several overlapping tools instead of only one scanner.

These are not universal rankings. They are fit-for-purpose notes for this pipeline shape: GitHub Actions, .NET workloads, GHCR publishing, Azure deployment, and staged DAST.

Quality and Secret Detection

ToolRole in this pipelineStrengthTradeoff or note
dotnet formatformatting gatedeterministic, fast, low-noisestyle-only, not security analysis
.NET analyzerscompile-time quality and API usage checksnative .NET feedback, easy to enforce with warnings as errorsnot replacement for dedicated SAST
Gitleakssecret detection in repository history and current contentstrong early fail-fast control for leaked credentialscan need baseline tuning for false positives
HadolintDockerfile lintingcatches common container hardening mistakes earlyfocuses on Dockerfile patterns, not full image content

IaC and Workflow Security

ToolRole in this pipelineStrengthTradeoff or note
Checkovscans GitHub Actions, Dockerfile, and secrets-style policiesgood left-shift coverage for delivery-system securitypolicy output can be noisy until teams tune rules
GitHub SARIF uploadpublishes findings into GitHub Security tabcentralizes review experience in same platform as PRsdepends on tools producing clean SARIF categories

SAST

ToolRole in this pipelineStrengthTradeoff or note
Semgrepfast blocking SAST lanebroad rule coverage, simple CI gating, quick feedbackless semantic depth than CodeQL
CodeQLdeep semantic SAST for C#strong data-flow analysis and GitHub-native integrationslower and heavier than pattern scanners
SonarQube / SonarCloudoptional quality and security hotspot laneuseful when teams already use Sonar governance and quality gatesextra platform dependency, not necessary for base sample

Application and Image SCA

ToolRole in this pipelineStrengthTradeoff or note
Trivyprimary blocking scanner for app filesystem and imageopen source, multi-target, covers vuln plus secret plus misconfigresults can overlap with other scanners, needs severity policy
Grypeadvisory second opinion for package and image vulnerability reviewuseful cross-check against another vulnerability database and matchersample treats it as advisory to reduce duplicate blocking noise
Snykoptional managed overlay for app and image scanningstrong policy management, curated vulnerability intelligence, good UIrequires token and paid-platform adoption for many teams

SBOM and Dependency Inventory

ToolRole in this pipelineStrengthTradeoff or note
CycloneDX for .NETgenerates application SBOM from solution and NuGet graphclean fit for .NET dependency inventory and CycloneDX outputapp-centric, not final container inventory
Syft (Anchore SBOM action)generates image SBOM for built container surfaceconvenient image-focused SBOM generation inside Actionsseparate from .NET project graph, so both outputs still matter
Dependency-Trackdownstream SBOM consumption and monitoringturns SBOM from static artifact into ongoing governance signaladds external platform to operate, and Trivy datasource needs separate Trivy server

Supply Chain Trust

ToolRole in this pipelineStrengthTradeoff or note
Cosignkeyless signing and verification of published image digeststrong modern signing model with GitHub OIDC and digest bindingteams must understand digest-based workflows, not only tags
GitHub artifact attestationsprovenance evidence for produced artifactsuseful for traceability and later policy enforcementstill additional supply-chain layer many teams are learning
GHCRregistry target for published and signed imagetight GitHub integration for permissions and automationregistry choice does not replace signing or scanning requirements

Deploy and Runtime Validation

ToolRole in this pipelineStrengthTradeoff or note
Azure Container Appsstaged deployment targetsimple managed runtime for promoting verified image digestsruntime platform hardening still matters beyond pipeline checks
OWASP ZAP Baselinepassive DAST after deploymenteasy first runtime check for headers, transport, and obvious web issuesbaseline mode is lighter than authenticated or active DAST

How to Read These Choices

If team wants smallest useful open-source stack, this sample can still work with Gitleaks, Hadolint, Checkov, Semgrep, CodeQL, Trivy, CycloneDX, Cosign, and ZAP.

If team already uses enterprise governance platforms, Sonar, Snyk, and Dependency-Track become strong overlays instead of mandatory foundation pieces.

For teams that want a local Dependency-Track lab instead of a managed instance, the sample repository includes a ready stack under deployments/dependency-track.

Local Developer Workflow

CI is strict, but local developer path is also covered.

This sample includes Husky.Net hooks so contributors catch issues before push.

dotnet tool restore
dotnet husky install
SOLUTION_PATH=DevSecOpsPipelineSample.slnx dotnet tool run husky -- run --name setup-solution-restore

This block brings part of security and quality discipline to developer machine. DevSecOps works better when some feedback happens before CI.

Why this matters in DevSecOps:

  • catching format, analyzer, and test failures before push shortens feedback loop
  • local hooks reduce avoidable CI churn and wasted runner minutes
  • developers learn pipeline expectations earlier instead of discovering them only in remote job logs
  • CI still remains source of truth, but local guardrails improve flow efficiency

Hooks live here:

Pre-commit runs formatting. Pre-push runs analyzers, build, and tests. CI still remains final policy gate.

Validation

Before treating pipeline as complete, validate these checks.

  1. GitHub Security tab receives distinct categories for Checkov, Semgrep, CodeQL, Trivy, Grype, and optional Snyk.
  2. Dependency-Track receives separate app and image SBOMs.
  3. Sonar lane skips cleanly when Sonar secrets are absent, or enforces Sonar gate when configured.
  4. security-gate writes report artifact and blocks publish when app or image security stage fails.
  5. Checkov findings still upload SARIF before the job fails on policy violations.
  6. App and image SBOM files both produce detached Sigstore bundles and both bundles verify successfully on trusted non-PR runs.
  7. Published image in GHCR has attached image SBOM evidence, signed digest metadata, attestation metadata, and expected tags for the current ref strategy.
  8. main pushes publish short SHA tag plus dev, main, and latest, while git tag pushes publish the git tag value.
  9. ci-evidence/build-metadata.json contains signedImageWithDigest, autoDeploy, and the expected targetEnvironment.
  10. Successful main CI runs trigger CD and deploy to dev, while successful tag CI runs trigger CD and deploy to staging.
  11. production starts only after successful staging smoke, k6, and ZAP validation, and only after GitHub Environment approval.
  12. dev, staging, and production GitHub environments each provide AZURE_RESOURCE_GROUP, CONTAINER_APP_NAME, and optional TARGET_API_URL variables plus Azure OIDC secrets.
  13. CD re-verifies the Cosign signature before deploy, rejects unsafe target URLs, and runs smoke, k6, and ZAP against the published endpoint.
  14. CI and CD each publish their own evidence artifacts: ci-evidence and cd-evidence.
  15. Pull request runs still produce readable CI step summaries and ci-evidence artifacts without triggering deployment.
  16. Local Dependency-Track stack starts cleanly from deployments/dependency-track/docker-compose.yml, and Dependency-Track can reach Trivy at http://trivy:8080 with the configured token.

Local validation still starts simple.

dotnet test DevSecOpsPipelineSample.slnx
docker build -t devsecops-pipeline-sample .

This is the smallest useful validation loop outside GitHub Actions. It does not replace the full pipeline, but it confirms the app and image can at least be exercised locally.

Why this matters in DevSecOps:

  • local reproduction reduces time spent debugging only in hosted runners
  • validating buildability before pushing helps keep security scans focused on real candidate artifacts
  • fast local verification is often first step when a pipeline stage fails and engineers need to narrow cause quickly

Conclusion

This pipeline is no longer just .NET build plus a few scanners. It is a layered CI/CD promotion workflow.

It starts with workflow-level guardrails, developer quality gates, IaC and SAST checks, splits app and image SCA correctly, adds centralized security gate, preserves SBOM and CI evidence, signs and verifies detached SBOM bundles, publishes trusted images to GHCR with Docker's official actions, attaches image SBOM to GHCR, signs published artifact with Sigstore Cosign, attests provenance, records deployment intent, then lets a separate CD workflow verify trust again before deploying to dev or staging, running smoke plus k6 plus ZAP, and promoting the same verified digest to production only after staging success and manual approval.

That shape is more useful than one huge job because each stage answers different question:

  • is code healthy?
  • is pipeline and Dockerfile safe?
  • does source contain dangerous patterns?
  • does the code meet broader quality and hotspot analysis expectations?
  • do dependencies or runtime layers contain known risk?
  • can deployed artifact be trusted?
  • should this signed artifact go to dev, staging, or production?
  • does the target endpoint show obvious runtime issues before promotion continues?

Tools may change over time. Stage boundaries should stay.

References

Article sample code is available here:

Useful references and related material: