Smart-Diff scoring configuration guide

Tune how Stella Ops Smart-Diff prioritises material risk changes between two scans — the weights and thresholds that drive each finding’s priority score. This guide is for security and platform engineers calibrating detection to their risk appetite; it covers the two configuration types (Scanner-side and Policy-side), the shipped presets, and how the priority score is actually computed. For how these results are exported to CI, see SARIF integration.

Provenance: SPRINT_3500_0004_0001 · SDIFF-BIN-031.

Reconciliation note (verify against source). This guide was reconciled against the implementation. Two distinct configuration types exist and were previously conflated here:

  • StellaOps.Scanner.SmartDiff.Detection.SmartDiffScoringConfig — the standalone Scanner-side config with named presets and a Validate() method (src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/SmartDiffScoringConfig.cs).
  • StellaOps.Policy.SmartDiffPolicyScoringConfig — an optional record embedded in PolicyScoringConfig (src/Policy/__Libraries/StellaOps.Policy/PolicyScoringConfig.cs).

Their field sets and defaults differ. Where this guide states a default, it names the owning type. When the code and this doc disagree, the code wins.

Overview

Smart-Diff uses configurable scoring weights to prioritize material risk changes. This guide explains how to customize scoring for your organization’s risk appetite.

Configuration Surfaces

Smart-Diff scoring weights are defined in code as two configuration types:

  1. SmartDiffScoringConfig(Scanner library) — comprehensive weights + thresholds, named presets (GetPreset), and Validate(). Converts to detector options via ToDetectorOptions()/CreateDetector().
  2. SmartDiffPolicyScoringConfig(Policy library) — an optional record carried on PolicyScoringConfig.SmartDiff. Bound from policy YAML (camelCase keys) by PolicyScoringConfigBinder.

Not implemented. There is currently no environment-variable override path and no HTTP configuration endpoint for Smart-Diff scoring. Earlier drafts of this guide documented STELLAOPS_SMARTDIFF_* variables and GET/PUT /api/v1/config/smart-diff/scoring; neither exists in the codebase. See Configuring weights and Smart-Diff API surface below for what is actually available.

Default Configuration

SmartDiffScoringConfig (Scanner) defaults

Source: src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/SmartDiffScoringConfig.cs.

{
  "name": "default",
  "version": "1.0",
  "reachabilityFlipUpWeight": 1.0,
  "reachabilityFlipDownWeight": 0.8,
  "useLatticeConfidence": true,
  "vexFlipToAffectedWeight": 0.9,
  "vexFlipToNotAffectedWeight": 0.7,
  "vexFlipToFixedWeight": 0.6,
  "vexFlipToUnderInvestigationWeight": 0.3,
  "rangeEntryWeight": 0.8,
  "rangeExitWeight": 0.6,
  "kevAddedWeight": 1.0,
  "kevRemovedWeight": 0.5,
  "epssThresholdWeight": 0.6,
  "epssThreshold": 0.5,
  "policyFlipWeight": 0.7,
  "hardeningRegressionWeight": 0.7,
  "hardeningScoreThreshold": 0.2,
  "includeHardeningFlags": true,
  "kevBoost": 1.5,
  "minPriorityScore": 0.1,
  "highPriorityThreshold": 0.7,
  "criticalPriorityThreshold": 0.9
}

SmartDiffPolicyScoringConfig (Policy) defaults

Source: src/Policy/__Libraries/StellaOps.Policy/PolicyScoringConfig.cs. Note the field set differs from the Scanner config (e.g. it has epssThresholdCrossWeight, hardeningImprovementWeight, hardeningRegressionThreshold, and epssThreshold defaults to 0.1 here vs 0.5 in the Scanner config).

{
  "reachabilityFlipUpWeight": 1.0,
  "reachabilityFlipDownWeight": 0.8,
  "vexFlipToAffectedWeight": 0.9,
  "vexFlipToNotAffectedWeight": 0.7,
  "rangeEntryWeight": 0.8,
  "rangeExitWeight": 0.6,
  "kevAddedWeight": 1.0,
  "epssThreshold": 0.1,
  "epssThresholdCrossWeight": 0.5,
  "hardeningRegressionWeight": 0.7,
  "hardeningImprovementWeight": 0.3,
  "hardeningRegressionThreshold": 0.1
}

Weight Categories (SmartDiffScoringConfig)

The tables below document the Scanner SmartDiffScoringConfigfields. The detector (MaterialRiskChangeDetector) consumes the subset projected by ToDetectorOptions().

Reachability Weights (R1)

Controls scoring for reachability status changes (reachable flips true/false; unknown is skipped).

ParameterDefaultDescription
reachabilityFlipUpWeight1.0Unreachable → Reachable (risk increase)
reachabilityFlipDownWeight0.8Reachable → Unreachable (risk decrease)
useLatticeConfidencetrueWhether to factor in lattice confidence (config flag; not forwarded to the detector by ToDetectorOptions())

Example scenarios:

VEX Status Weights (R2)

Controls scoring for VEX statement changes. The detector classifies transitions in ClassifyVexTransition: only a fixed set of transitions are “meaningful”. Increased-risk transitions use vexFlipToAffectedWeight; decreased-risk transitions use vexFlipToNotAffectedWeight.

ParameterDefaultDescription
vexFlipToAffectedWeight0.9Weight applied to increased-risk flips (e.g. → affected)
vexFlipToNotAffectedWeight0.7Weight applied to decreased-risk flips (e.g. → not_affected, → fixed)
vexFlipToFixedWeight0.6Config field; not forwarded to the detector by ToDetectorOptions()
vexFlipToUnderInvestigationWeight0.3Config field; not forwarded to the detector by ToDetectorOptions()

Implementation detail. ToDetectorOptions() only maps vexFlipToAffectedWeight and vexFlipToNotAffectedWeight. The detector applies the affected-weight to all increased-risk transitions and the not-affected-weight to all decreased-risk transitions; there is no separate “fixed” or “under_investigation” detector weight today. Transitions to under_investigation are detected as Neutral (noteworthy but unscored).

Rationale:

Version Range Weights (R3)

Controls scoring for affected version range changes (inAffectedRange flips; unknown is skipped).

ParameterDefaultDescription
rangeEntryWeight0.8Version entered affected range
rangeExitWeight0.6Version exited affected range

Intelligence Signal Weights (R4)

Controls scoring for external intelligence and policy changes. R4 can emit multiple changes (KEV, EPSS, policy) from a single comparison.

ParameterDefaultDescription
kevAddedWeight1.0Vulnerability added to CISA KEV
kevRemovedWeight0.5Vulnerability removed from CISA KEV
epssThreshold0.5EPSS score threshold for crossing detection (Scanner config default; Policy config default is 0.1)
epssThresholdWeight0.6Weight applied when the EPSS score crosses epssThreshold in either direction
policyFlipWeight0.7Weight for a policy-decision change (Allow/Warn/Block ordering); neutral flips are not emitted

Binary Hardening Weights (R5)

Controls scoring for binary hardening flag changes. These fields live on SmartDiffScoringConfig but are not forwarded to MaterialRiskChangeDetector via ToDetectorOptions(); they configure hardening handling elsewhere in the Smart-Diff pipeline.

ParameterDefaultDescription
hardeningRegressionWeight0.7Weight for a hardening regression (security flag disabled)
hardeningScoreThreshold0.2Minimum hardening score difference to trigger a finding
includeHardeningFlagstrueWhether to include hardening flags in diff output

Note. There is no hardeningImprovementWeight or hardeningRegressionThreshold on the Scanner SmartDiffScoringConfig. Those fields exist only on the Policy SmartDiffPolicyScoringConfig (defaults 0.3 and 0.1 respectively).

Priority / Classification Factors

ParameterDefaultDescription
kevBoost1.5Multiplier applied when a finding is in KEV
minPriorityScore0.1Minimum priority score to emit a finding
highPriorityThreshold0.7Threshold for “high priority” classification
criticalPriorityThreshold0.9Threshold for “critical priority” classification

These classification factors are stored on the config but are not part of ToDetectorOptions(); the detector’s ComputePriorityScore does not consult them today (see Score Calculation).

Validation

SmartDiffScoringConfig.Validate() enforces bounds and logical ordering, returning a SmartDiffScoringConfigValidation(bool IsValid, string[] Errors):

Presets

SmartDiffScoringConfig ships four named presets, retrievable via SmartDiffScoringConfig.GetPreset(name) (case-insensitive; aliases noted). Unknown names throw ArgumentException.

The previously documented “Strict” and “Lenient” presets do not exist on SmartDiffScoringConfig. (The Policy-side SmartDiffPolicyScoringConfig has Default and Strict only — see Policy Integration.)

Default ("default")

Balanced detection — all defaults shown in Default Configuration.

SmartDiffScoringConfig.Default

Security-focused ("security-focused", alias "security")

Aggressive detection, lower thresholds. Recommended for security-critical applications.

SmartDiffScoringConfig.SecurityFocused
{
  "name": "security-focused",
  "reachabilityFlipUpWeight": 1.2,
  "vexFlipToAffectedWeight": 1.0,
  "kevAddedWeight": 1.5,
  "epssThreshold": 0.3,
  "epssThresholdWeight": 0.8,
  "hardeningRegressionWeight": 0.9,
  "hardeningScoreThreshold": 0.15,
  "minPriorityScore": 0.05,
  "highPriorityThreshold": 0.5,
  "criticalPriorityThreshold": 0.8
}

Compliance-focused ("compliance-focused", alias "compliance")

Stricter thresholds for regulated environments.

SmartDiffScoringConfig.ComplianceFocused
{
  "name": "compliance-focused",
  "reachabilityFlipUpWeight": 1.0,
  "vexFlipToAffectedWeight": 1.0,
  "vexFlipToNotAffectedWeight": 0.9,
  "kevAddedWeight": 2.0,
  "epssThreshold": 0.2,
  "policyFlipWeight": 1.0,
  "hardeningRegressionWeight": 1.0,
  "hardeningScoreThreshold": 0.1,
  "minPriorityScore": 0.0,
  "highPriorityThreshold": 0.4,
  "criticalPriorityThreshold": 0.7
}

Developer-friendly ("developer-friendly", alias "developer")

Reduced noise; focus on actionable changes. Suitable for development/staging environments.

SmartDiffScoringConfig.DeveloperFriendly
{
  "name": "developer-friendly",
  "reachabilityFlipUpWeight": 0.8,
  "vexFlipToAffectedWeight": 0.7,
  "kevAddedWeight": 1.0,
  "epssThreshold": 0.7,
  "epssThresholdWeight": 0.4,
  "hardeningRegressionWeight": 0.5,
  "hardeningScoreThreshold": 0.3,
  "minPriorityScore": 0.2,
  "highPriorityThreshold": 0.8,
  "criticalPriorityThreshold": 0.95
}

Configuring weights

To run the detector with a custom configuration, build a SmartDiffScoringConfig (or pick a preset), then construct a detector:

var config = SmartDiffScoringConfig.GetPreset("security-focused");
var validation = config.Validate();
if (!validation.IsValid)
{
    throw new InvalidOperationException(string.Join("; ", validation.Errors));
}

// Either of these:
var detector = config.CreateDetector();
// or
var detector = new MaterialRiskChangeDetector(config.ToDetectorOptions());

The detector defaults to MaterialRiskChangeOptions.Default when no options are supplied.

Policy Integration

Smart-Diff scoring can be embedded in PolicyScoringConfig via the optional SmartDiff parameter. Note this uses the Policy type SmartDiffPolicyScoringConfig, whose field set differs from the Scanner config.

var config = new PolicyScoringConfig(
    Version: "1.0",
    SeverityWeights: severityWeights,
    QuietPenalty: 0.1,
    WarnPenalty: 0.5,
    IgnorePenalty: 0.0,
    TrustOverrides: trustOverrides,
    ReachabilityBuckets: reachabilityBuckets,
    UnknownConfidence: unknownConfig,
    SmartDiff: new SmartDiffPolicyScoringConfig(
        ReachabilityFlipUpWeight: 1.0,
        VexFlipToAffectedWeight: 0.9,
        KevAddedWeight: 1.2));

SmartDiffPolicyScoringConfig provides two static presets:

SmartDiffPolicyScoringConfig.Default   // all defaults
SmartDiffPolicyScoringConfig.Strict    // reachabilityFlipUpWeight 1.2, vexFlipToAffectedWeight 1.1,
                                        // kevAddedWeight 1.5, hardeningRegressionWeight 1.0,
                                        // hardeningRegressionThreshold 0.05

PolicyScoringConfig is bound from policy YAML (camelCase keys via PolicyScoringConfigBinder), not from environment variables.

Smart-Diff API surface

Correction. There is no GET/PUT /api/v1/config/smart-diff/scoring endpoint. The Scanner WebService exposes the following Smart-Diff endpoints (src/Scanner/StellaOps.Scanner.WebService/Endpoints/SmartDiffEndpoints.cs), mounted under the API group’s /smart-diff prefix. None of them read or write scoring configuration.

MethodPathAuth policyPurpose
GET/smart-diff/scans/{scanId}/changesscanner.scans.read or scanner:readMaterial risk changes for a scan (optional minPriority filter)
GET/smart-diff/scans/{scanId}/sarifscanner.scans.read or scanner:readSmart-Diff results as SARIF 2.1.0 (optional pretty)
GET/smart-diff/{scanId}/vex-candidatesscanner.scans.read or scanner:readVEX candidates resolved via scan metadata
POST/smart-diff/{scanId}/vex-candidates/reviewscanner.scans.writeReview a scan-scoped VEX candidate
GET/smart-diff/images/{digest}/candidatesscanner.scans.read or scanner:readVEX candidates for an image digest
GET/smart-diff/candidates/{candidateId}scanner.scans.read or scanner:readA specific VEX candidate
POST/smart-diff/candidates/{candidateId}/reviewscanner.scans.writeReview a VEX candidate (accept/reject/defer)

Read endpoints accept either the scanner.scans.read scope or the canonical scanner:read scope (StellaOpsScopes.ScannerRead). Review endpoints require scanner.scans.write and are audited.

GET /smart-diff/scans/{scanId}/changes returns each finding’s priorityScore plus the list of detected changes (rule, change type, direction, reason, before/after value, and per-change weight).

Score Calculation

Correction. The priority score is not computed as base_severity × Σ(change_weight × rule_match). The per-change weight values configured above are emitted on each DetectedChange (and surfaced in the API/SARIF output), but the finding-level priorityScore is computed by MaterialRiskChangeDetector.ComputePriorityScore using a fixed additive point scheme over the current risk state (per the Smart-Diff advisory A9), independent of the configured weights:

priority_score =
    (current.kev                     ? +1000 : 0)
  + (current.reachable == true       ?  +500 : 0)
  + (R3 increased AND in affected range ? +200 : 0)
  + (R2 increased AND vex == affected   ? +150 : 0)
  + round(clamp(current.epss, 0..1) * 100)          // 0..100
  + (current.policyDecision == Block ?  +300
     : current.policyDecision == Warn ? +100 : 0)

The score is 0 when there is no material change. It is an integer point total (stored as NUMERIC(6,4) in the database), not a 0–1 normalized value, so the previous “capped at 1.0” claim does not apply.

Example Calculation

Given a finding whose current state is kev = true, reachable = true, epss = 0.42, policyDecision = Block, with a detected R2 flip to affected and an R3 flip into the affected range:

priority_score = 1000 (kev)
               +  500 (reachable)
               +  200 (range entry)
               +  150 (vex → affected)
               +   42 (round(0.42 * 100))
               +  300 (policy Block)
               = 2192

Tuning Recommendations

The detector’s priorityScore is driven by the fixed point scheme above, so tuning the configured weights changes the per-change weight values (visible in the API/SARIF output and used by consumers that aggregate weights), not the finding-level priorityScore directly. Choose a preset close to your posture, then adjust:

For CI/CD pipelines (security-leaning)

Start from SmartDiffScoringConfig.SecurityFocused, then raise KEV/EPSS/hardening sensitivity:

{
  "kevAddedWeight": 1.5,
  "hardeningRegressionWeight": 1.2,
  "epssThreshold": 0.3
}

Focus on surfacing known-exploited vulnerabilities and hardening regressions.

For alert-fatigue reduction (developer-leaning)

Start from SmartDiffScoringConfig.DeveloperFriendly, then lower weights for positive changes:

{
  "reachabilityFlipDownWeight": 0.3,
  "vexFlipToNotAffectedWeight": 0.2,
  "rangeExitWeight": 0.2
}

For compliance focus

Start from SmartDiffScoringConfig.ComplianceFocused, then raise regulatory-relevant weights:

{
  "kevAddedWeight": 2.0,
  "vexFlipToAffectedWeight": 1.2,
  "hardeningScoreThreshold": 0.05
}

Validate any custom config with Validate() — weights must stay within [0.0, 2.0] and thresholds within [0.0, 1.0].

Monitoring and Metrics

Smart-Diff changes are persisted to scanner.material_risk_changes (src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/005_smart_diff_tables.sql). There is no top-level change_type column — the individual detected changes (and their rule type) live inside the changes JSONB array; the row carries a single priority_score and detected_at timestamp.

-- Average priority score and volume over the last 30 days
SELECT
  AVG(priority_score) AS avg_priority_score,
  COUNT(*)            AS change_count
FROM scanner.material_risk_changes
WHERE has_material_change = TRUE
  AND detected_at > now() - interval '30 days';

-- Breakdown by detection rule (unnesting the JSONB changes array)
SELECT
  c->>'Rule'           AS rule,
  COUNT(*)             AS occurrences,
  AVG(mc.priority_score) AS avg_priority_score
FROM scanner.material_risk_changes mc
CROSS JOIN LATERAL jsonb_array_elements(mc.changes) AS c
WHERE mc.has_material_change = TRUE
  AND mc.detected_at > now() - interval '30 days'
GROUP BY c->>'Rule'
ORDER BY occurrences DESC;

Adjust the JSONB key casing (Rule, ChangeType, Direction, Weight, …) to match the stored DetectedChange serialization in your deployment.