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 in src/Scanner/StellaOps.Scanner.WebService/Endpoints/SmartDiffEndpoints.cs, the detection models in src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/MaterialRiskChangeResult.cs, the suppression evaluator in src/Policy/__Libraries/StellaOps.Policy/Suppression/SuppressionRuleEvaluator.cs, and the persistence schema in src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/005_smart_diff_tables.sql. The canonical serialized shape is the golden fixture src/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:

If any of the three gate values is null (unknown), ComputeClass returns -1.

ClassreachableconfigActivatedrunningUserDescription
-1(any unknown)(any unknown)(any unknown)Indeterminate — at least one gate value is null
0None
1Running user only
2Config activated only
3Config + user
4Reachable only
5Reachable + user
6Reachable + config
7Full 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):

FieldTypeNotes
findingKeyFindingKeycomponentPurl / componentVersion / cveId
hasMaterialChangebool
changesDetectedChange[]
priorityScorenumber
previousStateHashstringsha256 of normalized previous state
currentStateHashstringsha256 of normalized current state

DetectedChange fields: rule (DetectionRule), changeType (MaterialChangeType), direction (RiskDirection), reason, previousValue, currentValue, weight.

Detection Rules (DetectionRule)

Serialized as the short code (R1R4) via JsonStringEnumMemberName:

MemberJSONMeaning
R1_ReachabilityFlipR1Reachability flipped
R2_VexFlipR2VEX status flipped
R3_RangeBoundaryR3Crossed affected-version range boundary
R4_IntelligenceFlipR4KEV / 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’s DetectedChange.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:

  1. unreachableReachable == false
  2. vex_not_affectedVexStatus == NotAffected
  3. not_kevKev == false
  4. no_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:

  1. version_changedVersionChanged == true
  2. not_in_affected_range!WasInAffectedRange && !IsInAffectedRange
  3. no_kevKev == false
  4. no_policy_flipPolicyFlipped == 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’s FindingKey (componentPurl, componentVersion, cveId) but is a separate type declared in StellaOps.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).

MethodRoute (under /smart-diff)ScopeReturns
GET/scans/{scanId}/changesscanner.scans.readMaterialChangesResponse
GET/scans/{scanId}/sarifscanner.scans.readSARIF 2.1.0 (application/sarif+json)
GET/{scanId}/vex-candidatesscanner.scans.readVexCandidatesResponse
POST/{scanId}/vex-candidates/reviewscanner.scans.writeReviewResponse
GET/images/{digest}/candidatesscanner.scans.readVexCandidatesResponse
GET/candidates/{candidateId}scanner.scans.readVexCandidateResponse
POST/candidates/{candidateId}/reviewscanner.scans.writeReviewResponse

Query parameters:

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()):

PostgreSQL enum types: scanner.vex_status_type, scanner.policy_decision_type (allow/warn/block), scanner.detection_rule (R1_ReachabilityFlipR4_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);
}