Files
git.stella-ops.org/docs/product-advisories/04-Dec-2025- Ranking Unknowns in Reachability Graphs.md
2025-12-09 20:23:50 +02:00

34 KiB
Raw Blame History

Heres a simple, actionable way to keep “unknowns” from piling up in StellaOps: rank them by how risky they might be and how widely they could spread—then let Scheduler autorecheck or escalate based on that score.


Unknowns Triage: a lightweight, highleverage scheme

Goal: decide which “Unknown” findings (no proof yet; inconclusive reachability; unparsed advisory; mismatched version; missing evidence) to rescan first or route into VEX escalation—without waiting for perfect certainty.

1) Define the score

Score each Unknown U with a weighted sum (normalize each input to 01):

  • Component popularity (P): how many distinct workloads/images depend on this package (direct + transitive). Proxy: indegree or deployment count across environments.
  • CVSS uncertainty (C): how fuzzy the risk is (e.g., missing vector, version ranges like <=, vendor ambiguity). Proxy: 1 certainty; higher = less certain, more dangerous to ignore.
  • Graph centrality (G): how “hublike” the component is in your dependency graph. Proxy: normalized betweenness/degree centrality in your SBOM DAG.

TriageScore(U) = wP·P + wC·C + wG·G, with default weights: wP=0.4, wC=0.35, wG=0.25.

Thresholds (tuneable):

  • ≥ 0.70Hot: immediate rescan + VEX escalation job
  • 0.400.69Warm: schedule rescan within 2448h
  • < 0.40Cold: batch into weekly sweep

2) Minimal schema (Postgres or Mongo) to support it

  • unknowns(id, pkg_id, version, source, first_seen, last_seen, certainty, evidence_hash, status)
  • deploy_refs(pkg_id, image_id, env, first_seen, last_seen) → compute popularity P
  • graph_metrics(pkg_id, degree_c, betweenness_c, last_calc_at) → compute centrality G
  • advisory_gaps(pkg_id, missing_fields[], has_range_version, vendor_mismatch) → compute uncertainty C

Store triage_score, triage_band on write so Scheduler can act without recomputing everything.

3) Fast heuristics to fill inputs

  • P (popularity): P = min(1, log10(1 + deployments)/log10(1 + 100))
  • C (uncertainty): start at 0; +0.3 if version range, +0.2 if vendor mismatch, +0.2 if missing CVSS vector, +0.2 if evidence stale (>7d), cap at 1.0
  • G (centrality): precompute on SBOM DAG nightly; normalize to [0,1]

4) Scheduler rules (UnknownsRegistry → jobs)

  • On unknowns.upsert:

    • compute (P,C,G) → triage_score

    • if Hot → enqueue:

      • Deterministic rescan (fresh feeds + strict lattice)
      • VEX escalation (Excititor) with context pack (SBOM slice, provenance, last evidence)
    • if Warm → enqueue rescan with jitter (spread load)

    • if Cold → tag for weekly batch

  • Backoff: if the same Unknown stays Hot after N attempts, widen evidence (alternate feeds, secondary matcher, vendor OVAL, NVD mirror) and alert.

5) Operatorvisible UX (DevOpsfriendly)

  • Unknowns list: columns = pkg@ver, deployments, centrality, uncertainty flags, last evidence age, score badge (Hot/Warm/Cold), Next action chip.
  • Side panel: show why the score is high (P/C/G subscores) + scheduled jobs and last outcomes.
  • Bulk actions: “Recompute scores”, “Force VEX escalation”, “Dedupe aliases”.

6) Guardrails to keep it deterministic

  • Record the inputs + weights + feed hashes in the scan manifest (your “replay” object).
  • Any change to weights or heuristics → new policy version in the manifest; old runs remain replayable.

7) Reference snippets

SQL (Postgres) — compute and persist score:

update unknowns u
set triage_score = least(1, 0.4*u.popularity_p + 0.35*u.cvss_uncertainty_c + 0.25*u.graph_centrality_g),
    triage_band  = case
        when (0.4*u.popularity_p + 0.35*u.cvss_uncertainty_c + 0.25*u.graph_centrality_g) >= 0.70 then 'HOT'
        when (0.4*u.popularity_p + 0.35*u.cvss_uncertainty_c + 0.25*u.graph_centrality_g) >= 0.40 then 'WARM'
        else 'COLD'
    end,
    last_scored_at = now()
where u.status = 'OPEN';

C# (Common) — score helper:

public static (double score, string band) Score(double p, double c, double g,
    double wP=0.4, double wC=0.35, double wG=0.25)
{
    var s = Math.Min(1.0, wP*p + wC*c + wG*g);
    var band = s >= 0.70 ? "HOT" : s >= 0.40 ? "WARM" : "COLD";
    return (s, band);
}

8) Where this plugs into StellaOps

  • Scanner.WebService: writes Unknowns with raw flags (rangeversion, vector missing, vendor mismatch).
  • UnknownsRegistry: computes P/C/G, persists triage fields, emits Unknown.Triaged.
  • Scheduler: listens → enqueues Rescan / VEX Escalation with jitter/backoff.
  • Excititor (VEX): builds vendormerge proof or raises “Unresolvable” with rationale.
  • Authority: records policy version + weights in replay manifest.

If you want, I can drop in a readytouse UnknownsRegistry table DDL + EF Core 9 model and a tiny Scheduler job that implements these thresholds. Below is a complete, production-grade developer guideline for Ranking Unknowns in Reachability Graphs inside Stella Ops. It fits the existing architectural rules (scanner = origin of truth, Concelier/Vexer = prune-preservers, Authority = replay manifest owner, Scheduler = executor).

These guidelines give:

  1. Definitions
  2. Ranking dimensions
  3. Deterministic scoring formula
  4. Evidence capture
  5. Scheduler policies
  6. UX and API rules
  7. Testing rules and golden fixtures

Stella Ops Developer Guidelines

Ranking Unknowns in Reachability Graphs

0. Purpose

An Unknown is any vulnerability-like record where reachability, affectability, or evidence linkage cannot yet be proved true or false. We rank Unknowns to:

  1. Prioritize rescans
  2. Trigger VEX escalation
  3. Guide operators in constrained time windows
  4. Maintain deterministic behaviour under replay manifests
  5. Avoid non-deterministic or “probabilistic” security decisions

Unknown ranking never declares security state. It determines the order of proof acquisition.


1. Formal Definition of “Unknown”

A record is classified as Unknown if one or more of the following is true:

  1. Dependency Reachability Unproven

    • Graph traversal exists but is not validated by call-graph/rule-graph evidence.
    • Downstream node is reachable but no execution path has sufficient evidence.
  2. Version Semantics Uncertain

    • Advisory reports <=, <, >=, version ranges, or ambiguous pseudo-versions.
    • Normalized version mapping disagrees between data sources.
  3. Component Provenance Uncertain

    • Package cannot be deterministically linked to its SBOM node (name-alias confusion, epoch mismatch, distro backport case).
  4. Missing/Contradictory Evidence

    • Feeds disagree; Vendor VEX differs from NVD; OSS index has missing CVSS vector; environment evidence incomplete.
  5. Weak Graph Anchoring

    • Node exists but cannot be anchored to a layer digest or artifact hash (common in scratch/base images and badly packaged libs).

Unknowns must be stored with explicit flags—not as a collapsed bucket.


2. Dimensions for Ranking Unknowns

Each Unknown is ranked along five deterministic axes:

2.1 Popularity Impact (P)

How broadly the component is used across workloads.

Evidence sources:

  • SBOM deployment graph
  • Workload registry
  • Layer-to-package index

Compute: P = normalized log(deployment_count).

2.2 Exploit Consequence Potential (E)

Not risk. Consequence if the Unknown turns out to be an actual vulnerability.

Compute from:

  • Maximum CVSS across feeds
  • CWE category weight
  • Vendor “criticality marker” if present
  • If CVSS missing → use CWE fallback → mark uncertainty penalty.

2.3 Uncertainty Density (U)

How much is missing or contradictory.

Flags (examples):

  • version_range → +0.25
  • missing_vector → +0.15
  • conflicting_feeds → +0.20
  • no provenance anchor → +0.30
  • unreachable source advisory → +0.10

U ∈ [0, 1].

2.4 Graph Centrality (C)

Is this component a structural hub?

Use:

  • In-degree
  • Out-degree
  • Betweenness centrality

Normalize per artifact type.

2.5 Evidence Staleness (S)

Age of last successful evidence pull.

Decay function: S = min(1, age_days / 14).


3. Deterministic Ranking Score

All Unknowns get a reproducible score under replay manifest:

Score = clamp01(
    wP·P  +
    wE·E  +
    wU·U  +
    wC·C  +
    wS·S
)

Default recommended weights:

wP = 0.25   (deployment impact)
wE = 0.25   (potential consequence)
wU = 0.25   (uncertainty density)
wC = 0.15   (graph centrality)
wS = 0.10   (evidence staleness)

The manifest must record:

  • weights
  • transform functions
  • normalization rules
  • feed hashes
  • evidence hashes

Thus the ranking is replayable bit-for-bit.


4. Ranking Bands

After computing Score:

  • Hot (Score ≥ 0.70) Immediate rescan, VEX escalation, widen evidence sources.

  • Warm (0.40 ≤ Score < 0.70) Scheduled rescan, no escalation yet.

  • Cold (Score < 0.40) Batch weekly; suppressed from UI noise except on request.

Band assignment must be stored explicitly.


5. Evidence Capture Requirements

Every Unknown must persist:

  1. UnknownFlags[] all uncertainty flags
  2. GraphSliceHash deterministic hash of dependents/ancestors
  3. EvidenceSetHash hashes of advisories, vendor VEXes, feed extracts
  4. NormalizationTrace version normalization decision path
  5. CallGraphAttemptHash even if incomplete
  6. PackageMatchTrace exact match reasoning (name, epoch, distro backport heuristics)

This allows Inspector/Authority to replay everything and prevents “ghost Unknowns” caused by environment drift.


6. Scheduler Policies

6.1 On Unknown Created

Scheduler receives event: Unknown.Created.

Decision matrix:

Condition Action
Score ≥ 0.70 Immediate Rescan + VEX Escalation job
Score 0.400.69 Queue rescan within 1272h (jitter)
Score < 0.40 Add to weekly batch

6.2 On Unknown Unchanged after N rescans

If N = 3 consecutive runs with same UnknownFlags:

  • Force alternate feeds (mirror, vendor direct)
  • Run VEX excitor with full provenance pack
  • If still unresolved → emit Unknown.Unresolvable event (not an error; a state)

6.3 Failure Recovery

If fetch/feed errors → Unknown transitions to Unknown.EvidenceFailed. This must raise S (staleness) on next compute.


7. Scanner Implementation Guidelines (.NET 10)

7.1 Ranking Computation Location

Ranking is computed inside scanner.webservice immediately after Unknown classification. Concelier/Vexer must not touch ranking logic.

7.2 Graph Metrics Service

Maintain a cached daily calculation of centrality metrics to prevent per-scan recomputation cost explosion.

7.3 Compute Path

1. Build evidence set
2. Classify UnknownFlags
3. Compute P, E, U, C, S
4. Compute Score
5. Assign Band
6. Persist UnknownRecord
7. Emit Unknown.Triaged event

7.4 Storage Schema (Postgres)

Fields required:

unknown_id PK
pkg_id
pkg_version
digest_anchor
unknown_flags jsonb
popularity_p float
potential_e float
uncertainty_u float
centrality_c float
staleness_s float
score float
band enum
graph_slice_hash bytea
evidence_set_hash bytea
normalization_trace jsonb
callgraph_attempt_hash bytea
created_at, updated_at

8. API and UX Guidelines

8.1 Operator UI

For every Unknown:

  • Score badge (Hot/Warm/Cold)
  • Sub-component contributions (P/E/U/C/S)
  • Flags list
  • Evidence age
  • Scheduled next action
  • History graph of score evolution

8.2 Filters

Operators may filter by:

  • High P (impactful components)
  • High U (ambiguous advisories)
  • High S (stale data)
  • High C (graph hubs)

8.3 Reasoning Transparency

UI must show exactly why the ranking is high. No hidden heuristics.


9. Unit Testing & Golden Fixtures

9.1 Golden Unknown Cases

Provide frozen fixtures for:

  • Version range ambiguity
  • Mismatched epoch/backport
  • Missing vector
  • Conflicting severity between vendor/NVD
  • Unanchored filesystem library

Each fixture stores expected:

  • Flags
  • P/E/U/C/S
  • Score
  • Band

9.2 Replay Manifest Tests

Given a manifest containing:

  • feed hashes
  • rules version
  • normalization logic
  • lattice rules (for overall system)

Ensure ranking recomputes identically.


10. Developer Checklist (must be followed)

  1. Did I persist all traces needed for deterministic replay?
  2. Does ranking depend only on manifest-declared parameters (not environment)?
  3. Are all uncertainty factors explicit flags, never inferred fuzzily?
  4. Is the scoring reproducible under identical inputs?
  5. Is Scheduler decision table deterministic and exhaustively tested?
  6. Does API expose full reasoning without hiding rules?

If you want, I can now produce:

  1. A full Postgres DDL for Unknowns.
  2. A .NET 10 service class for ranking calculation.
  3. A golden test suite with 20 fixtures.
  4. UI wireframe for Unknown triage screen.

Which one should I generate? Here is everything in one place: DDL, ranking service, golden fixtures, and a concrete UI wireframe that fits Stella Ops and the rules we already set (ranking happens in scanner.webservice, deterministic, replayable).


1. Postgres DDL for Unknown Ranking

You can put this into a dedicated schema, e.g. scanner or stellaops_scanner. I will assume scanner below; rename as needed.

-- Optional: for gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Band enum
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'unknown_band') THEN
        CREATE TYPE unknown_band AS ENUM ('HOT', 'WARM', 'COLD');
    END IF;
END$$;

CREATE TABLE IF NOT EXISTS scanner_unknowns (
    unknown_id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Link to component/artifact
    component_id            uuid        NOT NULL,     -- FK to your components/packages table
    component_name          text        NOT NULL,
    component_version       text        NOT NULL,
    component_type          text        NOT NULL,     -- e.g. "os-pkg", "library", "container-layer"
    source_image_id         uuid        NULL,         -- FK to images/artifacts table if you have one
    digest_anchor           text        NULL,         -- e.g. sha256:... layer or image digest

    -- Uncertainty flags and traces
    unknown_flags           jsonb       NOT NULL,     -- array or object of flags
    normalization_trace     jsonb       NOT NULL,     -- version/provenance decisions
    graph_slice_hash        bytea       NOT NULL,
    evidence_set_hash       bytea       NOT NULL,
    callgraph_attempt_hash  bytea       NULL,

    -- Axes (all normalized to [0, 1])
    popularity_p            real        NOT NULL,
    potential_e             real        NOT NULL,
    uncertainty_u           real        NOT NULL,
    centrality_c            real        NOT NULL,
    staleness_s             real        NOT NULL,

    -- Final ranking
    score                   real        NOT NULL,
    band                    unknown_band NOT NULL,

    -- Operational metadata
    created_at              timestamptz NOT NULL DEFAULT now(),
    updated_at              timestamptz NOT NULL DEFAULT now(),
    last_scored_at          timestamptz NOT NULL DEFAULT now()
);

-- Keep updated_at and last_scored_at current
CREATE OR REPLACE FUNCTION scanner_unknowns_set_timestamps()
RETURNS trigger AS $$
BEGIN
    NEW.updated_at := now();
    IF NEW.score IS DISTINCT FROM OLD.score
        OR NEW.band IS DISTINCT FROM OLD.band
        OR NEW.popularity_p IS DISTINCT FROM OLD.popularity_p
        OR NEW.potential_e  IS DISTINCT FROM OLD.potential_e
        OR NEW.uncertainty_u IS DISTINCT FROM OLD.uncertainty_u
        OR NEW.centrality_c  IS DISTINCT FROM OLD.centrality_c
        OR NEW.staleness_s   IS DISTINCT FROM OLD.staleness_s
    THEN
        NEW.last_scored_at := now();
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_scanner_unknowns_set_timestamps ON scanner_unknowns;

CREATE TRIGGER trg_scanner_unknowns_set_timestamps
BEFORE UPDATE ON scanner_unknowns
FOR EACH ROW
EXECUTE FUNCTION scanner_unknowns_set_timestamps();

-- Useful indexes
CREATE INDEX IF NOT EXISTS idx_scanner_unknowns_component
    ON scanner_unknowns (component_id);

CREATE INDEX IF NOT EXISTS idx_scanner_unknowns_source_image_band
    ON scanner_unknowns (source_image_id, band);

CREATE INDEX IF NOT EXISTS idx_scanner_unknowns_score_band
    ON scanner_unknowns (band, score DESC);

CREATE INDEX IF NOT EXISTS idx_scanner_unknowns_unknown_flags_gin
    ON scanner_unknowns USING gin (unknown_flags jsonb_path_ops);

If you prefer a separate table for graph/evidence hashes, they can be factored out, but the above keeps lookup simple.


2. .NET 10 Ranking Service (Scanner.WebService)

This service is pure: given normalized axes and weights, it returns Score and Band. Axis computation (deployments → P, etc.) can be in separate helpers or services; this class only enforces the deterministic score.

2.1 Domain Models

namespace StellaOps.Scanner.Domain.Unknowns;

public enum UnknownBand
{
    Hot,
    Warm,
    Cold
}

/// <summary>
/// Normalized ranking axes, all in [0, 1].
/// </summary>
public sealed record UnknownRankingAxes(
    double PopularityP,
    double PotentialE,
    double UncertaintyU,
    double CentralityC,
    double StalenessS
);

/// <summary>
/// Weights for the ranking axes. Stored in the replay manifest.
/// </summary>
public sealed record UnknownRankingWeights(
    double WeightP,
    double WeightE,
    double WeightU,
    double WeightC,
    double WeightS
)
{
    public static UnknownRankingWeights Default { get; } =
        new(WeightP: 0.25, WeightE: 0.25, WeightU: 0.25, WeightC: 0.15, WeightS: 0.10);
}

/// <summary>
/// Result of ranking: final score and band, plus the axes used.
/// </summary>
public sealed record UnknownRankingResult(
    double Score,
    UnknownBand Band,
    UnknownRankingAxes Axes,
    UnknownRankingWeights Weights
);

2.2 Ranking Service

using System;

namespace StellaOps.Scanner.Domain.Unknowns;

public interface IUnknownRankingService
{
    UnknownRankingResult Rank(UnknownRankingAxes axes, UnknownRankingWeights? weights = null);
}

public sealed class UnknownRankingService : IUnknownRankingService
{
    public UnknownRankingResult Rank(UnknownRankingAxes axes, UnknownRankingWeights? weights = null)
    {
        var w = weights ?? UnknownRankingWeights.Default;

        ValidateAxis(axes.PopularityP, nameof(axes.PopularityP));
        ValidateAxis(axes.PotentialE, nameof(axes.PotentialE));
        ValidateAxis(axes.UncertaintyU, nameof(axes.UncertaintyU));
        ValidateAxis(axes.CentralityC, nameof(axes.CentralityC));
        ValidateAxis(axes.StalenessS, nameof(axes.StalenessS));

        var score =
            w.WeightP * axes.PopularityP +
            w.WeightE * axes.PotentialE +
            w.WeightU * axes.UncertaintyU +
            w.WeightC * axes.CentralityC +
            w.WeightS * axes.StalenessS;

        score = Clamp01(score);

        var band = score switch
        {
            >= 0.70 => UnknownBand.Hot,
            >= 0.40 => UnknownBand.Warm,
            _       => UnknownBand.Cold
        };

        return new UnknownRankingResult(score, band, axes, w);
    }

    private static double Clamp01(double value)
        => value switch
        {
            < 0d => 0d,
            > 1d => 1d,
            _ => value
        };

    private static void ValidateAxis(double value, string paramName)
    {
        if (value is < 0d or > 1d)
        {
            throw new ArgumentOutOfRangeException(
                paramName,
                value,
                "Axis value must be normalized to the [0, 1] range.");
        }
    }
}

2.3 Optional Helpers (Axis Normalization)

If you want helpers for P and S (others likely depend on external data like CVSS, CWE, etc.):

public static class UnknownRankingAxisHelpers
{
    /// <summary>
    /// Normalizes deployment count to popularity P in [0,1].
    /// maxDeployments is the effective saturation point (e.g. 1000).
    /// </summary>
    public static double NormalizeDeploymentsToP(long deployments, long maxDeployments = 1000)
    {
        if (deployments <= 0) return 0d;
        if (maxDeployments <= 0) throw new ArgumentOutOfRangeException(nameof(maxDeployments));

        var x = (double)deployments;
        var max = (double)maxDeployments;

        var numerator   = Math.Log10(1d + x);
        var denominator = Math.Log10(1d + max);

        if (denominator <= 0d) return 0d;

        var value = numerator / denominator;
        return value < 0d ? 0d : (value > 1d ? 1d : value);
    }

    /// <summary>
    /// Normalizes evidence age in days to S in [0,1].
    /// Half-lifeWindowDays controls how fast staleness saturates.
    /// </summary>
    public static double NormalizeEvidenceAgeToS(int ageDays, int saturationDays = 14)
    {
        if (ageDays <= 0) return 0d;
        if (saturationDays <= 0) throw new ArgumentOutOfRangeException(nameof(saturationDays));

        var s = (double)ageDays / saturationDays;
        return s < 0d ? 0d : (s > 1d ? 1d : s);
    }
}

You can wire those into the SBOM/graph metric pipeline as needed.


3. Golden Test Suite with 20 Fixtures

3.1 File Layout

Proposed test project layout:

tests/
  Scanner/
    StellaOps.Scanner.Domain.Tests/
      UnknownRanking/
        Fixtures/
          unknown-ranking-fixtures.json
        UnknownRankingTests.cs

3.2 JSON Fixtures (20 Cases)

tests/Scanner/StellaOps.Scanner.Domain.Tests/UnknownRanking/Fixtures/unknown-ranking-fixtures.json

Each fixture includes axes and the expected score/band under the default weights from above.

[
  {
    "id": "U001",
    "axes": { "p": 0.95, "e": 0.95, "u": 0.90, "c": 0.90, "s": 0.90 },
    "expected": { "score": 0.9250, "band": "HOT" }
  },
  {
    "id": "U002",
    "axes": { "p": 0.80, "e": 0.90, "u": 0.85, "c": 0.60, "s": 0.70 },
    "expected": { "score": 0.7975, "band": "HOT" }
  },
  {
    "id": "U003",
    "axes": { "p": 0.70, "e": 0.80, "u": 0.60, "c": 0.70, "s": 0.50 },
    "expected": { "score": 0.6800, "band": "WARM" }
  },
  {
    "id": "U004",
    "axes": { "p": 0.60, "e": 0.75, "u": 0.80, "c": 0.40, "s": 0.80 },
    "expected": { "score": 0.6775, "band": "WARM" }
  },
  {
    "id": "U005",
    "axes": { "p": 0.50, "e": 0.70, "u": 0.90, "c": 0.30, "s": 0.60 },
    "expected": { "score": 0.6300, "band": "WARM" }
  },
  {
    "id": "U006",
    "axes": { "p": 0.60, "e": 0.60, "u": 0.50, "c": 0.40, "s": 0.40 },
    "expected": { "score": 0.5250, "band": "WARM" }
  },
  {
    "id": "U007",
    "axes": { "p": 0.40, "e": 0.55, "u": 0.55, "c": 0.50, "s": 0.30 },
    "expected": { "score": 0.4800, "band": "WARM" }
  },
  {
    "id": "U008",
    "axes": { "p": 0.30, "e": 0.65, "u": 0.45, "c": 0.30, "s": 0.50 },
    "expected": { "score": 0.4450, "band": "WARM" }
  },
  {
    "id": "U009",
    "axes": { "p": 0.45, "e": 0.40, "u": 0.60, "c": 0.35, "s": 0.20 },
    "expected": { "score": 0.4350, "band": "WARM" }
  },
  {
    "id": "U010",
    "axes": { "p": 0.55, "e": 0.45, "u": 0.35, "c": 0.60, "s": 0.10 },
    "expected": { "score": 0.4375, "band": "WARM" }
  },
  {
    "id": "U011",
    "axes": { "p": 0.20, "e": 0.30, "u": 0.50, "c": 0.20, "s": 0.40 },
    "expected": { "score": 0.3200, "band": "COLD" }
  },
  {
    "id": "U012",
    "axes": { "p": 0.10, "e": 0.40, "u": 0.40, "c": 0.10, "s": 0.20 },
    "expected": { "score": 0.2600, "band": "COLD" }
  },
  {
    "id": "U013",
    "axes": { "p": 0.25, "e": 0.20, "u": 0.60, "c": 0.15, "s": 0.30 },
    "expected": { "score": 0.3150, "band": "COLD" }
  },
  {
    "id": "U014",
    "axes": { "p": 0.30, "e": 0.35, "u": 0.25, "c": 0.30, "s": 0.30 },
    "expected": { "score": 0.3000, "band": "COLD" }
  },
  {
    "id": "U015",
    "axes": { "p": 0.35, "e": 0.25, "u": 0.30, "c": 0.10, "s": 0.10 },
    "expected": { "score": 0.2500, "band": "COLD" }
  },
  {
    "id": "U016",
    "axes": { "p": 0.05, "e": 0.10, "u": 0.20, "c": 0.05, "s": 0.05 },
    "expected": { "score": 0.1000, "band": "COLD" }
  },
  {
    "id": "U017",
    "axes": { "p": 0.15, "e": 0.05, "u": 0.10, "c": 0.10, "s": 0.10 },
    "expected": { "score": 0.1000, "band": "COLD" }
  },
  {
    "id": "U018",
    "axes": { "p": 0.05, "e": 0.20, "u": 0.05, "c": 0.05, "s": 0.20 },
    "expected": { "score": 0.1025, "band": "COLD" }
  },
  {
    "id": "U019",
    "axes": { "p": 0.10, "e": 0.10, "u": 0.15, "c": 0.05, "s": 0.15 },
    "expected": { "score": 0.1100, "band": "COLD" }
  },
  {
    "id": "U020",
    "axes": { "p": 0.20, "e": 0.05, "u": 0.05, "c": 0.20, "s": 0.05 },
    "expected": { "score": 0.1100, "band": "COLD" }
  }
]

3.3 xUnit Test Class

UnknownRankingTests.cs:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using StellaOps.Scanner.Domain.Unknowns;
using Xunit;

namespace StellaOps.Scanner.Domain.Tests.UnknownRanking;

public sealed class UnknownRankingTests
{
    private sealed class Fixture
    {
        public string Id { get; set; } = default!;
        public AxesFixture Axes { get; set; } = default!;
        public ExpectedFixture Expected { get; set; } = default!;
    }

    private sealed class AxesFixture
    {
        public double P { get; set; }
        public double E { get; set; }
        public double U { get; set; }
        public double C { get; set; }
        public double S { get; set; }
    }

    private sealed class ExpectedFixture
    {
        public double Score { get; set; }
        public string Band { get; set; } = default!;
    }

    [Fact]
    public void Ranking_Matches_Golden_Fixtures()
    {
        // Arrange
        var jsonPath = Path.Combine(
            AppContext.BaseDirectory,
            "UnknownRanking/Fixtures/unknown-ranking-fixtures.json");

        var json = File.ReadAllText(jsonPath);
        var fixtures = JsonSerializer.Deserialize<List<Fixture>>(json)
            ?? throw new InvalidOperationException("Failed to deserialize fixtures.");

        var service = new UnknownRankingService();

        foreach (var f in fixtures)
        {
            var axes = new UnknownRankingAxes(
                PopularityP: f.Axes.P,
                PotentialE:  f.Axes.E,
                UncertaintyU:f.Axes.U,
                CentralityC: f.Axes.C,
                StalenessS:  f.Axes.S);

            // Act
            var result = service.Rank(axes);

            // Assert
            Assert.Equal(f.Expected.Band, result.Band.ToString(), ignoreCase: true);
            Assert.InRange(result.Score, f.Expected.Score - 1e-6, f.Expected.Score + 1e-6);
        }
    }
}

Note: the tiny epsilon allows for floating-point drift if runtimes change.


4. UI Wireframe for “Unknowns in Reachability Graphs”

This is the operator view in Stella Ops UI (Angular front end). It serves on-call and security engineers; pipeline-first, UI-second.

4.1 Layout Overview

Two-pane layout:

  • Left: Unknowns list (table) with filters and sorting.
  • Right: Inspector panel with full reasoning for selected Unknown.

Rough ASCII layout:

+-------------------------------------------------------------------------------------+
| Filters & Summary                                                                   |
| [Image:] [Environment: All v] [Band: HOT/WARM/COLD v] [Component type v] [Search..] |
| [Toggle] Show only HOT [Toggle] Show shared components [Toggle] Show stale evidence |
+-------------------------------------------------------------------------------------+
| Unknowns (Reachability Graph)                                                       |
|-------------------------------------------------------------------------------------|
| Score | Band | Component          | Deployments | Centrality | Uncertainty | Next   |
|       |      |                    |             | (P/E/U/C/S)| Flags Count | Action |
|-------------------------------------------------------------------------------------|
| 0.93  | HOT  | openssl 1.1.1k    |  134        | 0.88       | 4           | Rescan |
| 0.80  | HOT  | log4j-core 2.17.0 |   72        | 0.91       | 3           | VEX    |
| 0.63  | WARM | zlib 1.2.11       |   51        | 0.70       | 2           | Batch  |
| ...                                                                                 |
+---------------------------------------------+---------------------------------------+
|                                             | Inspector: Unknown U001              |
|                                             |--------------------------------------|
|                                             | [Summary] [Axes] [Uncertainty] [Ev.] |
|                                             |                                      |
|                                             | Component: openssl 1.1.1k            |
|                                             | Band: HOT | Score: 0.93              |
|                                             | Reason: Broad use + high impact      |
|                                             |                                      |
|                                             | Axes:                                 |
|                                             |  P (Popularity)  [██████████ 0.95]   |
|                                             |  E (Potential)   [████████  0.95]    |
|                                             |  U (Uncertainty) [███████   0.90]    |
|                                             |  C (Centrality)  [███████   0.90]    |
|                                             |  S (Staleness)   [███████   0.90]    |
|                                             |                                      |
|                                             | Uncertainty flags (4):               |
|                                             |  • version_range                      |
|                                             |  • missing_vector                     |
|                                             |  • conflicting_feeds                  |
|                                             |  • no_provenance_anchor               |
|                                             |                                      |
|                                             | Evidence:                            |
|                                             |  • Advisories: NVD, VendorX, OSSIndex|
|                                             |  • Evidence age: 19 days             |
|                                             |  • EvidenceSetHash: <click to copy>  |
|                                             |  • GraphSliceHash: <click to copy>   |
|                                             |                                      |
|                                             | Scheduler:                           |
|                                             |  • Next action: Immediate rescan     |
|                                             |  • Last rescan: 2025-12-02 14:03 UTC |
|                                             |  • Previous band: WARM (0.68)        |
+---------------------------------------------+---------------------------------------+

4.2 Left Pane Unknowns List

Columns:

  1. Score

    • Numeric (2 decimals).
    • Coloured bar background by band (without hiding number).
  2. Band (HOT/WARM/COLD)

    • Chip style (red/amber/grey).
  3. Component

    • name version (e.g. openssl 1.1.1k).
    • Tooltip: component_type, package manager, distro.
  4. Deployments

    • Number of workloads/images using this component.
  5. Centrality

    • Single combined metric or icon-coded:

      • Hub (C ≥ 0.7), Medium (0.30.7), Leaf (≤ 0.3).
  6. Uncertainty

    • n flags
    • Hover shows list of flags.
  7. Next Action

    • Text: Rescan, VEX, Batch, None.
    • Optional “Run now” button (if operator wants manual override).

Filters:

  • Environment (Prod / Non-prod / All)

  • Image / Artifact

  • Band (multi-select)

  • Component type (OS pkg / library / container layer / unknown)

  • Quick toggles:

    • “Hot only”
    • “Shared components”
    • “Stale evidence (S ≥ 0.7)”

Search:

  • Free text on component name, version, flags.

4.3 Right Pane Inspector Tabs

  1. Summary tab

    • Component identity
    • Band + Score
    • Short human-readable reason: generated from top 2 axes + key flags, e.g.: “High use across 134 workloads and critical CVSS; several conflicting advisories.”
  2. Axes tab

    • Horizontal bar chart for P/E/U/C/S (01).
    • Each bar labelled and clickable (shows short explanation tooltip: “P: normalized by deployment count; current value driven by 134 workloads”).
  3. Uncertainty tab

    • Full list of UnknownFlags as discrete chips with explanations:

      • version_range “Vendor advisory uses <= version ranges; exact boundary unknown.”
      • missing_vector “CVSS vector missing; consequence estimated from CWE.”
    • Each flag links to the raw advisory snippet in “Evidence”.

  4. Evidence tab

    • Data:

      • EvidenceSetHash
      • GraphSliceHash
      • NormalizationTrace (rendered in readable diff-style, e.g. “deb:2.31-0ubuntu9.2 → normalized to 2.31.0 with epoch 0”)
    • Advisories list:

      • NVD ID, Vendor ID, OSS index entries
      • Severity and CVSS per source
      • Last fetch times
  5. Scheduler tab (optional or sub-section)

    • Current band and Score
    • Last scoring time
    • Next scheduled job (rescan / VEX / batch) and ETA
    • History mini-chart (Score over time for this Unknown)

If you tell me where in the current Stella Ops repo tree you want to drop these (e.g. src/Services/Scanner/StellaOps.Scanner.Domain/Unknowns/ and src/Web/StellaOps.Web/src/app/unknowns/), I can adapt namespaces/paths and also sketch the Angular components/interfaces to match this backend model.