# Scanner Drift API Reference **Module:** Scanner **Version:** 1.0 **Base Path:** `/api/scanner` **Last Updated:** 2025-12-22 --- ## 1. Overview The Scanner Drift API provides endpoints for computing and retrieving reachability drift analysis between scans. Drift detection identifies when code changes create new paths to vulnerable sinks or mitigate existing risks. --- ## 2. Authentication & Authorization ### Required Scopes | Endpoint | Scope | |----------|-------| | Read drift results | `scanner:read` | | Compute reachability | `scanner:write` | | Admin operations | `scanner:admin` | ### Headers ```http Authorization: Bearer X-Tenant-Id: ``` --- ## 3. Endpoints ### 3.1 GET /scans/{scanId}/drift Retrieves drift analysis results comparing the specified scan against its base scan. **Parameters:** | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | scanId | path | string | Yes | Head scan identifier | | baseScanId | query | string | No | Base scan ID (defaults to previous scan) | | language | query | string | No | Filter by language (dotnet, node, java, etc.) | **Response: 200 OK** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "baseScanId": "abc123", "headScanId": "def456", "language": "dotnet", "detectedAt": "2025-12-22T10:30:00Z", "newlyReachableCount": 3, "newlyUnreachableCount": 1, "totalDriftCount": 4, "hasMaterialDrift": true, "resultDigest": "sha256:a1b2c3d4..." } ``` **Response: 404 Not Found** ```json { "error": "DRIFT_NOT_FOUND", "message": "No drift analysis found for scan def456" } ``` --- ### 3.2 GET /drift/{driftId}/sinks Retrieves individual drifted sinks with pagination. **Parameters:** | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | driftId | path | uuid | Yes | Drift result identifier | | direction | query | string | No | Filter: `became_reachable` or `became_unreachable` | | sinkCategory | query | string | No | Filter by sink category | | offset | query | int | No | Pagination offset (default: 0) | | limit | query | int | No | Page size (default: 100, max: 1000) | **Response: 200 OK** ```json { "items": [ { "id": "660e8400-e29b-41d4-a716-446655440001", "sinkNodeId": "MyApp.Services.DbService.ExecuteQuery(string)", "symbol": "DbService.ExecuteQuery", "sinkCategory": "sql_raw", "direction": "became_reachable", "cause": { "kind": "guard_removed", "description": "Guard condition removed in AuthMiddleware.Validate", "changedSymbol": "AuthMiddleware.Validate", "changedFile": "src/Middleware/AuthMiddleware.cs", "changedLine": 42, "codeChangeId": "770e8400-e29b-41d4-a716-446655440002" }, "path": { "entrypoint": { "nodeId": "MyApp.Controllers.UserController.GetUser(int)", "symbol": "UserController.GetUser", "file": "src/Controllers/UserController.cs", "line": 15 }, "sink": { "nodeId": "MyApp.Services.DbService.ExecuteQuery(string)", "symbol": "DbService.ExecuteQuery", "file": "src/Services/DbService.cs", "line": 88 }, "intermediateCount": 3, "keyNodes": [ { "nodeId": "MyApp.Middleware.AuthMiddleware.Validate()", "symbol": "AuthMiddleware.Validate", "file": "src/Middleware/AuthMiddleware.cs", "line": 42, "isChanged": true, "changeKind": "guard_changed" } ] }, "associatedVulns": [ { "cveId": "CVE-2024-12345", "epss": 0.85, "cvss": 9.8, "vexStatus": "affected", "packagePurl": "pkg:nuget/Dapper@2.0.123" } ] } ], "totalCount": 3, "offset": 0, "limit": 100 } ``` --- ### 3.3 POST /scans/{scanId}/compute-reachability Triggers reachability computation for a scan. Idempotent - returns cached result if already computed. **Parameters:** | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | scanId | path | string | Yes | Scan identifier | **Request Body:** ```json { "languages": ["dotnet", "node"], "baseScanId": "abc123", "forceRecompute": false } ``` **Response: 202 Accepted** ```json { "jobId": "880e8400-e29b-41d4-a716-446655440003", "status": "queued", "estimatedCompletionSeconds": 30 } ``` **Response: 200 OK** (cached result) ```json { "jobId": "880e8400-e29b-41d4-a716-446655440003", "status": "completed", "driftResultId": "550e8400-e29b-41d4-a716-446655440000" } ``` --- ### 3.4 GET /scans/{scanId}/reachability/components Lists components with their reachability status. **Parameters:** | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | scanId | path | string | Yes | Scan identifier | | language | query | string | No | Filter by language | | reachable | query | bool | No | Filter by reachability | | offset | query | int | No | Pagination offset | | limit | query | int | No | Page size | **Response: 200 OK** ```json { "items": [ { "purl": "pkg:nuget/Newtonsoft.Json@13.0.1", "language": "dotnet", "reachableSinkCount": 2, "unreachableSinkCount": 5, "totalSinkCount": 7, "highestSeveritySink": "unsafe_deser", "reachabilityGate": 5 } ], "totalCount": 42, "offset": 0, "limit": 100 } ``` --- ### 3.5 GET /scans/{scanId}/reachability/findings Lists reachable vulnerable sinks with CVE associations. **Parameters:** | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | scanId | path | string | Yes | Scan identifier | | minCvss | query | float | No | Minimum CVSS score | | kevOnly | query | bool | No | Only KEV vulnerabilities | | offset | query | int | No | Pagination offset | | limit | query | int | No | Page size | **Response: 200 OK** ```json { "items": [ { "sinkNodeId": "MyApp.Services.CryptoService.Encrypt(string)", "symbol": "CryptoService.Encrypt", "sinkCategory": "crypto_weak", "isReachable": true, "shortestPathLength": 4, "vulnerabilities": [ { "cveId": "CVE-2024-54321", "cvss": 7.5, "epss": 0.42, "isKev": false, "vexStatus": "affected" } ] } ], "totalCount": 15, "offset": 0, "limit": 100 } ``` --- ### 3.6 GET /scans/{scanId}/reachability/explain Explains why a specific sink is reachable or unreachable. **Parameters:** | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | scanId | path | string | Yes | Scan identifier | | sinkNodeId | query | string | Yes | Sink node identifier | | includeFullPath | query | bool | No | Include full path (default: false) | **Response: 200 OK** ```json { "sinkNodeId": "MyApp.Services.DbService.ExecuteQuery(string)", "isReachable": true, "reachabilityGate": 6, "confidence": "confirmed", "explanation": "Sink is reachable from 2 HTTP entrypoints via direct call paths", "entrypoints": [ { "nodeId": "MyApp.Controllers.UserController.GetUser(int)", "entrypointType": "http_handler", "pathLength": 4 }, { "nodeId": "MyApp.Controllers.AdminController.Query(string)", "entrypointType": "http_handler", "pathLength": 2 } ], "shortestPath": { "entrypoint": {...}, "sink": {...}, "intermediateCount": 1, "keyNodes": [...] }, "fullPath": ["node1", "node2", "node3", "sink"] } ``` --- ## 4. Request/Response Models ### 4.1 DriftDirection ```typescript enum DriftDirection { became_reachable = "became_reachable", became_unreachable = "became_unreachable" } ``` ### 4.2 DriftCauseKind ```typescript enum DriftCauseKind { guard_removed = "guard_removed", guard_added = "guard_added", new_public_route = "new_public_route", visibility_escalated = "visibility_escalated", dependency_upgraded = "dependency_upgraded", symbol_removed = "symbol_removed", unknown = "unknown" } ``` ### 4.3 SinkCategory ```typescript enum SinkCategory { cmd_exec = "cmd_exec", unsafe_deser = "unsafe_deser", sql_raw = "sql_raw", ssrf = "ssrf", file_write = "file_write", path_traversal = "path_traversal", template_injection = "template_injection", crypto_weak = "crypto_weak", authz_bypass = "authz_bypass", ldap_injection = "ldap_injection", xpath_injection = "xpath_injection", xxe_injection = "xxe_injection", code_injection = "code_injection", log_injection = "log_injection", reflection = "reflection", open_redirect = "open_redirect" } ``` ### 4.4 CodeChangeKind ```typescript enum CodeChangeKind { added = "added", removed = "removed", signature_changed = "signature_changed", guard_changed = "guard_changed", dependency_changed = "dependency_changed", visibility_changed = "visibility_changed" } ``` --- ## 5. Error Codes | Code | HTTP Status | Description | |------|-------------|-------------| | `SCAN_NOT_FOUND` | 404 | Scan ID does not exist | | `DRIFT_NOT_FOUND` | 404 | No drift analysis for this scan | | `GRAPH_NOT_EXTRACTED` | 400 | Call graph not yet extracted | | `LANGUAGE_NOT_SUPPORTED` | 400 | Language not supported for reachability | | `COMPUTATION_IN_PROGRESS` | 409 | Reachability computation already running | | `COMPUTATION_FAILED` | 500 | Reachability computation failed | | `INVALID_SINK_ID` | 400 | Sink node ID not found in graph | --- ## 6. Rate Limiting | Endpoint | Rate Limit | |----------|------------| | GET endpoints | 100/min | | POST compute | 10/min | Rate limit headers: ```http X-RateLimit-Limit: 100 X-RateLimit-Remaining: 95 X-RateLimit-Reset: 1703242800 ``` --- ## 7. Examples ### 7.1 cURL - Get Drift Results ```bash curl -X GET \ 'https://api.stellaops.example/api/scanner/scans/def456/drift?language=dotnet' \ -H 'Authorization: Bearer ' \ -H 'X-Tenant-Id: ' ``` ### 7.2 cURL - Compute Reachability ```bash curl -X POST \ 'https://api.stellaops.example/api/scanner/scans/def456/compute-reachability' \ -H 'Authorization: Bearer ' \ -H 'X-Tenant-Id: ' \ -H 'Content-Type: application/json' \ -d '{ "languages": ["dotnet"], "baseScanId": "abc123" }' ``` ### 7.3 C# SDK ```csharp var client = new ScannerClient(options); // Get drift results var drift = await client.GetDriftAsync("def456", language: "dotnet"); Console.WriteLine($"Newly reachable: {drift.NewlyReachableCount}"); // Get drifted sinks var sinks = await client.GetDriftedSinksAsync(drift.Id, direction: DriftDirection.BecameReachable); foreach (var sink in sinks.Items) { Console.WriteLine($"{sink.Symbol}: {sink.Cause.Description}"); } ``` ### 7.4 TypeScript SDK ```typescript import { ScannerClient } from '@stellaops/sdk'; const client = new ScannerClient({ baseUrl, token }); // Get drift results const drift = await client.getDrift('def456', { language: 'dotnet' }); console.log(`Newly reachable: ${drift.newlyReachableCount}`); // Explain a sink const explanation = await client.explainReachability('def456', { sinkNodeId: 'MyApp.Services.DbService.ExecuteQuery(string)', includeFullPath: true }); console.log(explanation.explanation); ``` --- ## 8. Webhooks ### 8.1 drift.computed Fired when drift analysis completes. ```json { "event": "drift.computed", "timestamp": "2025-12-22T10:30:00Z", "data": { "driftResultId": "550e8400-e29b-41d4-a716-446655440000", "scanId": "def456", "baseScanId": "abc123", "newlyReachableCount": 3, "newlyUnreachableCount": 1, "hasMaterialDrift": true } } ``` ### 8.2 drift.kev_reachable Fired when a KEV becomes reachable. ```json { "event": "drift.kev_reachable", "timestamp": "2025-12-22T10:30:00Z", "severity": "critical", "data": { "driftResultId": "550e8400-e29b-41d4-a716-446655440000", "scanId": "def456", "kevCveId": "CVE-2024-12345", "sinkNodeId": "..." } } ``` --- ## 9. References - **Architecture:** `docs/modules/scanner/reachability-drift.md` - **Operations:** `docs/operations/reachability-drift-guide.md` - **Source:** `src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityDriftEndpoints.cs`