Smart-Diff API Types
This reference documents the Smart-Diff data types that Stella Ops exposes through its REST APIs and DSSE attestations. Smart-Diff compares two image scans and surfaces the material risk changes between them — reachability flips, VEX changes, range-boundary crossings, and threat-intelligence shifts — so that release gates react to meaningful deltas rather than raw version churn. It is written for integrators consuming the Scanner Smart-Diff endpoints and for tooling that verifies the signed predicate.
The types span three modules: Scanner (the predicate, detection engine, and REST surface), Policy (the suppression evaluator), and Attestor (DSSE signing).
Source of truth: the C# records in
src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/SmartDiffPredicate.cs, the REST endpoints insrc/Scanner/StellaOps.Scanner.WebService/Endpoints/SmartDiffEndpoints.cs, the detection models insrc/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/MaterialRiskChangeResult.cs, the suppression evaluator insrc/Policy/__Libraries/StellaOps.Policy/Suppression/SuppressionRuleEvaluator.cs, and the persistence schema insrc/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/005_smart_diff_tables.sql. The canonical serialized shape is the golden fixturesrc/Scanner/__Tests/StellaOps.Scanner.SmartDiff.Tests/Fixtures/smart-diff-predicate.v1.json.
Smart-Diff Predicate
The Smart-Diff predicate is a DSSE-signed attestation describing differential analysis between two image scans. It is serialized with SmartDiffJsonSerializer (camelCase property names via JsonSerializerDefaults.Web, WhenWritingNull ignore policy, and camelCase string enum members).
Predicate Type URI
stellaops.dev/predicates/smart-diff@v1
Defined as SmartDiffPredicate.PredicateType. The current schema version is SmartDiffPredicate.CurrentSchemaVersion = 1.0.0.
OpenAPI Schema Fragment
The schema below mirrors the SmartDiffPredicate record and its nested types. Note that the diff payload describes file and package changes between the two images (it is not a vulnerability-level diff). Vulnerability/finding-level deltas are surfaced separately as materialChanges (see MaterialChange) and via the Material Risk Change REST endpoints.
SmartDiffPredicate:
type: object
required:
- schemaVersion
- baseImage
- targetImage
- diff
- reachabilityGate
- scanner
properties:
schemaVersion:
type: string
example: "1.0.0"
description: Schema version (semver). Current = 1.0.0
baseImage:
$ref: '#/components/schemas/ImageReference'
targetImage:
$ref: '#/components/schemas/ImageReference'
diff:
$ref: '#/components/schemas/DiffPayload'
reachabilityGate:
$ref: '#/components/schemas/ReachabilityGate'
scanner:
$ref: '#/components/schemas/ScannerInfo'
context:
$ref: '#/components/schemas/RuntimeContext'
description: Optional runtime context for the scan
suppressedCount:
type: integer
minimum: 0
default: 0
description: Number of findings suppressed by pre-filters
materialChanges:
type: array
description: Finding-level material risk changes (omitted when null)
items:
$ref: '#/components/schemas/MaterialChange'
ImageReference:
type: object
required:
- digest
properties:
digest:
type: string
example: "sha256:abc123..."
name:
type: string
example: "ghcr.io/org/image"
tag:
type: string
example: "v1.2.3"
DiffPayload:
type: object
description: File- and package-level differences between base and target images.
properties:
filesAdded:
type: array
items:
type: string
description: Paths added in target
filesRemoved:
type: array
items:
type: string
description: Paths removed in target
filesChanged:
type: array
items:
$ref: '#/components/schemas/FileChange'
packagesChanged:
type: array
items:
$ref: '#/components/schemas/PackageChange'
packagesAdded:
type: array
items:
$ref: '#/components/schemas/PackageRef'
packagesRemoved:
type: array
items:
$ref: '#/components/schemas/PackageRef'
FileChange:
type: object
required:
- path
properties:
path:
type: string
hunks:
type: array
items:
$ref: '#/components/schemas/DiffHunk'
fromHash:
type: string
toHash:
type: string
DiffHunk:
type: object
required:
- startLine
- lineCount
properties:
startLine:
type: integer
lineCount:
type: integer
content:
type: string
PackageChange:
type: object
required:
- name
- from
- to
properties:
name:
type: string
from:
type: string
description: Previous version
to:
type: string
description: New version
purl:
type: string
example: "pkg:deb/openssl@3.0.14"
licenseDelta:
$ref: '#/components/schemas/LicenseDelta'
LicenseDelta:
type: object
properties:
added:
type: array
items:
type: string
removed:
type: array
items:
type: string
PackageRef:
type: object
required:
- name
- version
properties:
name:
type: string
version:
type: string
purl:
type: string
ReachabilityGate:
type: object
required:
- class
properties:
reachable:
type: boolean
nullable: true
description: Whether the vulnerable code is statically reachable (gate bit 2, value 4)
configActivated:
type: boolean
nullable: true
description: Whether the path is activated by the effective configuration (gate bit 1, value 2)
runningUser:
type: boolean
nullable: true
description: Whether the running user can trigger the path (gate bit 0, value 1)
class:
type: integer
minimum: -1
maximum: 7
description: |
3-bit reachability class derived from the gate values:
class = (reachable ? 4 : 0) + (configActivated ? 2 : 0) + (runningUser ? 1 : 0)
Returns -1 if any of the three gate values is unknown (null).
rationale:
type: string
description: Human-readable explanation of the gate decision
ScannerInfo:
type: object
required:
- name
- version
properties:
name:
type: string
example: "StellaOps.Scanner"
version:
type: string
example: "10.0.0"
ruleset:
type: string
example: "reachability-2025.12"
RuntimeContext:
type: object
description: Optional runtime context for the scan
properties:
entrypoint:
type: array
items:
type: string
example: ["/app/start"]
env:
type: object
additionalProperties:
type: string
example:
FEATURE_X: "true"
user:
$ref: '#/components/schemas/UserContext'
UserContext:
type: object
properties:
uid:
type: integer
gid:
type: integer
caps:
type: array
items:
type: string
example: ["NET_BIND_SERVICE"]
MaterialChange:
type: object
required:
- findingKey
- changeType
- reason
properties:
findingKey:
$ref: '#/components/schemas/FindingKey'
changeType:
$ref: '#/components/schemas/MaterialChangeType'
reason:
type: string
previousState:
$ref: '#/components/schemas/RiskState'
currentState:
$ref: '#/components/schemas/RiskState'
priorityScore:
type: integer
nullable: true
FindingKey:
type: object
required:
- componentPurl
- componentVersion
- cveId
properties:
componentPurl:
type: string
example: "pkg:npm/lodash"
componentVersion:
type: string
example: "4.17.21"
cveId:
type: string
example: "CVE-2024-1234"
MaterialChangeType:
type: string
description: Predicate-level material change type (serialized as snake_case)
enum:
- reachability_flip
- vex_flip
- range_boundary
- intelligence_flip
RiskState:
type: object
properties:
reachable:
type: boolean
nullable: true
vexStatus:
$ref: '#/components/schemas/VexStatusType'
inAffectedRange:
type: boolean
nullable: true
kev:
type: boolean
default: false
epssScore:
type: number
nullable: true
policyFlags:
type: array
items:
type: string
VexStatusType:
type: string
enum:
- affected
- not_affected
- fixed
- under_investigation
- unknown
Reachability Gate Classes
The class value is a 3-bit code computed by ReachabilityGate.ComputeClass(reachable, configActivated, runningUser). The bit layout is:
- Bit 2 (value 4):
reachable - Bit 1 (value 2):
configActivated - Bit 0 (value 1):
runningUser
If any of the three gate values is null (unknown), ComputeClass returns -1.
| Class | reachable | configActivated | runningUser | Description |
|---|---|---|---|---|
| -1 | (any unknown) | (any unknown) | (any unknown) | Indeterminate — at least one gate value is null |
| 0 | ❌ | ❌ | ❌ | None |
| 1 | ❌ | ❌ | ✅ | Running user only |
| 2 | ❌ | ✅ | ❌ | Config activated only |
| 3 | ❌ | ✅ | ✅ | Config + user |
| 4 | ✅ | ❌ | ❌ | Reachable only |
| 5 | ✅ | ❌ | ✅ | Reachable + user |
| 6 | ✅ | ✅ | ❌ | Reachable + config |
| 7 | ✅ | ✅ | ✅ | Full reachability confirmed |
Verified against ReachabilityGateTests.ComputeClass_Returns0To7_WhenAllKnown and ComputeClass_ReturnsMinus1_WhenAnyUnknown.
Material Change & Detection Types
Finding-level changes are produced by the detection engine (MaterialRiskChangeDetector) and surfaced via the Material Risk Change endpoints. The result type is MaterialRiskChangeResult and each detected change is a DetectedChange.
MaterialRiskChangeResult (Detection/MaterialRiskChangeResult.cs):
| Field | Type | Notes |
|---|---|---|
findingKey | FindingKey | componentPurl / componentVersion / cveId |
hasMaterialChange | bool | |
changes | DetectedChange[] | |
priorityScore | number | |
previousStateHash | string | sha256 of normalized previous state |
currentStateHash | string | sha256 of normalized current state |
DetectedChange fields: rule (DetectionRule), changeType (MaterialChangeType), direction (RiskDirection), reason, previousValue, currentValue, weight.
Detection Rules (DetectionRule)
Serialized as the short code (R1…R4) via JsonStringEnumMemberName:
| Member | JSON | Meaning |
|---|---|---|
R1_ReachabilityFlip | R1 | Reachability flipped |
R2_VexFlip | R2 | VEX status flipped |
R3_RangeBoundary | R3 | Crossed affected-version range boundary |
R4_IntelligenceFlip | R4 | KEV / EPSS / policy intelligence change |
Detection-level MaterialChangeType
The detection engine (Detection/MaterialRiskChangeResult.cs) uses a wider seven-value MaterialChangeType enum than the four-value predicate enum above. These values also match the scanner.material_change_type PostgreSQL enum:
reachability_flip
vex_flip
range_boundary
kev_added
kev_removed
epss_threshold
policy_flip
Note: the predicate’s
MaterialChange.changeType(SmartDiffPredicate.cs) and the detection engine’sDetectedChange.changeType(MaterialRiskChangeResult.cs) are two distinct enums that happen to share a type name (MaterialChangeType). The predicate enum has four members (reachability_flip,vex_flip,range_boundary,intelligence_flip); the detection enum has the seven members listed here.
Risk Direction (RiskDirection)
increased | decreased | neutral.
Suppression Rules
Implemented in
StellaOps.Policy.Suppression.SuppressionRuleEvaluator(module: Policy). Suppression is not a configurable rule list with regex/threshold patterns; it is a fixed evaluator that requires all of a set of conditions to pass before a finding is suppressed.
Standard suppression (4-condition AND)
SuppressionRuleEvaluator.Evaluate(SuppressionInput) suppresses a finding only when all four conditions pass:
unreachable—Reachable == falsevex_not_affected—VexStatus == NotAffectednot_kev—Kev == falseno_override— no active policy override for the finding key
Patch-churn suppression
SuppressionRuleEvaluator.EvaluatePatchChurn(PatchChurnInput) suppresses a version-bump finding only when all four conditions pass:
version_changed—VersionChanged == truenot_in_affected_range—!WasInAffectedRange && !IsInAffectedRangeno_kev—Kev == falseno_policy_flip—PolicyFlipped == false
Schema Fragments (as implemented)
SuppressionInput:
type: object
required:
- findingKey
- vexStatus
- kev
properties:
findingKey:
$ref: '#/components/schemas/FindingKey'
reachable:
type: boolean
nullable: true
vexStatus:
type: string
enum: [Unknown, Affected, NotAffected, Fixed, UnderInvestigation]
kev:
type: boolean
PatchChurnInput:
type: object
required:
- findingKey
- versionChanged
- wasInAffectedRange
- isInAffectedRange
- kev
- policyFlipped
properties:
findingKey:
$ref: '#/components/schemas/FindingKey'
versionChanged:
type: boolean
wasInAffectedRange:
type: boolean
isInAffectedRange:
type: boolean
kev:
type: boolean
policyFlipped:
type: boolean
SuppressionResult:
type: object
required:
- findingKey
- suppressed
- conditions
- reason
properties:
findingKey:
$ref: '#/components/schemas/FindingKey'
suppressed:
type: boolean
conditions:
type: array
items:
$ref: '#/components/schemas/SuppressionConditionResult'
reason:
type: string
SuppressionConditionResult:
type: object
required:
- conditionName
- passed
- reason
properties:
conditionName:
type: string
passed:
type: boolean
reason:
type: string
Note:
SuppressionRuleEvaluator.FindingKey(Policy module) has the same field shape as the Scanner predicate’sFindingKey(componentPurl,componentVersion,cveId) but is a separate type declared inStellaOps.Policy.Suppression.
REST Endpoints (Scanner WebService)
Smart-Diff endpoints are registered by SmartDiffEndpoints.MapSmartDiffEndpoints under the /smart-diff prefix. Authorization uses the scanner scopes scanner.scans.read (read) and scanner.scans.write (review). Write/review endpoints emit an audit record (AuditActions.Scanner.Review).
| Method | Route (under /smart-diff) | Scope | Returns |
|---|---|---|---|
| GET | /scans/{scanId}/changes | scanner.scans.read | MaterialChangesResponse |
| GET | /scans/{scanId}/sarif | scanner.scans.read | SARIF 2.1.0 (application/sarif+json) |
| GET | /{scanId}/vex-candidates | scanner.scans.read | VexCandidatesResponse |
| POST | /{scanId}/vex-candidates/review | scanner.scans.write | ReviewResponse |
| GET | /images/{digest}/candidates | scanner.scans.read | VexCandidatesResponse |
| GET | /candidates/{candidateId} | scanner.scans.read | VexCandidateResponse |
| POST | /candidates/{candidateId}/review | scanner.scans.write | ReviewResponse |
Query parameters:
GET /scans/{scanId}/changes:minPriority(number) filters changes bypriorityScore.GET /scans/{scanId}/sarif:pretty(bool) for indented JSON.GET /{scanId}/vex-candidatesand/images/{digest}/candidates:minConfidence(number) andpendingOnly(bool).
Review requests accept an action of accept, reject, or defer (VexReviewAction) plus an optional comment.
Persistence Schema
Migration 005_smart_diff_tables.sql creates the scanner schema objects with row-level security (tenant isolation via scanner.current_tenant_id()):
scanner.risk_state_snapshots— point-in-time risk state per finding, with a deterministicstate_hash.scanner.material_risk_changes— detected changes between scans (changesJSONB array), keyed by(tenant_id, scan_id, vuln_id, purl).scanner.vex_candidates— VEX candidate records pending review. The WebService registers the PostgreSQL store plus read/review/SARIF endpoints, but Scanner has no production registration or caller forVexCandidateEmitterat HEAD. The current runtime therefore does not automatically populate this table from scan completion or Smart-Diff comparison; candidates are visible only after another trusted writer has stored them.
PostgreSQL enum types: scanner.vex_status_type, scanner.policy_decision_type (allow/warn/block), scanner.detection_rule (R1_ReachabilityFlip…R4_IntelligenceFlip), scanner.material_change_type (7 values), scanner.risk_direction (increased/decreased/neutral), scanner.vex_justification, and scanner.vex_review_action (accept/reject/defer).
Usage Examples
Creating a Smart-Diff Predicate
var predicate = new SmartDiffPredicate(
SchemaVersion: SmartDiffPredicate.CurrentSchemaVersion,
BaseImage: new ImageReference(
Digest: "sha256:aaa...",
Name: "ghcr.io/org/image",
Tag: "v1.0.0"),
TargetImage: new ImageReference(
Digest: "sha256:bbb...",
Name: "ghcr.io/org/image",
Tag: "v1.1.0"),
Diff: new DiffPayload(
PackagesChanged:
[
new PackageChange(
Name: "openssl",
From: "1.1.1u",
To: "3.0.14",
Purl: "pkg:deb/openssl@3.0.14"),
]),
ReachabilityGate: ReachabilityGate.Create(
reachable: true,
configActivated: true,
runningUser: false,
rationale: "vulnerable symbol reachable from entrypoint"),
Scanner: new ScannerInfo(
Name: "StellaOps.Scanner",
Version: "10.0.0",
Ruleset: "reachability-2025.12"),
SuppressedCount: 5);
var json = SmartDiffJsonSerializer.Serialize(predicate, indent: true);
Evaluating Suppression Rules
// SuppressionRuleEvaluator is constructed with an ISuppressionOverrideProvider.
var evaluator = new SuppressionRuleEvaluator(overrideProvider);
var input = new SuppressionInput(
FindingKey: new FindingKey(
ComponentPurl: "pkg:npm/lodash",
ComponentVersion: "4.17.21",
CveId: "CVE-2024-1234"),
Reachable: false,
VexStatus: VexStatus.NotAffected,
Kev: false);
SuppressionResult result = evaluator.Evaluate(input);
if (result.Suppressed)
{
logger.LogInformation(
"Finding {Finding} suppressed: {Reason}",
result.FindingKey,
result.Reason);
}
