Triage vulnerabilities and gate CI

Resolve and classify findings, then fail a CI build when a quality gate is breached.

The problem

A pentest produces findings, but findings only matter if they are triaged and if regressions are caught automatically. This recipe covers the full triage flow — resolve, false positive, accept risk, and bulk updates — and then wires a quality gate into CI so a build fails when, say, any critical vulnerability is still open.

Prerequisites

pip install rank-sdk
export RANK_API_KEY=rk_...
export RANK_PENTEST_ID=123

Steps

  1. List the findings. Start from the current inventory, optionally filtered by severity. From the CLI this is rank pentest vulns <pentestId>.

    vulns = client.pentests.vulnerabilities.list(pentest_id, severity="critical")
    for v in vulns.items:
        print(v.id, v.severity, v.status, v.vulnerability)
  2. Triage each finding. Use the dedicated transition methods. resolve accepts without_evidence (default) or evidenced; false_positive and accept_risk require a reason.

    client.pentests.vulnerabilities.resolve(
        pentest_id, vuln_id,
        resolution_type="without_evidence",
        comment="Patched in the latest deployment.",
    )
    client.pentests.vulnerabilities.false_positive(
        pentest_id, vuln_id,
        reason="Only reachable from the staging network, not production.",
    )
    client.pentests.vulnerabilities.accept_risk(
        pentest_id, vuln_id,
        reason="Low-impact informational finding, accepted by management.",
    )
  3. Bulk-update status. Move many findings to open or in_progress in one call (use the dedicated methods above for resolved / false-positive / accepted-risk states).

    open_ids = [v.id for v in vulns.items if v.status == "open"]
    result = client.pentests.vulnerabilities.bulk_update_status(
        pentest_id,
        vulnerability_ids=open_ids,
        status="in_progress",
        comment="Batch triage - assigned to the security team.",
    )
    print(result.updated, result.skipped)
  4. Evaluate a quality gate. Define rules and let Rank score them. The per-pentest gate returns passed plus a list of failures.

    gate = client.pentests.vulnerabilities.quality_gate(
        pentest_id,
        rules=[
            {"severity": "critical", "max_open": 0},
            {"severity": "high", "max_open": 5},
            {"min_resolution_rate": 0.8},
        ],
    )
    if not gate.passed:
        for f in gate.failures:
            print(f.rule, f.expected, f.actual)

    To check a rolling gate across your recent pentests instead, call the global gate:

    summary = client.pentests.quality_gate(max_open_critical=0, min_resolution_rate=80)
    print(summary.overall_passed, summary.pass_count, summary.fail_count)
  5. Fail the build in CI. Commit the gate script to your own repository as quality_gate.py and run it as a step. Because the script exits non-zero when the gate fails, the job fails too.

    name: Security gate
    on: [push, pull_request]
    
    jobs:
      rank-quality-gate:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: "3.12"
          - run: pip install rank-sdk
          - name: Enforce Rank quality gate
            env:
              RANK_API_KEY: ${{ secrets.RANK_API_KEY }}
              RANK_PENTEST_ID: ${{ vars.RANK_PENTEST_ID }}
            run: python quality_gate.py

Run it

Save the following as quality_gate.py. It exits with status 1 when the gate fails, 0 when it passes, and 2 on configuration or API errors. Set RANK_API_KEY and RANK_PENTEST_ID, then run python quality_gate.py. Commit the same file to your own repository so the CI step above can run it.

"""Triage vulnerabilities and enforce a quality gate in CI.

This script is designed to run inside a CI pipeline. It:

  1. Lists the vulnerabilities of a completed pentest.
  2. (Optionally) applies a triage pass that demonstrates the status
     transitions: resolve, false positive, accept risk, and a bulk update.
     The mutating calls only run when RANK_APPLY_TRIAGE=1 so the script is
     safe to run repeatedly in CI.
  3. Evaluates a quality gate against the pentest's vulnerabilities.
  4. Exits with a non-zero status code when the gate fails, which fails the
     CI build.

Run:
    pip install rank-sdk
    export RANK_API_KEY=rk_...
    export RANK_PENTEST_ID=123
    python quality_gate.py

Optional environment variables:
    RANK_APPLY_TRIAGE  Set to "1" to run the (mutating) triage demonstration.
"""

from __future__ import annotations

import os
import sys

import rank

PENTEST_ID = int(os.environ.get("RANK_PENTEST_ID", "0"))
APPLY_TRIAGE = os.environ.get("RANK_APPLY_TRIAGE") == "1"

# A finding that breaks any of these rules fails the gate (and the build).
GATE_RULES = [
    {"severity": "critical", "max_open": 0},
    {"severity": "high", "max_open": 5},
    {"min_resolution_rate": 0.8},
]


def show_inventory(client: rank.Rank) -> None:
    """Print the current vulnerabilities grouped by what they are."""
    vulns = client.pentests.vulnerabilities.list(PENTEST_ID)
    print(f"Pentest #{PENTEST_ID}: {len(vulns.items)} vulnerabilities on this page")
    for v in vulns.items:
        print(f"  [{v.id}] {v.severity:>8}  {v.status:>12}  {v.vulnerability}")


def apply_triage(client: rank.Rank) -> None:
    """Demonstrate the status transitions used during triage.

    Only runs when RANK_APPLY_TRIAGE=1. Every call is guarded so an
    unexpected current status does not abort the whole run.
    """
    vulns = client.pentests.vulnerabilities.list(PENTEST_ID)
    if not vulns.items:
        print("Nothing to triage.")
        return

    first = vulns.items[0]
    try:
        client.pentests.vulnerabilities.resolve(
            PENTEST_ID, first.id,
            resolution_type="without_evidence",
            comment="Patched in the latest deployment.",
        )
        print(f"Resolved #{first.id}")
    except rank.APIError as exc:
        print(f"Could not resolve #{first.id}: {exc.message}")

    if len(vulns.items) >= 2:
        second = vulns.items[1]
        try:
            client.pentests.vulnerabilities.false_positive(
                PENTEST_ID, second.id,
                reason="Only reachable from the staging network, not production.",
            )
            print(f"Marked #{second.id} as false positive")
        except rank.APIError as exc:
            print(f"Could not flag #{second.id}: {exc.message}")

    if len(vulns.items) >= 3:
        third = vulns.items[2]
        try:
            client.pentests.vulnerabilities.accept_risk(
                PENTEST_ID, third.id,
                reason="Low-impact informational finding, accepted by management.",
            )
            print(f"Accepted risk on #{third.id}")
        except rank.APIError as exc:
            print(f"Could not accept risk on #{third.id}: {exc.message}")

    open_ids = [v.id for v in vulns.items if v.status == "open"][:50]
    if open_ids:
        result = client.pentests.vulnerabilities.bulk_update_status(
            PENTEST_ID,
            vulnerability_ids=open_ids,
            status="in_progress",
            comment="Batch triage - assigned to the security team.",
        )
        print(f"Bulk update: {result.updated} updated, {result.skipped} skipped")


def evaluate_gate(client: rank.Rank) -> bool:
    """Evaluate the quality gate and print any failures. Returns passed."""
    gate = client.pentests.vulnerabilities.quality_gate(PENTEST_ID, rules=GATE_RULES)

    print("\n--- Quality gate ---")
    if gate.summary is not None:
        s = gate.summary
        print(f"  Total open vulns : {s.by_status.get('open', 0)}")
        print(f"  Resolution rate  : {s.resolution_rate}")

    if gate.passed:
        print("  Result: PASSED")
        return True

    print("  Result: FAILED")
    for failure in gate.failures:
        print(f"    - {failure.rule}: expected {failure.expected}, got {failure.actual}")
    return False


def main() -> int:
    if PENTEST_ID <= 0:
        print("ERROR: set RANK_PENTEST_ID to a completed pentest ID.")
        return 2

    with rank.Rank() as client:
        show_inventory(client)

        if APPLY_TRIAGE:
            print("\n--- Applying triage (RANK_APPLY_TRIAGE=1) ---")
            apply_triage(client)
        else:
            print("\nSkipping mutating triage (set RANK_APPLY_TRIAGE=1 to enable).")

        passed = evaluate_gate(client)

    # A failing gate must fail the CI build.
    return 0 if passed else 1


if __name__ == "__main__":
    try:
        sys.exit(main())
    except rank.AuthenticationError:
        print("ERROR: invalid or missing API key. Set RANK_API_KEY.")
        sys.exit(2)
    except rank.NotFoundError:
        print(f"ERROR: pentest #{PENTEST_ID} not found.")
        sys.exit(2)
    except rank.APIError as exc:
        print(f"API error ({exc.status_code}): {exc.message}")
        sys.exit(2)

For more on the underlying status model, see Vulnerabilities.