Risk Scoring Contract

Contract ID: CONTRACT-RISK-SCORING-002 Version: 1.0 Status: Published (API Endpoints section reconciled against source 2026-05) Last Updated: 2026-05-30

Overview

This contract defines how Stella Ops scores and prioritizes vulnerability findings so that policy gates and operators can act on what matters first. It covers the scoring job requests, results, risk profiles, and signal definitions that drive prioritization.

Audience: engineers integrating with or extending risk scoring — the Policy Engine, the Risk Engine service, and the Findings Ledger.

Three subsystems, three surfaces. Risk scoring in Stella Ops is not a single service. The Policy Engine owns the job/result/profile DTOs below and creates jobs internally (no public submission endpoint); the Risk Engine service exposes provider-based scoring over HTTP (/risk-scores/*); and the Findings Ledger exposes the Evidence-Weighted Score (EWS) API (/api/v1/findings/{findingId}/score, /api/v1/scoring/*). See API Endpoints and the surface map under Implementation References for the authoritative breakdown.

Implementation References

Surface map (verify against code). Three distinct subsystems contribute to risk scoring and they do not share a single HTTP surface:

  1. Policy Engine owns the RiskScoringJobRequest/RiskScoringJob/RiskScoringResult/FindingChangedEvent DTOs documented below. Jobs are created internally by RiskScoringTriggerService.HandleFindingChangedAsync / CreateJobAsync (queue/event-driven) and persisted to policy.risk_scoring_jobs. There is no public POST /api/v1/risk/jobs HTTP endpoint — the Policy Engine /api/risk/* routes cover profiles, simulation, scopes, overrides, events, and schema validation only.
  2. Risk Engine service (StellaOps.RiskEngine.WebService) exposes provider-based scoring under /risk-scores/*.
  3. Findings Ledger exposes the Evidence-Weighted Score (EWS) API under /api/v1/findings/{findingId}/score and /api/v1/scoring/*.

Data Models

RiskScoringJobRequest

Request to create a risk scoring job.

{
  "tenant_id": "string",
  "context_id": "string",
  "profile_id": "string",
  "findings": [
    {
      "finding_id": "string",
      "component_purl": "pkg:npm/lodash@4.17.20",
      "advisory_id": "CVE-2024-1234",
      "trigger": "created|updated|enriched|vex_applied"
    }
  ],
  "priority": "low|normal|high|emergency",
  "correlation_id": "string (optional)",
  "requested_at": "2025-12-05T00:00:00Z (optional)"
}

RiskScoringJob

A queued or completed risk scoring job.

{
  "job_id": "string",
  "tenant_id": "string",
  "context_id": "string",
  "profile_id": "string",
  "profile_hash": "sha256:...",
  "findings": [...],
  "priority": "normal",
  "status": "queued|running|completed|failed|cancelled",
  "requested_at": "2025-12-05T00:00:00Z",
  "started_at": "2025-12-05T00:00:01Z (optional)",
  "completed_at": "2025-12-05T00:00:02Z (optional)",
  "correlation_id": "string (optional)",
  "error_message": "string (optional)"
}

RiskScoringResult

Result of scoring a single finding (Policy Engine RiskScoringResult record).

{
  "finding_id": "string",
  "profile_id": "string",
  "profile_version": "1.0.0",
  "raw_score": 0.75,
  "normalized_score": 0.85,
  "severity": "high",
  "signal_values": {
    "cvss": 7.5,
    "kev": true,
    "reachability": 0.9
  },
  "signal_contributions": {
    "cvss": 0.4,
    "kev": 0.3,
    "reachability": 0.3
  },
  "override_applied": "kev-boost (optional)",
  "override_reason": "Known Exploited Vulnerability (optional)",
  "scored_at": "2025-12-05T00:00:02Z",
  "scoring_profile": "Simple|Advanced|Custom (optional)",
  "explain": []
}

normalized_score is DEPRECATED. Per the XML doc on RiskScoringResult.NormalizedScore, this field is a legacy normalized score (0–1) scheduled for removal in v2.0 (DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001). Consumers should rely on severity instead.

scoring_profile(string?) records which engine produced the result (Simple, Advanced, or Custom).

explainis a deterministically sorted list of ScoreExplanation entries describing per-factor contributions (src/Policy/__Libraries/StellaOps.Policy/Scoring/ScoreExplanation.cs). It defaults to an empty array.

Risk Profile Model

RiskProfileModel

Defines how findings are scored and prioritized.

{
  "id": "default-profile",
  "version": "1.0.0",
  "description": "Default risk profile for vulnerability prioritization",
  "extends": "base-profile (optional)",
  "signals": [
    {
      "name": "cvss",
      "source": "nvd",
      "type": "numeric",
      "path": "/cvss/base_score",
      "transform": "normalize_10",
      "unit": "score"
    },
    {
      "name": "kev",
      "source": "cisa",
      "type": "boolean",
      "path": "/kev/in_catalog"
    },
    {
      "name": "reachability",
      "source": "scanner",
      "type": "numeric",
      "path": "/reachability/score"
    }
  ],
  "weights": {
    "cvss": 0.4,
    "kev": 0.3,
    "reachability": 0.3
  },
  "overrides": {
    "severity": [
      {
        "when": { "kev": true },
        "set": "critical"
      }
    ],
    "decisions": [
      {
        "when": { "kev": true, "reachability": { "$gt": 0.8 } },
        "action": "deny",
        "reason": "KEV with high reachability"
      }
    ]
  },
  "metadata": {}
}

Signal Types

TypeDescriptionValue Range
booleanTrue/false signaltrue / false
numericNumeric signal0.0 to 1.0 (normalized)
categoricalCategorical signalString values

Severity Levels

The five canonical levels below match the RiskSeverity enum (RiskProfileModel.cs) and the RiskLevel enum in the attestation schema. The “Priority” column is illustrative ordering for documentation only — it is not a field emitted by code.

LevelJSON ValuePriority
Critical"critical"1 (highest)
High"high"2
Medium"medium"3
Low"low"4
Informational"informational"5 (lowest)

Decision Actions

ActionDescription
allowFinding is acceptable, no action required
reviewFinding requires manual review
denyFinding is not acceptable, blocks promotion

Scoring Algorithm

Score Calculation (weighted profile model)

For the Policy Engine RiskProfileModel weighted approach, each signal value is multiplied by its profile weight and summed, then clamped:

raw_score = Σ(signal_value × weight) for all signals
normalized_score = clamp(raw_score, 0.0, 1.0)

Note: in RiskScoringResult, signal_values is a mixed-type map (IReadOnlyDictionary<string, object?> — booleans, numerics, or categoricals), while signal_contributions is IReadOnlyDictionary<string, double>. The standalone Risk Engine providers (below) instead consume a flat IReadOnlyDictionary<string, double> of numeric signals.

These providers live in the Risk Engine service (src/Findings/StellaOps.RiskEngine.Core/Providers/) and operate on the ScoreRequest.Signals numeric dictionary. Each clamps and rounds its result to 6 decimal places (banker’s rounding).

VEX Gate Provider

VexGateProvider (ProviderName = "vex-gate") short-circuits scoring when the HasDenial signal is set (>= 1); otherwise it returns the clamped max of the remaining signals:

if (signals["HasDenial"] >= 1)
    return 0.0;  // Fully mitigated

// Otherwise, max of remaining signals, clamped to [0,1]
return clamp01(signals.Where(k != "HasDenial").Max());

CVSS + KEV Provider

CvssKevProvider (ProviderName = "cvss-kev") reads inline Cvss and Kev/IsKev signals (falling back to ICvssSource/IKevSource when no inline signals are supplied):

score = min(1.0, (clamp(cvss, 0, 10) / 10.0) + kevBonus)
where kevBonus = kev ? 0.2 : 0.0

API Endpoints

Corrected 2026-05. Earlier revisions of this contract documented a POST /api/v1/risk/jobs, GET /api/v1/risk/jobs/{job_id}, and GET /api/v1/risk/findings/{finding_id}/score surface. None of those routes exist in the code. The job/result DTOs above are created internally by the Policy Engine RiskScoringTriggerService (no public submission endpoint). The real HTTP surfaces are the Risk Engine service (/risk-scores/*) and the Findings Ledger Evidence-Weighted Score API (/api/v1/findings/{findingId}/score, /api/v1/scoring/*), documented below.

Risk Engine service — /risk-scores/*

Source: src/Findings/StellaOps.RiskEngine.WebService/Program.cs. All routes require an Authority bearer token; scopes are risk-engine:read (StellaOpsScopes.RiskEngineRead) for reads and risk-engine:operate (StellaOpsScopes.RiskEngineOperate) for writes/simulations.

MethodRouteScopeDescription
GET/risk-scores/providersrisk-engine:readSorted list of registered provider names.
POST/risk-scores/jobsrisk-engine:operateEnqueues a ScoreRequest, runs it synchronously, returns 202 Accepted with { jobId, result }. Result is persisted.
GET/risk-scores/jobs/{jobId:guid}risk-engine:readReturns the stored RiskScoreResult for a job, or 404.
POST/risk-scores/simulationsrisk-engine:operateEvaluates a collection of ScoreRequest; returns { results }. Not persisted.
POST/risk-scores/simulations/summaryrisk-engine:operateAs above plus an aggregate { averageScore, minScore, maxScore, topMovers } summary.

Health probes GET /healthz and GET /readyz are anonymous.

ScoreRequest(StellaOps.RiskEngine.Core.Contracts.ScoreRequest):

{
  "provider": "cvss-kev",
  "subject": "CVE-2024-1234",
  "signals": { "Cvss": 7.5, "Kev": 1 }
}

RiskScoreResult(StellaOps.RiskEngine.Core.Contracts.RiskScoreResult):

{
  "jobId": "guid",
  "provider": "cvss-kev",
  "subject": "CVE-2024-1234",
  "score": 0.95,
  "success": true,
  "error": null,
  "signals": { "Cvss": 7.5, "Kev": 1 },
  "completedAtUtc": "2026-05-05T00:00:02Z"
}

Registered providers (Program.cs): default-transforms, cvss-kev, epss, cvss-kev-epss, vex-gate, fix-exposure.

Findings Ledger — Evidence-Weighted Score (EWS) API

Source: src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/ScoringEndpoints.cs. All routes require a tenant (RequireTenant()) and an Authority token carrying the configured scope (findings:ledger:Authority:RequiredScopes, default vuln:operate). This API returns the EWS shape (EvidenceWeightedScoreResponse, score 0–100), which is distinct from the Policy Engine RiskScoringResult above.

MethodRouteDescription
POST/api/v1/findings/{findingId}/scoreCalculate and persist the evidence-weighted score; returns EvidenceWeightedScoreResponse. 404 if finding/evidence missing.
GET/api/v1/findings/{findingId}/scoreReturn the cached score without recalculation; 404 if none computed.
POST/api/v1/findings/scoresBatch calculate (max 100 finding IDs); returns CalculateScoresBatchResponse. 400 if batch too large.
GET/api/v1/findings/{findingId}/score-historyPaginated history (from, to, limit clamped 1–100, cursor).
GET/api/v1/scoring/policyActive scoring policy (ScoringPolicyResponse).
GET/api/v1/scoring/policy/{version}A specific policy version; 404 if unknown.
GET/api/v1/scoring/policy/versionsList of all policy versions (PolicyVersionListResponse).

EvidenceWeightedScoreResponse(abridged):

{
  "findingId": "string",
  "score": 78,
  "bucket": "ActNow|ScheduleNext|Investigate|Watchlist",
  "inputs": { "rch": 0.9, "rts": 0.0, "bkp": 0.0, "xpl": 0.4, "src": 1.0, "mit": 0.0 },
  "weights": { "rch": 0.0, "rts": 0.0, "bkp": 0.0, "xpl": 0.0, "src": 0.0, "mit": 0.0 },
  "flags": [],
  "explanations": [],
  "caps": { "speculativeCap": false, "notAffectedCap": false, "runtimeFloor": false },
  "policyDigest": "sha256:...",
  "calculatedAt": "2026-05-05T00:00:02Z",
  "cachedUntil": null,
  "fromCache": false,
  "hardFail": false
}

Finding Change Events

Events that trigger rescoring:

EventJSON ValueDescription
Created"created"New finding discovered
Updated"updated"Finding metadata changed
Enriched"enriched"New signals available
VEX Applied"vex_applied"VEX status changed

Determinism Guarantees

  1. Reproducible scores: Same inputs always produce same outputs
  2. Profile versioning: Profile hash included in results for traceability
  3. Signal ordering: Signals processed in deterministic order
  4. Timestamp precision: UTC ISO-8601 with millisecond precision

Unblocks

This contract unblocks the following tasks: