Mert Özen Her Satırda Daha İleri
Güvenlik DevSecOps — #06

DAST: ZAP ile Çalışan Uygulamayı Taramak — Baseline, API Scan ve Authenticated Tarama

Mert Özen 14 Kas 2026 15 dk 6 görüntülenme
DAST: ZAP ile Çalışan Uygulamayı Taramak — Baseline, API Scan ve Authenticated Tarama

DAST çalışan uygulamaya dışarıdan istek gönderip header, cookie, hata sızıntısı ve yansıyan girdi gibi davranış açıklarını bulur; ama yalnız ulaşabildiği endpoint'leri tarar ve authorization hatasını göremez. Bookstore.Api'yi ZAP 2.17 ile baseline, API scan ve Automation Framework planıyla tarıyor, token'lı taramada oturumun düştüğünü yakalamayı, bulguları integration teste çevirmeyi ve main'de çalışan DAST job'ını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; taramalar ZAP (açık kaynak web güvenlik tarama aracı) 2.17.0 (ghcr.io/zaproxy/zaproxy:stable) ile docker compose'la ayağa kaldırılan yerel bir Bookstore.Api ortamına karşı çalıştırılmış, regresyon testleri xUnit v3 4.0 ile koşturulmuştur.

Önce Tahmin Et

Bookstore.Api'nin test ortamı bir container'da çalışıyor; OpenAPI dokümanı açık, bir endpoint kullanıcıdan gelen bir parametreyi HTML (HyperText Markup Language) sayfasına encode etmeden basıyor, bir başka endpoint ise yalnız oturum açmış kullanıcıya açık ve beklenmedik girdide stack trace döndürüyor. ZAP'ın "baseline" taramasını API'nin kök adresine karşı çalıştırıyorsun. Tahmin et: kaç URL görür? Aynı uygulamayı OpenAPI dokümanıyla, aktif olarak taradığında XSS (Cross-Site Scripting) açığını bulur mu? Ve token'ı tarama ortasında süresi dolsa, rapor bunu sana söyler mi?

Bu yazıda DAST'ı (çalışan uygulamaya dışarıdan saldırarak yapılan güvenlik testi) Bookstore pipeline'ına ekliyoruz: DAST'ın çalışan uygulamada neyi görüp neyi göremediği, ZAP'ın üç tarama modu, Automation Framework YAML'ı, authenticated tarama ve oturumun düştüğünü fark etmek, bulguları kalıcı testlere çevirmek ve gereksiz uyarıları yönetmek. Bütün taramalar yalnız kendi makinemde, kendi container'ımda çalışan Bookstore'a karşı yapıldı; bu yazıdaki hiçbir komutu sahibi olmadığın ya da yazılı izin almadığın bir hedefe karşı çalıştırma.

DAST Neyi Görür, Neyi Göremez

Dördüncü yazıdaki SAST (kaynak kodu çalıştırmadan tarayan güvenlik testi) kaynak koda bakıyordu; DAST ise uygulamaya dışarıdan, bir istemci gibi HTTP istekleri gönderir ve yanıtları inceler. Kodu bilmez, dile bakmaz; gördüğü tek şey çalışan sistemin davranışıdır. Bu yüzden SAST'ın zor yakaladığı şeyleri kolayca görür: eksik security header'lar, cookie flag'leri, hata sayfalarının sızdırdığı stack trace, girdinin yanıta encode edilmeden yansıması, yanlış ortam ayarı. Bunların çoğu kodda değil configuration'da ya da framework'ün çalışma anındaki davranışında ortaya çıkar.

Göremediği şeyler de aynı nedenden gelir. DAST bir isteğin "doğru" olup olmadığını bilmez; yalnız yanıtta tanıdığı bir iz arar. Ada'nın token'ıyla Alan'ın siparişini okuyabilmek, yanıt açısından tamamen normal bir 200'dür. İş kuralı ve authorization hataları, ancak ne olması gerektiğini bilen bir test ile yakalanır; bunu yazının sonunda gerçek bir denemeyle göreceğiz. İkinci sınır kapsam: tarayıcı yalnız ulaşabildiği endpoint'leri tarar, yani ona ne kadar iyi bir harita ve ne kadar anlamlı girdi verdiğin sonucu doğrudan belirler.

Bir de kural: aktif DAST taraması gerçek saldırı payload'ları gönderir. Yalnız kendi kurduğun, bu iş için ayrılmış bir ortama karşı çalıştır. Production'a aktif tarama yapma; paylaşılan bir staging'e karşı yapacaksan önce ortamın sahibiyle konuş.

Ephemeral Ortam ve Üç Tarama Modu

Hedef, pipeline'ın her koşuda sıfırdan kurup sonunda yok ettiği bir ortam. Bookstore.Api için bir servisli compose dosyası yetiyor; imzalama anahtarı dışarıdan, pipeline secret'ından geliyor:

name: bookstore-dast

services:
  api:
    build: .
    container_name: devsecseries-api
    environment:
      ASPNETCORE_ENVIRONMENT: Staging
      Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: Information
      Auth__SigningKey: ${DAST_SIGNING_KEY:?DAST_SIGNING_KEY is required}
    ports:
      - "127.0.0.1:18080:8080"

Request log'unu açık tuttum, çünkü tarayıcının gerçekte ne yaptığını görmenin en güvenilir yolu uygulamanın kendi log'u. İlk sürümde ortam Development olarak ayarlıydı; test ortamlarında sık görülen bu hatanın ne sızdırdığına aşağıda bakacağız.

ZAP Docker image'ı üç hazır script ile geliyor. zap-baseline.py hedefi spider ile gezer ve yalnız pasif kuralları çalıştırır, yani hiçbir saldırı isteği göndermez. zap-full-scan.py buna aktif taramayı ekler. zap-api-scan.py ise spider yerine OpenAPI, SOAP (XML tabanlı eski web servis protokolü) ya da GraphQL tanımından yola çıkar ve API'lere göre ayarlanmış bir aktif tarama yapar. Önce en ucuzundan, baseline'dan başladım:

docker run --rm --network bookstore-dast_default -v "$PWD/reports:/zap/wrk:rw" \
  ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t http://api:8080/ -J baseline-v1.json
Total of 4 URLs
WARN-NEW: Storable and Cacheable Content [10049] x 2
	http://api:8080/ (404 Not Found)
	http://api:8080/sitemap.xml (404 Not Found)
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 1	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 67
Automation plan warnings:
	Job spider error accessing URL http://api:8080/ status code returned : 404 expected 200

Yirmi dört saniye, 67 PASS ve neredeyse sıfır bilgi. Spider bir API'nin kökünde takip edecek link bulamadı; 67 kuralın geçmesinin tek nedeni bakacak bir şey olmaması. Baseline, link'lerle gezilebilen bir web uygulaması (Bookstore'un Angular arayüzü gibi) için anlamlı; JSON (JavaScript Object Notation) dönen bir API için yanlış araç. "Yeşil" bir DAST raporu, taramanın neyi gerçekten ziyaret ettiğini görmeden hiçbir şey söylemez.

Sonra API scan'i OpenAPI dokümanıyla çalıştırdım. ZAP 11 URL import etti ve iki uyarı verdi: X-Content-Type-Options ve Cross-Origin-Resource-Policy header'ları eksik. Uygulama log'undan istekleri saydığımda sebep ortaya çıktı:

 122 Request finished HTTP/1.1 GET http://api:8080/preview/10?… - 404
 122 Request finished HTTP/1.1 GET http://api:8080/orders/export?… - 401

ZAP path parametresi id için varsayılan olarak 10 değerini üretti; Bookstore'un test verisinde 10 numaralı kitap yok, dolayısıyla önizleme endpoint'ine giden 122 saldırı isteğinin hepsi 404'e çarptı ve koda hiç ulaşmadı. Sipariş endpoint'ine gidenler de token olmadığı için 401 aldı. ZAP'ın form handler ayarıyla id için 1 değerini verince önizleme sayfası 200 döndü, cookie ve HTML header bulguları geldi; ama XSS yine yoktu. Image içindeki script'e baktığımda nedenini gördüm: API scan, API-Minimal adlı sabit bir policy kullanıyor ve bu policy'de XSS kuralları kapalı. Bookstore'un HTML dönen bir önizleme endpoint'i olduğu için API'ye özel policy burada kör kalıyor.

Automation Framework: Taramayı Kod Olarak Yazmak

Hazır script'ler hızlı başlangıç için iyi, ama ne çalıştığını açıkça görmek ve repo'da review etmek istediğinde Automation Framework daha doğru araç: tarama, sırayla çalışan job'lardan oluşan bir YAML planı. Bookstore'un planı şöyle:

env:
  contexts:
    - name: bookstore
      urls:
        - http://api:8080
  parameters:
    failOnError: true
    progressToStdout: true

jobs:
  - type: alertFilter
    alertFilters:
      - ruleId: 10104
        newRisk: False Positive
        url: http://api:8080/orders/export.*
        urlRegex: true
  - type: requestor
    requests:
      - name: session-check-before
        url: http://api:8080/orders/1
        responseCode: 200
  - type: openapi
    parameters:
      apiUrl: http://api:8080/openapi/v1.json
      context: bookstore
  - type: passiveScan-config
    parameters:
      maxAlertsPerRule: 10
  - type: activeScan
    parameters:
      context: bookstore
      maxScanDurationInMins: 10
  - type: passiveScan-wait
  - type: requestor
    requests:
      - name: session-check-after
        url: http://api:8080/orders/1
        responseCode: 200
  - type: report
    parameters:
      template: traditional-json
      reportDir: /zap/wrk
      reportFile: zap-report
  - type: exitStatus
    parameters:
      errorLevel: High
      warnLevel: Medium

openapi job'ı haritayı yükler, activeScan varsayılan policy ile aktif taramayı yapar, exitStatus ise sonucu çıkış koduna çevirir: High ya da üstü bir alert 1, Medium 2, temiz bir tarama 0. requestor ve alertFilter job'larına aşağıda geleceğiz. Plan şöyle çalıştırılıyor; form handler ayarı, test verisinde gerçekten var olan bir id değerini veriyor:

docker run --rm --network bookstore-dast_default -v "$PWD/reports:/zap/wrk:rw" \
  ghcr.io/zaproxy/zaproxy:stable zap.sh -cmd -autorun /zap/wrk/plan.yaml \
  -config 'formhandler.fields.field(0).fieldId=id' \
  -config 'formhandler.fields.field(0).value=1' \
  -config 'formhandler.fields.field(0).enabled=true'

Burada ikinci bir ders çıktı. İlk denemede değeri 1001 yapmıştım, çünkü Ada'nın siparişinin numarası 1001'di; bu kez de kitap ve önizleme endpoint'leri 404 döndü ve XSS bulgusu kayboldu. Form handler alan adına göre çalışıyor ve id adlı her path parametresine aynı değeri veriyor. Çözüm tarayıcıyı değil test verisini ayarlamak oldu: ephemeral ortamın seed verisini, Ada'nın 1 numaralı siparişi ve 1 numaralı kitap aynı değerle bulunabilecek şekilde düzenledim. DAST ortamının verisi, tarama configuration'ının bir parçası.

Aktif tarama iki dakika sürdü. İlk sürüm için sonuç:

KuralBulguRiskNerede
40012Cross Site Scripting (Reflected)High/preview/1?campaign=
40026Cross Site Scripting (DOM Based)High/preview/1?campaign=
10038Content Security Policy (CSP) Header Not SetMedium/preview/1
10020Missing Anti-clickjacking HeaderMedium/preview/1
10010, 10054Cookie No HttpOnly Flag, Cookie without SameSite AttributeLow/books/1
10021X-Content-Type-Options Header MissingLow5 URL

Aynı uygulama, aynı ZAP sürümü: baseline bir şey bulamadı, API scan header'ları buldu, varsayılan policy'li aktif tarama iki high XSS bulgusu verdi. Fark araçta değil, taramaya verdiğin harita, girdi ve policy'de.

Authenticated Tarama ve Oturumun Düşmesi

Sipariş endpoint'leri hâlâ 401 dönüyordu, yani uygulamanın en değerli kısmı hiç taranmamıştı. Ephemeral ortam için ayrı bir test issuer'ı tanımladım: ortam yalnız pipeline secret'ında duran bir anahtarla imzalanmış token'lara güveniyor ve pipeline, test kullanıcısı Ada için kısa ömürlü bir JWT üretiyor. .NET 10'un file-based app desteği sayesinde bunun için proje bile gerekmiyor:

#:package Microsoft.IdentityModel.JsonWebTokens@8.23.0

using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

// Yalnız ephemeral test ortamı içindir: anahtar pipeline secret'ından gelir
string key = Environment.GetEnvironmentVariable("DAST_SIGNING_KEY")
    ?? throw new InvalidOperationException("DAST_SIGNING_KEY is not set.");
int minutes = args.Length > 0 ? int.Parse(args[0]) : 30;

string token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
    Issuer = "bookstore-test-issuer",
    Audience = "bookstore-api",
    Subject = new ClaimsIdentity([new Claim("sub", "ada")]),
    Expires = DateTime.UtcNow.AddMinutes(minutes),
    SigningCredentials = new SigningCredentials(
        new SymmetricSecurityKey(Convert.FromBase64String(key)), SecurityAlgorithms.HmacSha256),
});
Console.WriteLine(token);

Bu anahtar yalnız bu ortam için var ve production issuer'ı bu token'ları tanımaz. Gerçek bir kimlik sağlayıcın varsa (Keycloak, Entra ID) aynı işi o sağlayıcıda tanımlı, şifresi pipeline secret'ında duran bir test kullanıcısıyla yaparsın; secret'ların pipeline'a nasıl taşınacağı CI/CD Temelleri serisinin "Pipeline Güvenliği ve Secret'lar: Least Privilege, Kısa Ömürlü Credential ve Supply Chain" yazısında.

Token'ı ZAP'a vermenin en basit yolu ortam değişkenleri: ZAP_AUTH_HEADER_VALUE her isteğe eklenecek değer, ZAP_AUTH_HEADER header adı (varsayılanı Authorization), ZAP_AUTH_HEADER_SITE ise header'ın hangi sitelere ekleneceği. İlk denemede site değerine api:8080 yazdım; tarama hatasız bitti, rapor bir önceki taramanın aynısıydı ve uygulama log'unda 617 kez AuthenticationScheme: Bearer was challenged vardı. Header hiç eklenmemişti. Aynı denemeyi üç değerle tekrarladım: api:8080 ve http://api:8080 ile istekler 401 aldı, yalnız host adı olan api ile token gönderildi. ZAP bu yanlış eşleşme için hiçbir uyarı vermedi.

İşte requestor job'larının görevi bu. Planın başında ve sonunda yalnız oturum açmış kullanıcının görebileceği /orders/1'i isteyip 200 bekliyorum. Token'ı bilerek bir dakikalık üretip iki dakikalık taramayı başlattım:

Automation plan warnings:
	Difference in response code values for message GET http://api:8080/orders/1 Expected : 200 Received : 401

Uygulama log'u da aynı şeyi gösteriyordu: sipariş endpoint'lerine giden isteklerin 130'u 401 almıştı ve 2.066 satırda şu mesaj vardı:

Bearer was not authenticated. Failure message: IDX10223: Lifetime validation failed. The token is expired. ValidTo (UTC): '11/13/2026 22:26:13', Current time (UTC): '11/13/2026 22:26:17'.

Raporda ise bulgu listesi tam bir taramanınkiyle aynı görünüyordu, çünkü önemli bulgular token düşmeden önce yakalanmıştı. Bir dahaki sefere o kadar şanslı olmayabilirsin. Oturumun düştüğünü raporun kendisinden anlayamazsın; ya tarayıcıya bir kontrol isteği yaptırırsın ya da hedefin log'una bakarsın. Pipeline'da ikisini birden yapıyoruz. Gerçek bir kimlik sağlayıcıyla ZAP'ın kendi authentication ve session yönetimini (login akışı, verification strategy, poll URL) kullanıp token'ı tarama sırasında yeniletmek de mümkün; ama buradaki gibi header ile token verdiğinde token'ın tarama boyunca geçerli kalmasından sen sorumlusun.

Token ile yapılan tarama, anonim taramanın göremediği bir bulgu ekledi: /orders/export?format=format isteği 500 döndü ve ZAP 90022 Application Error Disclosure raporladı. Ortam Development olduğu için yanıt şöyle başlıyordu:

System.NotSupportedException: Export format 'xml' is not supported.
   at Bookstore.Api.Controllers.OrdersController.Export(String format) in /src/src/Bookstore.Api/Controllers/OrdersController.cs:line 26

Sınıf adı, dosya yolu, satır numarası. Hata sayfalarının neden ortama göre ayrılması gerektiği OWASP (web uygulama güvenliği üzerine çalışan açık topluluk) serisinin "OWASP A02: Security Misconfiguration — Hata Sayfaları, OpenAPI, Header'lar ve XXE" yazısında anlatıldı; burada önemli olan, bu bulgunun yalnız authenticated taramada ortaya çıkması.

Bulguyu Düzeltmek ve Testle Kilitlemek

DAST bulgusu bir semptomdur; düzeltme kodda, kalıcılık testte. Dört düzeltme yaptım. Önizleme sayfası girdiyi HtmlEncoder ile encode ediyor:

HtmlEncoder encoder = HtmlEncoder.Default;
string html = $"<html><head><title>{encoder.Encode(book.Title)}</title></head>" +
              $"<body><h1>{encoder.Encode(book.Title)}</h1>" +
              $"<p>Campaign: {encoder.Encode(campaign ?? "")}</p></body></html>";
return Content(html, "text/html");

Her yanıt nosniff ve kısıtlayıcı bir CSP (tarayıcının hangi kaynakları yükleyebileceğini belirleyen güvenlik header'ı) alıyor; cookie HttpOnly, Secure ve SameSite=Lax ile yazılıyor; bilinmeyen export formatı exception yerine 400 ProblemDetails dönüyor ve ortam Staging'e çekildi:

app.UseExceptionHandler();
app.Use(async (context, next) =>
{
    // Her yanıtta: MIME sniffing yok, frame içine alınamaz, varsayılan olarak hiçbir kaynak yüklenmez
    context.Response.Headers.XContentTypeOptions = "nosniff";
    context.Response.Headers.ContentSecurityPolicy = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
    await next();
});

CSP satırı ilk hâlinde yalnız default-src 'none'; frame-ancestors 'none' idi. Düzeltmeden sonraki taramada XSS, cookie ve hata sızıntısı bulguları kayboldu ama yeni bir medium geldi: 10055 CSP: Failure to Define Directive with No Fallback. base-uri ve form-action, default-src'ye geri düşmeyen directive'ler; açıkça yazılmazlarsa kısıtlanmazlar. İkisini ekleyince plan Automation plan succeeded! ile 0 döndü. Header'ların ayrıntılı tasarımı ASP.NET Core Güvenliği serisinin "Security Header'ları ve Nonce'lu CSP'yi ASP.NET Core'dan Üretmek" yazısında.

Şimdi asıl önemli adım: her bulguyu, aktif tarama gerektirmeyen ve her PR'da saniyeler içinde koşan bir integration teste çevirmek. DAST iki dakika sürüyor ve main'de çalışıyor; test ise regresyonu kodun girdiği anda yakalıyor:

// ZAP 40012: campaign parametresi encode edilmeden HTML'e yansıyordu
[Fact]
public async Task Preview_encodes_campaign_parameter()
{
    string html = await _client.GetStringAsync("/preview/1?campaign=%3Cscript%3Ealert(1)%3C/script%3E");

    Assert.DoesNotContain("<script>", html);
    Assert.Contains("&lt;script&gt;", html);
}

// ZAP 90022: bilinmeyen format stack trace değil 400 ProblemDetails döner
[Fact]
public async Task Unknown_export_format_returns_problem_without_stack_trace()
{
    using HttpRequestMessage request = new(HttpMethod.Get, "/orders/export?format=xml");
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", CreateToken("ada"));

    HttpResponseMessage response = await _client.SendAsync(request);
    string body = await response.Content.ReadAsStringAsync();

    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
    Assert.DoesNotContain("   at ", body);
}

Test sınıfında bunların yanında header'lar için üç URL'li bir theory ve cookie flag'leri için bir test daha var; WebApplicationFactory ortamı Staging olarak ve her koşuda rastgele üretilen bir imzalama anahtarıyla kuruyor. Testleri önce düzeltmeden önceki koda karşı çalıştırdım; altısı da kırıldı. Düzeltilmiş kodda:

Test run summary: Passed!
  total: 6
  failed: 0
  succeeded: 6
  skipped: 0

Bulgudan regresyon testine geçişin genel yöntemi API Güvenliği serisinin "API Güvenliğini Sürekli Test Etmek ve İzlemek" yazısında; DAST bu akışta yeni bulgu kaynağı, test ise kalıcı hafıza.

Gereksiz Uyarılar ve Gate: alertFilter, exitStatus ve Pipeline

Son taramada geriye tek bir bilgi seviyesinde bulgu kalmıştı: 10104 User Agent Fuzzer, export endpoint'inin farklı User-Agent değerlerine farklı yanıt verdiğini not ediyordu. Bu endpoint'te farkın nedeni bilinçli input doğrulaması, yani bizim için gereksiz bir uyarı. Plandaki alertFilter job'ı bu kuralı yalnız o URL için false positive'e çeviriyor; filtre repo'da, review'dan geçerek ve hangi kural ile hangi URL'ye uygulandığı açıkça yazılarak duruyor. Hazır script'lerde aynı iş, kural ID'si ile IGNORE, WARN, FAIL ya da OUTOFSCOPE eşlemesi yapan, -c ile verilen bir rules dosyasıyla yapılıyor. ZAP 2.17 bu konuda kendi tarafından da yardım ediyor: pasif tarayıcı eşdeğer istekleri birleştirip tekrar eden alert'leri azaltıyor ve site genelindeki header bulgularını "Systemic" olarak işaretleyip binlerce örnek yerine birkaç örnek raporluyor.

Gate kararı exitStatus'ta: High ya da üstü pipeline'ı kırıyor, Medium uyarı veriyor. Serinin ilk yazısındaki "yeni bulguyu engelle" ilkesinin DAST karşılığı bu. Workflow:

name: dast

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  zap:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    environment: dast
    env:
      ZAP_IMAGE: ghcr.io/zaproxy/zaproxy@sha256:9624669db72f54d86c4b46a7064518bd9bf79d8c61a4c31befa9923489f328af # 2.17.0
      DAST_SIGNING_KEY: ${{ secrets.DAST_SIGNING_KEY }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
        with:
          dotnet-version: 10.0.x
      - name: Start ephemeral environment
        run: |
          docker compose up -d --build
          curl -sf --retry 15 --retry-delay 2 --retry-connrefused -o /dev/null http://127.0.0.1:18080/openapi/v1.json
      - name: ZAP scan (authenticated)
        run: |
          token=$(dotnet run tools/mint-test-token.cs -- 30)
          echo "::add-mask::$token"
          mkdir -p reports && chmod 777 reports && cp zap/plan.yaml reports/
          docker run --rm --network bookstore-dast_default \
            -e ZAP_AUTH_HEADER_VALUE="Bearer $token" -e ZAP_AUTH_HEADER_SITE=api \
            -v "$PWD/reports:/zap/wrk:rw" "$ZAP_IMAGE" \
            zap.sh -cmd -autorun /zap/wrk/plan.yaml \
            -config 'formhandler.fields.field(0).fieldId=id' \
            -config 'formhandler.fields.field(0).value=1' \
            -config 'formhandler.fields.field(0).enabled=true'
      - name: Fail if the session dropped
        if: always()
        run: |
          if docker compose logs api | grep -q "The token is expired"; then
            echo "::error::The test token expired during the scan; results are incomplete."
            exit 1
          fi
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        if: always()
        with:
          name: zap-report
          path: reports/zap-report.json
      - name: Tear down
        if: always()
        run: docker compose down

Workflow'u actionlint 1.7.12 ile doğruladım; adımların komutlarını yerelde aynı sırayla çalıştırdım, "session dropped" kontrolü süresi dolmuş token'lı koşunun log'unda tetiklendi, temiz koşuda tetiklenmedi. Bir koşuda token üreten adım bozuk bir değer verdi ve uygulama IDX14122: JWT is not a well formed JWE ile reddetti; planın başındaki requestor bunu taramanın ilk saniyesinde gösterdi. ZAP image'ı tag yerine digest ile sabit: geçen yazıdaki Trivy olayından çıkan kural tarayıcı image'ları için de geçerli. environment: dast, secret'ı yalnız bu job'a açıyor; ayrıntısı GitHub Actions serisinin "GitHub Actions'ta Secret, Variable ve GITHUB_TOKEN Permission'ları" yazısında.

Authorization: DAST'ın Göremediği

Başta söylediğim sınırı denedim. OrdersController'daki sahiplik kontrolünü (o.CustomerId == sub) kaldırdım, image'ı yeniden build ettim ve Ada'nın token'ıyla Alan'ın siparişini istedim:

{"id":2,"customerId":"alan","bookIds":[2]}

Bu, IDOR (yetki kontrolü yapılmadan id ile başkasının kaydına erişilmesi) denen klasik authorization açığı. Aynı plan aynı token'la iki dakika boyunca taradı ve Automation plan succeeded! ile 0 döndü; tek kayıt, false positive'e çevrilmiş bilgi seviyesindeki alert'ti. ZAP'ın bakış açısından 200 dönen, JSON içeren, header'ları doğru bir yanıt kusursuzdu. Kontrolü geri koyunca aynı istek 404 döndü. Bu açığı yakalayan şey, "Ada'nın token'ıyla Alan'ın siparişi 404 dönmeli" diyen bir testtir; API Güvenliği serisindeki authorization regresyon testleri tam bu boşluğu dolduruyor. DAST, SAST ve bu testler birbirinin yerine geçmez; her biri diğerinin kör noktasına bakar.

Üretimde Dikkat

Süre bütçesi koy. Bookstore'un küçük API'sinde aktif tarama iki dakika sürdü; endpoint sayısı arttıkça bu süre hızla büyür. maxScanDurationInMins ve job'ın timeout-minutes değeri, taramanın pipeline'ı kilitlemesini engeller. Büyük uygulamalarda aktif taramayı her main push'unda değil gecelik bir zamanlamayla, pasif kısmı her deploy'da çalıştırmak makul bir denge.

Ortamı production'a benzet ama production verisi kullanma. Ortam değişkeni, header middleware'i ve exception handler production'daki gibi olmalı; aksi hâlde ya production'da olmayan bulgularla uğraşırsın ya da production'daki açığı kaçırırsın. Veri ise sentetik olmalı: aktif tarama yazma endpoint'lerine binlerce anlamsız kayıt gönderir.

Raporu artifact olarak sakla ve tarihlendir. Bir bulgunun ilk ne zaman göründüğü, dokuzuncu yazıda ölçeceğimiz "zafiyet yaşı" metriğinin girdisi.

ZAP image'ını Dependabot ya da Renovate ile güncelle. Digest sabitlemek tekrarlanabilirlik verir; ama kural setleri ve tarayıcının kendisi de güncellenen bir bağımlılık.

Sık Düşülen Tuzaklar

Birincisi, bir API'ye baseline taraması yapıp temiz raporu güvenlik kanıtı saymak. Spider'ın gezecek link'i yoksa rapor yalnız ziyaret edilmeyen sayfaların listesidir.

İkincisi, tarayıcının ürettiği girdilerin koda ulaşıp ulaşmadığını kontrol etmemek. Uygulama log'unda status code dağılımına bak; isteklerin çoğu 404 ya da 401 ise tarama kapının önünde dönmüştür.

Üçüncüsü, authenticated taramayı kurup doğrulamamak. Header'ın gerçekten eklendiğini ve tarama boyunca geçerli kaldığını, tarama öncesi ve sonrası bir kontrol isteğiyle ve hedefin log'uyla doğrula.

Dördüncüsü, DAST bulgusunu yalnız tarayıcı ayarıyla susturmak. Bir bulgu ya kodda düzeltilir ve testle kilitlenir ya da gerekçesiyle filtrelenir; sessiz bir IGNORE satırı, bir sonraki gerçek açığı da gizleyebilir.

Beşincisi, test ortamını Development ile çalıştırmak. Developer exception page'i hem bilgi sızdırır hem de production'da olmayan bir davranışı test etmene yol açar.

Küçük Bir Deneme

★☆☆ Kendi API'ne (yalnız kendi yerel ortamına) zap-baseline.py ve OpenAPI dokümanıyla zap-api-scan.py çalıştır. İki taramanın ziyaret ettiği URL sayısını ve uygulama log'undaki status code dağılımını karşılaştır.

★★☆ Bir Automation Framework planı yaz, başına ve sonuna korumalı bir endpoint için requestor kontrolü koy. Kısa ömürlü bir token'la taramayı başlatıp kontrolün token'ın düştüğünü yakaladığını gör.

★★★ ZAP'ın bulduğu bir header ya da encoding bulgusunu kodda düzelt ve onu WebApplicationFactory ile bir integration teste çevir. Testin düzeltmeden önceki kodda kırıldığını, sonrakinde geçtiğini doğrula; sonra aynı bulgunun bir sonraki taramada kaybolduğunu gör.

Artık kodu, bağımlılıkları, image'ı ve çalışan uygulamayı tarıyoruz. Ama yarın yeni bir CVE (yayımlanmış güvenlik açıklarının standart kimlik numarası) yayımlandığında "bu bizi etkiliyor mu?" sorusuna hangi image'da hangi paketin hangi sürümle çalıştığını bilmeden cevap veremeyiz. Bir sonraki yazıda SBOM (yazılımın içerdiği bileşenlerin listesi) ve VEX (bir zafiyetin üründe gerçekten istismar edilebilir olup olmadığını bildiren doküman) ile Bookstore'un ne kullandığını kayıt altına alıyoruz.