Advisory Drift Re-scan Flow

Source-reconciled. Event names, payload schemas, trigger sources, the material-change rules, and auth scopes in this document were verified against src/Concelier, src/JobEngine (Scheduler), src/Scanner, src/Notify, and the canonical scope catalog (src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs). Where a sample is illustrative rather than a wire-faithful contract it is labelled (illustrative).

Overview

The Advisory Drift Re-scan Flow describes how StellaOps re-evaluates previously scanned images when new vulnerability advisories are published or existing advisories are updated, without re-running the original image analysis. This keeps security verdicts current as new vulnerabilities are disclosed.

Audience: platform operators tuning rescan cadence and impact targeting, and engineers integrating with the scheduler.rescan.delta / scanner.report.ready events the re-evaluation paths emit.

Business Value: Continuous security-posture updates as new vulnerabilities are disclosed, catching newly-vulnerable images before they are exploited.

There are two distinct, independently-wired re-evaluation paths in the code, and this document covers both:

  1. Scheduler-driven impact targeting (JobEngine). Concelier/Excitor export completion notifies the Scheduler, which resolves the impacted image set from its impact index and queues re-evaluation runs. The materialised result is the scheduler.rescan.delta@1 event consumed by Notify. (src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker)
  2. Scanner feed-change rescore (Scanner WebService). A background job polls feed snapshot hashes (Concelier / Excititor / Policy) and replays scoring for recently-scanned images when a snapshot changes. (src/Scanner/StellaOps.Scanner.WebService/Services/FeedChangeRescoreJob.cs)

Note. The advisory observation event publisher and the Scanner feed-change rescore job are both gated behind configuration. The Concelier observation event publisher defaults to disabled (AdvisoryObservationEventPublisherOptions.Enabled = false), and the Scanner rescore job defaults to enabled with a 15-minute poll interval. Verify the deployment configuration before assuming a path is live.

Actors

ActorTypeRole
ConcelierServiceIngests/normalises advisories; optionally emits advisory.observation.updated@1
Scheduler (JobEngine)ServiceResolves impacted images via the impact index and queues re-evaluation runs; emits scheduler.rescan.delta@1
ScannerServiceReplays scoring for affected scans (feed-change rescore) and serves Smart-Diff comparisons
Policy EngineServiceMaterialises effective findings / verdicts that the re-evaluation compares against
NotifyServiceDelivers scanner.report.ready and scheduler.rescan.delta events to channels
SbomServiceServiceStores/serves the SBOM documents that re-evaluation reuses

Prerequisites

Advisory Sources

Concelier ships a large connector catalogue (50+ connector families under src/Concelier/.../StellaOps.Concelier.Connector.*). Representative families:

CategoryConnector families (examples)
National / catalog DBsNvd, Ghsa, Osv, Kev (CISA KEV), Epss
Vendor PSIRTsVndr.Msrc (Microsoft), Vndr.Oracle, Vndr.Cisco, Vndr.Vmware, Vndr.Adobe, Vndr.Apple, Vndr.Intel, Vndr.Amd, Vndr.Arm, Vndr.Chromium, Vndr.Siemens
DistrosDistro.Ubuntu, Distro.Debian, Distro.Alpine, Distro.RedHat, Distro.Suse, Distro.Arch, Distro.Gentoo
EcosystemsNpm, PyPi/PyPa, Go/GoVuln, RubyGems, Packagist, Crates/RustSec, Maven, Hex, BundlerAudit
CERTs / regionalCertCc, CertBund, CertFr, CertIn, Cccs, Acsc, Jvn, Kisa, Ru.Bdu, Ru.Nkcki
ICS / frameworkIcs.Cisa, Ics.Kaspersky, MitreAttack, MitreD3fend
Cloud-vendor advisoriesCloud.Aws, Cloud.Azure, Cloud.Gcp

These ingest advisory feeds from cloud vendors; they do not introduce any cloud-managed dependency. StellaOps remains self-hosted / air-gap-first.

Flow Diagram (Scheduler-driven path)

┌─────────────────────────────────────────────────────────────────────────────────┐
│            Advisory Drift Re-scan Flow (Scheduler / impact-index path)            │
└─────────────────────────────────────────────────────────────────────────────────┘

┌───────────┐  ┌───────────┐  ┌───────────┐  ┌─────────┐  ┌────────┐  ┌────────┐
│ Advisory  │  │ Concelier │  │ Scheduler │  │ Scanner │  │ Policy │  │ Notify │
│  Source   │  │           │  │(JobEngine)│  │         │  │ Engine │  │        │
└─────┬─────┘  └─────┬─────┘  └─────┬─────┘  └────┬────┘  └───┬────┘  └───┬────┘
      │ New/updated  │              │             │           │           │
      │ advisory     │              │             │           │           │
      │─────────────>│              │             │           │           │
      │              │ Ingest &     │             │           │           │
      │              │ normalise to │             │           │           │
      │              │ observation  │             │           │           │
      │              │──────┐       │             │           │           │
      │              │<─────┘       │             │           │           │
      │              │              │             │           │           │
      │              │ export       │             │           │           │
      │              │ completed    │             │           │           │
      │              │ (RunTrigger. │             │           │           │
      │              │  Conselier)  │             │           │           │
      │              │─────────────>│             │           │           │
      │              │              │ Resolve     │           │           │
      │              │              │ impacted    │           │           │
      │              │              │ images via  │           │           │
      │              │              │ ImpactIndex │           │           │
      │              │              │ (by purl /  │           │           │
      │              │              │  vuln id)   │           │           │
      │              │              │───┐         │           │           │
      │              │              │<──┘         │           │           │
      │              │              │ Queue run   │           │           │
      │              │              │ segments    │           │           │
      │              │              │────────────>│           │           │
      │              │              │             │ Reuse SBOM│           │
      │              │              │             │ + score   │           │
      │              │              │             │ replay /  │           │
      │              │              │             │ re-eval   │           │
      │              │              │             │──────────>│           │
      │              │              │             │           │ Materialise│
      │              │              │             │           │ findings / │
      │              │              │             │           │ verdict    │
      │              │              │             │           │───┐        │
      │              │              │             │           │<──┘        │
      │              │              │ DeltaSummary│           │           │
      │              │              │<────────────│           │           │
      │              │              │             │           │           │
      │              │              │ Emit scheduler.rescan.delta@1        │
      │              │              │─────────────────────────────────────>│
      │              │              │             │           │           │ Fan-out
      │              │              │             │           │           │ to
      │              │              │             │           │           │ channels
      │              │              │             │           │           │──┐
      │              │              │             │           │           │<─┘

Step-by-Step (Scheduler-driven path)

1. Advisory ingestion (Concelier)

Concelier connectors fetch raw advisories and the observation pipeline normalises each into an append-only canonical observation (AdvisoryObservation) with a deterministic linkset (aliases, purls, cpes). Ingestion endpoints require the advisory:ingest scope; reads require advisory:read (src/Concelier/StellaOps.Concelier.WebService/Extensions/PublicAdvisoryImportEndpointExtensions.cs).

The original document showed a flat {source, advisory_id, cvss, affected[…]} JSON. That shape is not the persisted contract — Concelier stores canonical observations and emits the event below. Treat any single-document JSON here as (illustrative) only.

2. Event emission (Concelier — optional)

When AdvisoryObservationEventPublisherOptions.Enabled is set (default false), Concelier publishes an advisory.observation.updated@1 event. The transport is postgres (outbox) or natsnot a Valkey stream — and the default NATS subject is concelier.advisory.observation.updated.v1 (src/Concelier/.../Observations/AdvisoryObservationEventPublisherOptions.cs).

Actual payload (AdvisoryObservationUpdatedEvent, src/Concelier/.../Observations/AdvisoryObservationUpdatedEvent.cs):

{
  "eventId": "…",                         // deterministic Guid from first 16 bytes of SHA-256("observation-event:{observationId}:{observationHash}")
  "tenantId": "urn:tenant:acme-corp",
  "observationId": "…",
  "advisoryId": "CVE-2024-1234",          // upstream advisory id
  "source": { /* AdvisoryObservationSource */ },
  "linksetSummary": {
    "aliases": ["…"],
    "purls":   ["pkg:npm/lodash"],
    "cpes":    ["…"],
    "scopes":  ["…"],
    "relationships": [ { "type": "…", "source": "…", "target": "…", "provenance": "…" } ]
  },
  "documentSha": "…",
  "observationHash": "…",
  "ingestedAt": "2024-12-29T08:01:00Z",
  "replayCursor": "…",
  "supersedesId": null,
  "traceId": null
}

There are no separate advisory.new / advisory.update / advisory.withdrawn event kinds in the code; the single advisory.observation.updated@1 event covers new and updated observations (supersession is expressed via supersedesId).

3. Trigger into the Scheduler

The Scheduler models the run trigger as the RunTrigger enum: Cron | Conselier | Excitor | Manual (src/JobEngine/.../StellaOps.Scheduler.Models/Enums.cs). Feed-driven drift runs carry RunTrigger.Conselier (or .Excitor) and record the originating export id in RunReason.ConselierExportId / RunReason.ExcitorExportId. Notify also defines the upstream completion events conselier.export.completed and excitor.export.completed (src/Notify/.../NotifyEventKinds.cs).

4. Impact targeting (Scheduler)

Rather than ad-hoc SQL against an scanner.scans table, the Scheduler resolves the affected image set through the impact index (IImpactIndex / RoaringImpactIndex, src/JobEngine/.../StellaOps.Scheduler.ImpactIndex/). The targeting service (ImpactTargetingService) exposes:

usageOnly restricts the result to components on a runtime/entrypoint path. The Selector scopes the query by digest / namespace / repository / tag glob / labels (SelectorScope: AllImages | ByNamespace | ByRepository | ByDigest | ByLabels). The returned ImpactSet is deduplicated by image digest.

The SQL snippet in earlier revisions is (illustrative) of intent only; the production path is the roaring-bitmap impact index, not a live SQL query.

5. Re-evaluation (Scanner + Policy Engine)

Queued run segments reuse the stored SBOM / score manifest instead of re-analysing the image. On the Scanner side, score replay is provided by IScoreReplayService / ScoreReplayService (src/Scanner/StellaOps.Scanner.WebService/Services/). The Policy Engine materialises effective findings, and Smart-Diff (below) classifies whether the state change is material.

6. Delta summary and the rescan-delta event

For each impacted image the run produces a DeltaSummary (src/JobEngine/.../StellaOps.Scheduler.Models/Run.cs):

{
  "imageDigest": "sha256:…",
  "newFindings": 4,
  "newCriticals": 0,
  "newHigh": 1,
  "newMedium": 2,
  "newLow": 1,
  "kevHits": ["CVE-2024-1234"],
  "topFindings": [ { "purl": "…", "vulnerabilityId": "CVE-2024-1234", "severity": "Critical", "link": "…" } ],
  "reportUrl": "…",
  "attestation": { "uuid": "…", "verified": true },
  "detectedAt": "2025-10-18T15:40:11Z"
}

When any deltas exist, SchedulerEventPublisher.PublishRescanDeltaAsync publishes a Notify event of kind scheduler.rescan.delta(version 1, NotifyEventKinds.SchedulerRescanDelta). The wire-faithful payload (see the canonical sample at docs/modules/signals/events/samples/scheduler.rescan.delta@1.sample.json, which the RescanDeltaEventSampleTests assert against) is:

{
  "eventId": "51d0ef8d-3a17-4af3-b2d7-4ad3db3d9d2c",
  "kind": "scheduler.rescan.delta",
  "tenant": "tenant-acme-solar",
  "ts": "2025-10-18T15:40:11+00:00",
  "payload": {
    "scheduleId": "rescan-weekly-critical",
    "impactedDigests": [
      "sha256:0f0a8de5c1f93d6716b7249f6f4ea3a8db451dc3f3c3ff823f53c9cbde5d5e8a",
      "sha256:ab921f9679dd8d0832f3710a4df75dbadbd58c2d95f26a4d4efb2fa8c3d9b4ce"
    ],
    "summary": { "newCritical": 0, "newHigh": 1, "total": 4 },
    "reason": "policy-change:scoring/v2"
  },
  "attributes": {}
}

The producer (BuildRescanPayload) emits scheduleId, impactedDigests[], summary { newCritical, newHigh, total }, and an optional reason (manual:… / conselier:… / excitor:…). There is no scan.verdict_changed event kind in the platform; verdict changes are surfaced through scheduler.rescan.delta (and, for individual report completion, scanner.report.ready).

A divergent sample exists at docs/modules/notify/resources/samples/scheduler.rescan.delta@1.sample.json with deltaId / imagesAffected / newVulnerabilities fields. That sample does not match the Scheduler producer or the asserted contract; the signals sample above is authoritative.

Scanner Feed-Change Rescore (second path)

Independently of the Scheduler, the Scanner WebService runs FeedChangeRescoreJob (BackgroundService, src/Scanner/StellaOps.Scanner.WebService/Services/FeedChangeRescoreJob.cs). Behaviour and defaults (FeedChangeRescoreOptions):

OptionDefaultEffect
EnabledtrueWhether the job runs
CheckInterval15 minPoll cadence for feed snapshot changes
MaxScansPerCycle100Cap on scans rescored per cycle
ScanAgeLimit7 daysOnly scans newer than this are eligible
RescoreConcurrency4Parallel rescore operations

Each cycle compares current Concelier / Excititor / Policy snapshot hashes against the last-seen values (IFeedSnapshotTracker), finds affected scans via IScanManifestRepository.FindAffectedScansAsync, and replays scoring through IScoreReplayService.ReplayScoreAsync. Metrics are emitted under the meter StellaOps.Scanner.FeedChangeRescore (see Observability).

Material Risk Detection (Smart-Diff)

Material-change detection is implemented by MaterialRiskChangeDetector (src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/). The rules R1–R4 are not the verdict/finding/KEV/VEX set previously documented; the actual rules are:

RuleID (DetectionRule)TriggerChange types
Reachability flipR1reachable flips true↔false (skipped if either side unknown)reachability_flip
VEX status flipR2Meaningful VEX transition (e.g. not_affected→affected, affected→fixed)vex_flip
Affected range boundaryR3Component version enters/exits the affected version rangerange_boundary
Intelligence / policy flipR4KEV add/remove, EPSS crosses threshold (default 0.10), or policy decision flip (Allow/Warn/Block)kev_added, kev_removed, epss_threshold, policy_flip

Each detected change carries a direction (increased / decreased / neutral) and a configurable weight (MaterialRiskChangeOptions). The detector also computes a priorityScore (per the Smart-Diff advisory A9): +1000 if currently KEV, +500 if reachable, +200 for a range flip into the affected range, +150 for a VEX flip to affected, +0..100 from EPSS (epss × 100), and +300/+100 for a Block/Warn policy decision.

The result record (MaterialRiskChangeResult) exposes findingKey, hasMaterialChange, changes[], priorityScore, and the previous/current state hashes.

Re-scan Policies (illustrative)

Not source-verified config. The YAML blocks below describe a desired triggering model and are retained as design intent. They do not map to a shipped configuration schema. The shipped knobs are the Scanner FeedChangeRescoreOptions (above), the Concelier AdvisoryObservationEventPublisherOptions, and Scheduler schedule definitions.

# (illustrative) immediate re-evaluation on critical advisories
advisory_drift:
  trigger: immediate
  severity_threshold: critical
  batch_size: 100
  parallelism: 10
# (illustrative) scheduled re-evaluation
advisory_drift:
  trigger: scheduled
  schedule: "0 */4 * * *"   # every 4 hours
  severity_threshold: high
  include_new_vex: true

Data Contracts

Advisory observation event (advisory.observation.updated@1)

See §2 above for the authoritative AdvisoryObservationUpdatedEvent shape. There is a single event kind; new vs. updated is not distinguished by name.

Rescan delta event (scheduler.rescan.delta@1)

interface SchedulerRescanDeltaPayload {
  scheduleId: string;
  impactedDigests: string[];           // "sha256:…"
  summary: { newCritical: number; newHigh: number; total: number };
  reason?: string;                     // "manual:…" | "conselier:…" | "excitor:…"
}

Delta summary (per image)

interface DeltaSummary {
  imageDigest: string;                 // "sha256:…"
  newFindings: number;
  newCriticals: number;
  newHigh: number;
  newMedium: number;
  newLow: number;
  kevHits: string[];
  topFindings: { purl: string; vulnerabilityId: string; severity: SeverityRank; link?: string }[];
  reportUrl?: string;
  attestation?: { uuid?: string; verified?: boolean };
  detectedAt?: string;
}

Authorization Scopes

Scopes referenced by this flow (canonical catalog StellaOpsScopes.cs):

Scope constantValueUsed for
AdvisoryIngestadvisory:ingestWrite/import raw advisory ingestion (Concelier)
AdvisoryReadadvisory:readRead raw advisory ingestion data
ConcelierJobsTriggerconcelier.jobs.triggerTrigger Concelier connector jobs
SchedulerReadscheduler:readRead Scheduler run state / history
SchedulerOperatescheduler:operatePause / resume / trigger Scheduler jobs
ScannerReadscanner:readRead scan results / score replays
ScannerScanscanner:scanTrigger scan / rescore operations
FindingsReadfindings:readRead effective findings materialised by Policy Engine
NotifyViewernotify.viewerView notifier delivery history

ASP.NET policy names may use colon form even where a scope constant differs; verify the policy registration and the scope constant together when wiring endpoints.

Performance Optimizations

OptimizationDescriptionSource anchor
SBOM / score reuseReplay scoring from the stored manifest rather than re-analysing the imageScoreReplayService
Impact-index targetingRoaring-bitmap lookup by purl / vuln id instead of scanning all imagesRoaringImpactIndex
Usage-only filteringRestrict to runtime/entrypoint components (usageOnly)ImpactTargetingService
Bounded rescore cyclesMaxScansPerCycle, ScanAgeLimit, RescoreConcurrency capsFeedChangeRescoreOptions
Deterministic dedupImpact set deduplicated by image digestImpactTargetingService

Error Handling

ErrorRecoverySource anchor
Manifest not found for rescoreLog manifest_not_found, skip scanFeedChangeRescoreJob.RescoreScanAsync
Rescore failureLog rescore_failed, increment error metric, continue cycleFeedChangeRescoreJob
Rescore cycle failureLog error, set activity error status, increment rescore_errors{context=cycle_failed}, sleep CheckInterval, retryFeedChangeRescoreJob.ExecuteAsync
Feed-snapshot init failureWarn and retry on next cycleFeedChangeRescoreJob.InitializeSnapshotsAsync
Event publish failure (Scheduler)Logged; run is not failed by a publish errorSchedulerEventPublisher.PublishAsync
Local publisher in useOne-time warning that Notify events are not publishedLocalSchedulerEventPublisher

Observability

Metrics

Scanner feed-change rescore (meter StellaOps.Scanner.FeedChangeRescore, FeedChangeRescoreMetrics):

MetricTypeLabels
stellaops.scanner.feed_changesCounterfeed (concelier / excititor / policy)
stellaops.scanner.rescoresCounterdeterministic
stellaops.scanner.rescore_errorsCountercontext
stellaops.scanner.rescore_cycle_duration_msHistogram

The advisory_drift_* metric names in earlier revisions are not emitted by the shipped code; use the meter names above.

Key log events (representative)

EventLevelFields
Feed changes detectedINFOchanged feeds list
Found N scans to rescoreINFOcount
Rescore cycle completedINFOelapsed ms, rescored/total
Failed to rescore scanERROR / WARNscanId
Failed to publish scheduler eventERROReventKind, runId