Advisory Drift Re-scan Flow
Ingestion contract reconciled (PCD-307, 2026-10-05). The hub ingest loop, fact payload and read scope were verified against
438677d46finsrc/Vulnerabilitiesand the canonicalStellaOpsScopes.cscatalog. Re-verify the source anchors below withrg -n 'FactUpsertedEventType|ReadHubEvents|SectionName' src/Vulnerabilities. The downstream sections cite JobEngine, Scanner and Notify contracts separately. 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.
Re-evaluation runs through one path, Scheduler-driven impact targeting (JobEngine). The Scheduler’s advisory trigger follows the Vulnerabilities hub’s vuln.facts stream, keeps the changed packages that match an indexed image, and creates Advisory runs that resolve those images from its impact index and queue re-evaluation. The materialised result is the scheduler.rescan.delta@1 event consumed by Notify. (src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker)
Note. The path is gated behind configuration. The advisory trigger (
Scheduler:Worker:VulnerabilityHub:Trigger) and the Scanner image projection that fills the impact index (Scheduler:Worker:ScannerProjection) both default to disabled, and the trigger plans nothing while the index is empty. Verify the deployment configuration before assuming the path is live.
Actors
| Actor | Type | Role |
|---|---|---|
| Vulnerabilities hub | Service | Owns advisory facts; publishes the vuln.facts stream the advisory trigger follows |
| Scheduler (JobEngine) | Service | Resolves impacted images via the impact index and queues re-evaluation runs; emits scheduler.rescan.delta@1 |
| Scanner | Service | Serves the reports (and, for content-refresh schedules, the rescans) the runner requests, and 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. The index is filled from Scanner’s
scanner.scan.completedfeed by JobEngine’s Scanner image projection (Scheduler:Worker:ScannerProjection:Enabled, default off); with it off, every selector-based run plans zero images.
Advisory Sources
The Vulnerabilities worker runs the configured source keys in Vulnerabilities:Ingestion:Scheduler:EnabledSources. Scheduling is opt-in through Vulnerabilities:Ingestion:Scheduler:Enabled; the configured interval applies to complete cycles. IngestScheduler resolves the registered IVulnSourceConnector implementations, passes documents through IngestPipeline, and invokes NormalizationPump for advisory facts. Its optional EPSS snapshot is a separate cycle input. Binary corpus sources write corpus records rather than advisory facts.
Source: src/Vulnerabilities/__Libraries/StellaOps.Vulnerabilities.Ingestion/Ingest/IngestScheduler.cs and src/Vulnerabilities/StellaOps.Vulnerabilities.Worker/Program.cs.
Flow Diagram (Scheduler-driven path)
sequenceDiagram
participant Source as Advisory source
participant Hub as Vulnerabilities hub
participant Jobs as Scheduler (JobEngine)
participant Scanner
participant Notify
Source->>Hub: Connector ingestion and normalization
Hub->>Hub: Fact changes and vuln.facts outbox in one transaction
Jobs->>Hub: Authenticated event read from durable checkpoint
Hub-->>Jobs: Ordered fact envelopes and stream epoch
Jobs->>Jobs: Debounce changed packages; resolve indexed images
Jobs->>Scanner: Request re-evaluation reports
Scanner-->>Jobs: Materialized report changes
Jobs->>Notify: scheduler.rescan.delta@1
Step-by-Step (Scheduler-driven path)
1. Advisory ingestion (Vulnerabilities hub)
The worker’s IngestSchedulerWorker hosts the configured ingest cycle. IngestPipeline stores source documents and NormalizationPump derives facts and provenance. FactWriter updates current facts, appends changed revisions, and writes one outbox event per changed fact in the caller’s transaction. Unchanged fact content creates no revision or vuln.facts event. Connector failures are reported per source so one source does not starve the cycle.
2. Fact change stream (Vulnerabilities hub)
FactWriter.Stream is vuln.facts; its changed-fact event type is vuln.fact.upserted, version 1. This path uses the hub’s PostgreSQL outbox and authenticated pull API, GET /api/vulnerabilities/v1/events/vuln.facts. It does not require a Concelier publisher or a NATS subject.
The event payload uses these exact names (FactWriter.WriteBatchAsync):
interface FactUpsertedPayload {
fact_id: string;
vulnerability_id: string;
product_key: string;
revision_hash: string;
}
The event-read response supplies streamEpoch, headSeq, retention information and ordered envelopes. Each envelope has eventId, streamEpoch, seq, type, v, occurredAt and payload. The read requires the hub’s AdvisoryRead policy (advisory:read). Source: StellaOps.Vulnerabilities.WebService/Program.cs, ReadHubEvents. Consumers must handle a retained-window gap rather than treating a partial page as complete history.
3. Trigger into the Scheduler
The Scheduler models the run trigger as the RunTrigger enum: Cron | Conselier | Excitor | Manual | Advisory (src/JobEngine/.../StellaOps.Scheduler.Models/Enums.cs). Advisory-driven runs carry RunTrigger.Advisory; Conselier and Excitor belonged to the retired Concelier/Excititor export webhooks and remain only so stored runs still read.
The advisory trigger (Scheduler:Worker:VulnerabilityHub:Trigger, default off) reads the Vulnerabilities hub’s global vuln.facts stream (GET /api/vulnerabilities/v1/events/vuln.facts) per scheduler tenant:
vuln.fact.upsertedandvuln.fact.tombstonedname a product key (a versionless purl);exploit_evidence.changednames a vulnerability, resolved to its product keys throughGET /api/vulnerabilities/v1/advisories/{id}.- The keys that match an indexed image of the tenant are recorded in
scheduler.advisory_trigger_pendingwith the checkpoint, in one transaction. - Once the oldest pending change is older than the debounce (default 300 s), one
Advisoryrun is created on the system schedulesys-{tenant}-nightly-vuln-scan; its planner claims up to 500 pending keys and plans the images carrying them (RunReason.Cursorrecords the hub position,vuln.facts:{epoch}:{seq}). - A first start or a new hub epoch begins at the stream head, and envelopes the hub reclaimed before they were read are logged and passed over; the timed schedules cover both.
Notify still defines the retired upstream completion events conselier.export.completed and excitor.export.completed (src/Notify/.../NotifyEventKinds.cs); nothing in JobEngine consumes them.
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, …)(exact purls, or hub product keys, which match every indexed version of the package)ResolveByVulnerabilitiesAsync(vulnerabilityIds, usageOnly, selector, …)(ids resolved to product keys by the Vulnerabilities hub)ResolveAllAsync(selector, usageOnly, …)
usageOnly drops only matches whose component is known not to be used in that image; components on an entrypoint path and components whose usage was never analysed are kept, because skipping a rescan for lack of usage data would be silent. Analysis-only schedules pass usageOnly = true; content-refresh schedules pass false. The Selector scopes the query by tenant / digest / namespace / repository / tag glob / labels (SelectorScope: AllImages | ByNamespace | ByRepository | ByDigest | ByLabels). The index keys images by tenant and digest, so a tenant-scoped query returns each image once.
5. Re-evaluation (Scanner + Policy Engine)
For each image in a queued run segment, the runner asks Scanner for a fresh report (POST /api/v1/reports with the image digest, HttpScannerReportClient), and Scanner obtains the verdict from the Policy Engine’s preview of that image. Analysis-only schedules re-evaluate the stored image this way without re-analysing it; content-refresh schedules first submit a new scan of the image’s indexed location (registry/repository@digest, POST /api/v1/scans), and skip the rescan of an image whose location the index does not know. The runner builds the image’s DeltaSummary from the report, and Smart-Diff (below) classifies whether a state change is material.
Score replay (POST /api/v1/scans/{scanId}/score/replay, ScoreReplayService) is not part of this flow: it reproduces a scan’s original score from the frozen inputs in its manifest, so it cannot reflect a new advisory.
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.
Withdrawn: Scanner feed-change rescore
Scanner has no feed-change rescore job. FeedChangeRescoreJob, a Scanner WebService background job that polled Concelier, Excititor and Policy snapshot hashes and replayed scoring for recent scans, was never registered by a host, and its snapshot source had no implementation. It is withdrawn, together with its FeedChangeRescore options and its StellaOps.Scanner.FeedChangeRescore meter. Advisory changes reach images only through the Scheduler path above.
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 a verdict/finding/KEV/VEX set. 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 advisory trigger (
Scheduler:Worker:VulnerabilityHub:Trigger), the Scanner image projection (Scheduler:Worker:ScannerProjection), and the 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
Hub fact event (vuln.facts / vuln.fact.upserted@1)
The payload and ordered read envelope are defined in step 2. product_key identifies the changed product used by the Scheduler’s impact targeting; fact_id and revision_hash identify the fact and its changed revision.
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 |
|---|---|---|
AdvisoryRead | advisory:read | Read Vulnerabilities hub facts and ordered event envelopes |
VulnSubmit | vuln:submit | Submit first-party issuer facts to the Vulnerabilities hub |
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 |
|---|---|---|
| Report reuse | Analysis-only runs request a fresh report for the stored image instead of rescanning it | HttpScannerReportClient |
| 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 |
| Deterministic dedup | Impact set deduplicated by image digest | ImpactTargetingService |
Error Handling
| Error | Recovery | Source anchor |
|---|---|---|
| Transient Scanner report failure | Retry with exponential backoff (SchedulerWorkerOptions.Runner.Scanner.MaxRetryAttempts, default 3) | HttpScannerReportClient |
| Content-refresh submission failure | Logged as a warning; the runner still requests the report | HttpScannerReportClient |
| 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
Scheduler worker (meter StellaOps.Scheduler.Worker, SchedulerWorkerMetrics), grouped by schedule mode:
| Metric | Type |
|---|---|
scheduler_planner_runs_total | Counter |
scheduler_runner_images_total | Counter |
scheduler_runner_delta_critical_total, scheduler_runner_delta_high_total, scheduler_runner_delta_total, scheduler_runner_delta_kev_total | Counter |
Key log events (representative)
| Event | Level | Fields |
|---|---|---|
| Scanner report attempt failed; retrying | WARN | digest, attempt, delay |
| 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
