Mengapa False Positive SAST Merusak DevSecOps Pipeline

Tingginya angka false positive (FP) pada Static Application Security Testing (SAST) adalah penyebab utama tim engineering mengabaikan security alert. Ketika rule SAST terlalu agresif, tim developer terbebani oleh puluhan temuan tidak valid, memperlambat proses code review, dan akhirnya menurunkan adopsi security tooling secara keseluruhan.

Solusi teknis untuk masalah ini bukan menurunkan sensitivitas scanning di production secara manual, melainkan memberlakukan Rule-as-Code dengan automated test suite. Setiap modifikasi atau penambahan custom rule Semgrep harus melewati benchmark otomatis di CI (Continuous Integration) untuk memverifikasi precision dan recall sebelum di-merge ke repository sentral.

Struktur Fixture Test: True Positive vs True Negative

Semgrep menyediakan native harness untuk pengujian rule melalui anotasi komentar. Untuk setiap file rule (misalnya rules/auth/insecure-jwt-secret.yaml), letakkan file target pengujian dengan nama yang sama di direktori yang sama atau sub-direktori pengujian (misalnya rules/auth/insecure-jwt-secret.js).

Anotasi yang didukung adalah:

  • // ruleid: <rule-id>: Menandai baris kode yang wajib terdeteksi sebagai vulnerabilitas (True Positive).
  • // ok: <rule-id>: Menandai baris kode aman yang sering memicu kesalahan pola AST, wajib tidak terdeteksi (True Negative).

Berikut adalah contoh implementasi custom rule Semgrep untuk mendeteksi hardcoded secret:

# File: rules/auth/insecure-jwt-secret.yaml
rules:
  - id: insecure-jwt-secret
    languages: [javascript, typescript]
    severity: ERROR
    message: "Hardcoded secret terdeteksi pada jwt.sign(). Gunakan environment variable."
    patterns:
      - pattern: jwt.sign($PAYLOAD, "...", ...)
      - pattern-not: jwt.sign($PAYLOAD, "development-mock-secret", ...)

Berikut adalah file fixture pendamping untuk menguji validitas rule di atas:

// File: rules/auth/insecure-jwt-secret.js
const jwt = require("jsonwebtoken");

function issueToken(payload) {
  // ruleid: insecure-jwt-secret
  return jwt.sign(payload, "SUPER_SECRET_KEY_PROD_123", { expiresIn: "1h" });
}

function issueSafeToken(payload) {
  // ok: insecure-jwt-secret
  return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "1h" });
}

function issueMockToken(payload) {
  // ok: insecure-jwt-secret
  return jwt.sign(payload, "development-mock-secret", { expiresIn: "1h" });
}

Menjalankan Native semgrep --test

Semgrep memiliki command bawaan semgrep --test untuk memvalidasi test fixture terhadap rule definitions. Command ini mencocokkan baris temuan engine dengan posisi tag ruleid dan memastikan tidak ada temuan pada baris ok.

# Eksekusi unit test rule dan output hasil ke JSON
semgrep --test --json -o semgrep-test-results.json rules/

Jika terjadi ketidakcocokan (misalnya baris ok terdeteksi atau baris ruleid terlewat), CLI Semgrep secara native mengembalikan non-zero exit code. Namun, untuk pipeline CI yang membutuhkan audit threshold spesifik (misalnya: mentoleransi 0% False Positive tetapi mengizinkan threshold regresi tertentu), kita memerlukan skrip evaluasi terpisah.

Skrip Gating Python Stdlib untuk Metrik Presisi

Hindari dependensi eksternal berat di CI runner hanya untuk parsing metrik. Gunakan Python standard library untuk memverifikasi file JSON output dari Semgrep test runner dan mengeksekusi quality gate.

#!/usr/bin/env python3
# File: scripts/evaluate_rules.py
import json
import sys

def evaluate(report_path: str, max_fp_allowed: int = 0) -> None:
    try:
        with open(report_path, "r", encoding="utf-8") as f:
            data = json.load(f)
    except FileNotFoundError:
        sys.stderr.write(f"Error: File {report_path} tidak ditemukan.
")
        sys.exit(2)

    # Parsing struktur internal semgrep test report
    # Data memuat ringkasan eksekusi: checks dan results
    stats = {
        "passed": 0,
        "false_positives": 0,
        "false_negatives": 0
    }

    # Semgrep native test output format
    results = data.get("results", [])
    for entry in results:
        if entry.get("passed") is True:
            stats["passed"] += 1
        else:
            # Kegagalan deteksi: unexpected match (FP) atau missing match (FN)
            for failure in entry.get("failures", []):
                fail_type = failure.get("type")
                if fail_type == "unexpected_match":
                    stats["false_positives"] += 1
                    print(f"[FP] Unexpected match di rule: {entry.get('rule_id')} baris {failure.get('line')}")
                elif fail_type == "missing_match":
                    stats["false_negatives"] += 1
                    print(f"[FN] Missing match di rule: {entry.get('rule_id')} baris {failure.get('line')}")

    total_issues = stats["false_positives"] + stats["false_negatives"]
    print("--- Hasil Evaluasi Rule Semgrep ---")
    print(f"Passed checks   : {stats['passed']}")
    print(f"False Positives : {stats['false_positives']}")
    print(f"False Negatives : {stats['false_negatives']}")

    # Quality Gate Policy
    if stats["false_positives"] > max_fp_allowed:
        sys.stderr.write(f"GATING FAILED: False Positive ({stats['false_positives']}) melebihi threshold ({max_fp_allowed}).
")
        sys.exit(1)

    if stats["false_negatives"] > 0:
        sys.stderr.write("GATING FAILED: Terjadi regresi True Positive (False Negative > 0).
")
        sys.exit(1)

    print("GATING PASSED: Rule siap dideploy.")
    sys.exit(0)

# ponytail: simple CLI parser, replace with argparse when adding export flags
if __name__ == "__main__":
    target_report = sys.argv[1] if len(sys.argv) > 1 else "semgrep-test-results.json"
    evaluate(target_report)

Workflow GitHub Actions dengan Caching

Konfigurasi workflow ini menjalankan benchmark hanya ketika ada perubahan pada direktori rules/ atau file skrip terkait. Tool cache dimanfaatkan untuk mencegah overhead instalasi Semgrep pada setiap trigger PR.

name: Semgrep Rule Benchmark

on:
  pull_request:
    paths:
      - 'rules/**'
      - 'scripts/evaluate_rules.py'
      - '.github/workflows/semgrep-benchmark.yml'

jobs:
  benchmark-rules:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Install Semgrep
        run: |
          pip install semgrep

      - name: Run Semgrep Test Suite
        run: |
          # semgrep --test return exit code non-zero jika test gagal.
          # Gunakan flag agar JSON tetap dihasilkan untuk dievaluasi gating script.
          semgrep --test --json -o semgrep-test-results.json rules/ || true

      - name: Evaluate Quality Gate
        run: |
          python scripts/evaluate_rules.py semgrep-test-results.json

Trade-offs dan Limitasi

1. Kompleksitas Taint Analysis

Rule berbasis mode: taint membutuhkan propagasi dari source ke sink. Test fixture sederhana terkadang lolos di test runner lokal jika definisi source dan sink berada dalam satu scope fungsi yang sama, namun menghasilkan FP tinggi pada framework arsitektur monolitik riil akibat sanitization step yang tidak terpetakan. Selalu sertakan test case dengan sanitizers di test fixture.

2. Sintaks Dinamis Bahasa Pemrograman

Bahasa pemrograman dinamis seperti JavaScript/Python memungkinkan aliasing impor modul. Pola import jwt from 'jsonwebtoken' berbeda penanganan AST-nya dengan dynamic import const jwt = await import('jsonwebtoken'). Pastikan suite fixture memuat ragam variasi penulisan impor yang lazim digunakan di internal codebase perusahaan.

3. Trade-off Precision vs Coverage

Menetapkan zero FP policy secara agresif dapat memicu under-detection (peningkatan False Negative). Aturan keamanan tingkat audit seringkali membutuhkan kelonggaran kontekstual. Pisahkan rule menjadi dua kategori:

  • Blocking Rules (Zero FP threshold): Rule yang langsung memblokir PR developer di repo aplikasi. Wajib lolos 100% precision gate.
  • Audit/Experimental Rules: Rule observasi yang dijalankan secara non-blocking untuk mengumpulkan data empiris sebelum dipromosikan ke blocking category.