Azure DevOps Pipeline’larında Otomatik NuGet Zafiyet Taraması (DevSecOps)

Yazılım projelerimizde kullandığımız üçüncü parti kütüphanelerin güvenliği, uygulamamızın genel sağlığı için hayati önem taşır. CI/CD süreçlerimize entegre edeceğimiz otomatik güvenlik taramaları sayesinde, olası zafiyetleri henüz production ortamına çıkmadan yakalayabiliriz. Bu yazıda, Azure DevOps pipeline’larınıza NuGet paketleri için nasıl otomatik zafiyet taraması (vulnerability scan) ekleyebileceğinizi adım adım anlatacağım.

Zafiyet Tarama Şablonunun Oluşturulması

İlk adım olarak, tarama işlemini üstlenecek ve projelerimizde tekrar tekrar kullanabileceğimiz bir YAML şablonu (template) hazırlıyoruz. Bu şablon arka planda bir PowerShell betiği çalıştırarak şu adımları gerçekleştirir:

  • Çalışma dizinindeki (checkout dizini) tüm .csproj dosyalarını bularak tarama kapsamına alır.
  • Her bir proje için dotnet list package --vulnerable --include-transitive komutunu çalıştırarak hem doğrudan kurulan hem de dolaylı (transitive) gelen paketleri denetler.
  • Belirlediğiniz eşik değerine (varsayılan olarak Critical ve High) uyan bir zafiyet tespit edilirse pipeline’ı “Fail” (hata) durumuna çeker.
  • Transitive paketlerdeki zafiyetler için dotnet nuget why komutunu kullanarak, hangi üst düzey (top-level) paketin bu zafiyetli kütüphaneyi getirdiğini loglarda detaylıca raporlar.

Aşağıdaki içeriği templates/nuget-vulnerability-scan.yml adıyla kaydedin:

# templates/nuget-vulnerability-scan.yml
parameters:
  - name: workingDirectory
    type: string
    default: '$(System.DefaultWorkingDirectory)'
  - name: severityThreshold
    type: string
    default: 'Critical,High'
  - name: failOnNotFound
    type: boolean
    default: false

steps:
  - task: PowerShell@2
    displayName: 'Scan and fail on vulnerable NuGet packages'
    inputs:
      targetType: 'inline'
      script: |
        $workingDir = "${{ parameters.workingDirectory }}"
        $severityList = "${{ parameters.severityThreshold }}" -split ',' | ForEach-Object { $_.Trim() }
        $failOnNotFound = [System.Convert]::ToBoolean("${{ parameters.failOnNotFound }}")

        Write-Host "Tarama dizini    : $workingDir"
        Write-Host "Fail eden seviyeler: $($severityList -join ', ')"

        $projects = Get-ChildItem -Path $workingDir -Recurse -Filter "*.csproj" -ErrorAction SilentlyContinue

        if (-not $projects -or $projects.Count -eq 0) {
          $msg = "Hic csproj dosyasi bulunamadi: $workingDir"
          if ($failOnNotFound) {
            Write-Host "##vso[task.logissue type=error]$msg"
            exit 1
          } else {
            Write-Host "##vso[task.logissue type=warning]$msg"
            exit 0
          }
        }

        Write-Host "Taranacak proje sayisi: $($projects.Count)"
        $projects | ForEach-Object { Write-Host " - $($_.FullName)" }

        $findings = @()

        foreach ($proj in $projects) {
          Write-Host "Taraniyor: $($proj.FullName)"
          $vulnJsonPath = Join-Path $env:AGENT_TEMPDIRECTORY "vuln_$($proj.BaseName)_$([guid]::NewGuid().ToString('N')).json"

          dotnet list "$($proj.FullName)" package --vulnerable --include-transitive --format json | Out-File $vulnJsonPath -Encoding utf8

          $content = Get-Content $vulnJsonPath -Raw -ErrorAction SilentlyContinue
          if (-not $content) { continue }

          try { $json = $content | ConvertFrom-Json } catch {
            Write-Host "##[warning]$($proj.Name) icin JSON parse edilemedi, atlaniyor."
            continue
          }
          if (-not $json.projects) { continue }

          foreach ($project in $json.projects) {
            foreach ($fw in $project.frameworks) {

              foreach ($pkg in @($fw.topLevelPackages)) {
                if ($pkg.vulnerabilities) {
                  foreach ($v in $pkg.vulnerabilities) {
                    if ($v.severity -in $severityList) {
                      $findings += [pscustomobject]@{
                        Project    = $proj.Name
                        Package    = $pkg.id
                        Resolved   = $pkg.resolvedVersion
                        Severity   = $v.severity
                        Advisory   = $v.advisoryurl
                        Type       = 'TopLevel'
                        UpdateHint = "PackageReference'i direkt guncelleyin: $($pkg.id)"
                      }
                    }
                  }
                }
              }

              foreach ($pkg in @($fw.transitivePackages)) {
                if ($pkg.vulnerabilities) {
                  foreach ($v in $pkg.vulnerabilities) {
                    if ($v.severity -in $severityList) {
                      $whyOutput = dotnet nuget why "$($proj.FullName)" "$($pkg.id)" 2>&1 | Out-String
                      $findings += [pscustomobject]@{
                        Project    = $proj.Name
                        Package    = $pkg.id
                        Resolved   = $pkg.resolvedVersion
                        Severity   = $v.severity
                        Advisory   = $v.advisoryurl
                        Type       = 'Transitive'
                        UpdateHint = $whyOutput.Trim()
                      }
                    }
                  }
                }
              }
            }
          }
        }

        if ($findings.Count -gt 0) {
          Write-Host "##vso[task.logissue type=error]NuGet guvenlik zaafiyeti bulundu ($($severityList -join '/'))!"
          Write-Host ""
          foreach ($item in $findings) {
            Write-Host "============================================================"
            Write-Host "Proje    : $($item.Project)"
            Write-Host "Paket    : $($item.Package) ($($item.Resolved))"
            Write-Host "Seviye   : $($item.Severity)"
            Write-Host "Advisory : $($item.Advisory)"
            Write-Host "Tip      : $($item.Type)"
            if ($item.Type -eq 'Transitive') {
              Write-Host "--- Bagimlilik zinciri (hangi paketi guncellemelisiniz) ---"
              Write-Host $item.UpdateHint
            } else {
              Write-Host "Aksiyon  : $($item.UpdateHint)"
            }
            Write-Host ""
          }
          exit 1
        } else {
          Write-Host "Belirtilen seviyede ($($severityList -join '/')) zafiyet bulunamadi."
        }

(Not: Bu işlemin düzgün çalışabilmesi için pipeline agent’ınızda .NET SDK 8.0.4 veya üzeri bir sürümün kurulu olması ve UseDotNet@2 adımının bu betikten önce çalıştırılmış olması gerekmektedir.)

Ana Pipeline’a Entegrasyon Senaryoları

Şablonu oluşturduktan sonra, projelerinizin ana azure-pipelines.yml dosyasında bu şablonu nasıl çağıracağınıza karar vermelisiniz. İhtiyacınıza göre iki farklı senaryo kullanabilirsiniz:

Şablon Aynı Repo İçerisindeyse Eğer projeniz monorepo yapısındaysa veya şablonu sadece o proje için kullanacaksanız, şablonu doğrudan yolunu belirterek çağırabilirsiniz:

steps:
  - task: NuGetCommand@2
    name: 'NugetUpdate'
    inputs:
      restoreSolution: '${{ parameters.solutionFileName }}'
      feedsToUse: 'config'
      continueOnError: true
      includeNuGetOrg: true

  - template: templates/nuget-vulnerability-scan.yml

  - task: DotNetCoreCLI@2
    displayName: 'Publish Project'
    condition: and(succeeded(), ne(${{ parameters.createImage }}, true))
    inputs:
      command: publish
      projects: '**/${{ parameters.ProjectFileName }}'
      publishWebProjects: False
      arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
      zipAfterPublish: True

Bu yapı sayesinde tek bir satır kod ile tüm uygulamalarınızı bilinen güvenlik zafiyetlerine karşı tarayabilir, geliştirme süreçlerinizi çok daha güvenli hale getirebilirsiniz!