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:
- 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@1event consumed by Notify. (src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker) - 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
| Actor | Type | Role |
|---|---|---|
| Concelier | Service | Ingests/normalises advisories; optionally emits advisory.observation.updated@1 |
| Scheduler (JobEngine) | Service | Resolves impacted images via the impact index and queues re-evaluation runs; emits scheduler.rescan.delta@1 |
| Scanner | Service | Replays scoring for affected scans (feed-change rescore) and serves Smart-Diff comparisons |
| Policy Engine | Service | Materialises effective findings / verdicts that the re-evaluation compares against |
| Notify | Service | Delivers scanner.report.ready and scheduler.rescan.delta events to channels |
| SbomService | Service | Stores/serves the SBOM documents that re-evaluation reuses |
Prerequisites
- Advisory connectors configured (NVD, GHSA, OSV, distro/vendor feeds, etc.).
- Images previously scanned, with SBOMs and score manifests persisted.
- For the Scheduler path: schedules configured and the impact index populated.
- For the Scanner path:
FeedChangeRescoreJobenabled (default).
Advisory Sources
Concelier ships a large connector catalogue (50+ connector families under src/Concelier/.../StellaOps.Concelier.Connector.*). Representative families:
| Category | Connector families (examples) |
|---|---|
| National / catalog DBs | Nvd, Ghsa, Osv, Kev (CISA KEV), Epss |
| Vendor PSIRTs | Vndr.Msrc (Microsoft), Vndr.Oracle, Vndr.Cisco, Vndr.Vmware, Vndr.Adobe, Vndr.Apple, Vndr.Intel, Vndr.Amd, Vndr.Arm, Vndr.Chromium, Vndr.Siemens |
| Distros | Distro.Ubuntu, Distro.Debian, Distro.Alpine, Distro.RedHat, Distro.Suse, Distro.Arch, Distro.Gentoo |
| Ecosystems | Npm, PyPi/PyPa, Go/GoVuln, RubyGems, Packagist, Crates/RustSec, Maven, Hex, BundlerAudit |
| CERTs / regional | CertCc, CertBund, CertFr, CertIn, Cccs, Acsc, Jvn, Kisa, Ru.Bdu, Ru.Nkcki |
| ICS / framework | Ics.Cisa, Ics.Kaspersky, MitreAttack, MitreD3fend |
| Cloud-vendor advisories | Cloud.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 nats— not 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:
ResolveByPurlsAsync(productKeys, usageOnly, selector, …)(theproductKeysargument carries purls)ResolveByVulnerabilitiesAsync(vulnerabilityIds, usageOnly, selector, …)ResolveAllAsync(selector, usageOnly, …)
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.jsonwithdeltaId/imagesAffected/newVulnerabilitiesfields. 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):
| Option | Default | Effect |
|---|---|---|
Enabled | true | Whether the job runs |
CheckInterval | 15 min | Poll cadence for feed snapshot changes |
MaxScansPerCycle | 100 | Cap on scans rescored per cycle |
ScanAgeLimit | 7 days | Only scans newer than this are eligible |
RescoreConcurrency | 4 | Parallel 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:
| Rule | ID (DetectionRule) | Trigger | Change types |
|---|---|---|---|
| Reachability flip | R1 | reachable flips true↔false (skipped if either side unknown) | reachability_flip |
| VEX status flip | R2 | Meaningful VEX transition (e.g. not_affected→affected, affected→fixed) | vex_flip |
| Affected range boundary | R3 | Component version enters/exits the affected version range | range_boundary |
| Intelligence / policy flip | R4 | KEV 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 ConcelierAdvisoryObservationEventPublisherOptions, 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 constant | Value | Used for |
|---|---|---|
AdvisoryIngest | advisory:ingest | Write/import raw advisory ingestion (Concelier) |
AdvisoryRead | advisory:read | Read raw advisory ingestion data |
ConcelierJobsTrigger | concelier.jobs.trigger | Trigger Concelier connector jobs |
SchedulerRead | scheduler:read | Read Scheduler run state / history |
SchedulerOperate | scheduler:operate | Pause / resume / trigger Scheduler jobs |
ScannerRead | scanner:read | Read scan results / score replays |
ScannerScan | scanner:scan | Trigger scan / rescore operations |
FindingsRead | findings:read | Read effective findings materialised by Policy Engine |
NotifyViewer | notify.viewer | View 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
| Optimization | Description | Source anchor |
|---|---|---|
| SBOM / score reuse | Replay scoring from the stored manifest rather than re-analysing the image | ScoreReplayService |
| Impact-index targeting | Roaring-bitmap lookup by purl / vuln id instead of scanning all images | RoaringImpactIndex |
| Usage-only filtering | Restrict to runtime/entrypoint components (usageOnly) | ImpactTargetingService |
| Bounded rescore cycles | MaxScansPerCycle, ScanAgeLimit, RescoreConcurrency caps | FeedChangeRescoreOptions |
| Deterministic dedup | Impact set deduplicated by image digest | ImpactTargetingService |
Error Handling
| Error | Recovery | Source anchor |
|---|---|---|
| Manifest not found for rescore | Log manifest_not_found, skip scan | FeedChangeRescoreJob.RescoreScanAsync |
| Rescore failure | Log rescore_failed, increment error metric, continue cycle | FeedChangeRescoreJob |
| Rescore cycle failure | Log error, set activity error status, increment rescore_errors{context=cycle_failed}, sleep CheckInterval, retry | FeedChangeRescoreJob.ExecuteAsync |
| Feed-snapshot init failure | Warn and retry on next cycle | FeedChangeRescoreJob.InitializeSnapshotsAsync |
| Event publish failure (Scheduler) | Logged; run is not failed by a publish error | SchedulerEventPublisher.PublishAsync |
| Local publisher in use | One-time warning that Notify events are not published | LocalSchedulerEventPublisher |
Observability
Metrics
Scanner feed-change rescore (meter StellaOps.Scanner.FeedChangeRescore, FeedChangeRescoreMetrics):
| Metric | Type | Labels |
|---|---|---|
stellaops.scanner.feed_changes | Counter | feed (concelier / excititor / policy) |
stellaops.scanner.rescores | Counter | deterministic |
stellaops.scanner.rescore_errors | Counter | context |
stellaops.scanner.rescore_cycle_duration_ms | Histogram | — |
The
advisory_drift_*metric names in earlier revisions are not emitted by the shipped code; use the meter names above.
Key log events (representative)
| Event | Level | Fields |
|---|---|---|
| Feed changes detected | INFO | changed feeds list |
| Found N scans to rescore | INFO | count |
| Rescore cycle completed | INFO | elapsed ms, rescored/total |
| Failed to rescore scan | ERROR / WARN | scanId |
| Failed to publish scheduler event | ERROR | eventKind, runId |
Related Flows
- Scan Submission Flow — original scan process
- Policy Evaluation Flow — verdict materialisation
- Notification Flow — delivery of
scheduler.rescan.delta/scanner.report.ready - Reachability Drift Alert Flow — runtime drift
