- Add RpmVersionComparer for RPM version comparison with epoch, version, and release handling. - Introduce DebianVersion for parsing Debian EVR (Epoch:Version-Release) strings. - Create ApkVersion for parsing Alpine APK version strings with suffix support. - Define IVersionComparator interface for version comparison with proof-line generation. - Implement VersionComparisonResult struct to encapsulate comparison results and proof lines. - Add tests for Debian and RPM version comparers to ensure correct functionality and edge case handling. - Create project files for the version comparison library and its tests.
12 KiB
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
Authorization: Bearer <access_token>
X-Tenant-Id: <tenant_uuid>
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
{
"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
{
"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
{
"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:
{
"languages": ["dotnet", "node"],
"baseScanId": "abc123",
"forceRecompute": false
}
Response: 202 Accepted
{
"jobId": "880e8400-e29b-41d4-a716-446655440003",
"status": "queued",
"estimatedCompletionSeconds": 30
}
Response: 200 OK (cached result)
{
"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
{
"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
{
"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
{
"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
enum DriftDirection {
became_reachable = "became_reachable",
became_unreachable = "became_unreachable"
}
4.2 DriftCauseKind
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
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
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:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1703242800
7. Examples
7.1 cURL - Get Drift Results
curl -X GET \
'https://api.stellaops.example/api/scanner/scans/def456/drift?language=dotnet' \
-H 'Authorization: Bearer <token>' \
-H 'X-Tenant-Id: <tenant_id>'
7.2 cURL - Compute Reachability
curl -X POST \
'https://api.stellaops.example/api/scanner/scans/def456/compute-reachability' \
-H 'Authorization: Bearer <token>' \
-H 'X-Tenant-Id: <tenant_id>' \
-H 'Content-Type: application/json' \
-d '{
"languages": ["dotnet"],
"baseScanId": "abc123"
}'
7.3 C# SDK
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
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.
{
"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.
{
"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