34 KiB
Here’s a simple, actionable way to keep “unknowns” from piling up in Stella Ops: rank them by how risky they might be and how widely they could spread—then let Scheduler auto‑recheck or escalate based on that score.
Unknowns Triage: a lightweight, high‑leverage scheme
Goal: decide which “Unknown” findings (no proof yet; inconclusive reachability; unparsed advisory; mismatched version; missing evidence) to re‑scan 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 0–1):
- Component popularity (P): how many distinct workloads/images depend on this package (direct + transitive). Proxy: in‑degree 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 “hub‑like” 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.70→ Hot: immediate rescan + VEX escalation job0.40–0.69→ Warm: schedule rescan within 24–48h< 0.40→ Cold: 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 Pgraph_metrics(pkg_id, degree_c, betweenness_c, last_calc_at)→ compute centrality Gadvisory_gaps(pkg_id, missing_fields[], has_range_version, vendor_mismatch)→ compute uncertainty C
Store
triage_score,triage_bandon 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) Operator‑visible UX (DevOps‑friendly)
- 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 sub‑scores) + scheduled jobs and last outcomes.
- Bulk actions: “Recompute scores”, “Force VEX escalation”, “De‑dupe 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 Stella Ops
- Scanner.WebService: writes Unknowns with raw flags (range‑version, 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 vendor‑merge proof or raises “Unresolvable” with rationale.
- Authority: records policy version + weights in replay manifest.
If you want, I can drop in a ready‑to‑use 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:
- Definitions
- Ranking dimensions
- Deterministic scoring formula
- Evidence capture
- Scheduler policies
- UX and API rules
- 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:
- Prioritize rescans
- Trigger VEX escalation
- Guide operators in constrained time windows
- Maintain deterministic behaviour under replay manifests
- 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:
-
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.
-
Version Semantics Uncertain
- Advisory reports
<=,<,>=, version ranges, or ambiguous pseudo-versions. - Normalized version mapping disagrees between data sources.
- Advisory reports
-
Component Provenance Uncertain
- Package cannot be deterministically linked to its SBOM node (name-alias confusion, epoch mismatch, distro backport case).
-
Missing/Contradictory Evidence
- Feeds disagree; Vendor VEX differs from NVD; OSS index has missing CVSS vector; environment evidence incomplete.
-
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:
- UnknownFlags[] – all uncertainty flags
- GraphSliceHash – deterministic hash of dependents/ancestors
- EvidenceSetHash – hashes of advisories, vendor VEXes, feed extracts
- NormalizationTrace – version normalization decision path
- CallGraphAttemptHash – even if incomplete
- 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.40–0.69 | Queue rescan within 12–72h (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.Unresolvableevent (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)
- Did I persist all traces needed for deterministic replay?
- Does ranking depend only on manifest-declared parameters (not environment)?
- Are all uncertainty factors explicit flags, never inferred fuzzily?
- Is the scoring reproducible under identical inputs?
- Is Scheduler decision table deterministic and exhaustively tested?
- Does API expose full reasoning without hiding rules?
If you want, I can now produce:
- A full Postgres DDL for Unknowns.
- A .NET 10 service class for ranking calculation.
- A golden test suite with 20 fixtures.
- 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:
-
Score
- Numeric (2 decimals).
- Coloured bar background by band (without hiding number).
-
Band (HOT/WARM/COLD)
- Chip style (red/amber/grey).
-
Component
name version(e.g.openssl 1.1.1k).- Tooltip: component_type, package manager, distro.
-
Deployments
- Number of workloads/images using this component.
-
Centrality
-
Single combined metric or icon-coded:
- Hub (C ≥ 0.7), Medium (0.3–0.7), Leaf (≤ 0.3).
-
-
Uncertainty
n flags- Hover shows list of flags.
-
Next Action
- Text:
Rescan,VEX,Batch,None. - Optional “Run now” button (if operator wants manual override).
- Text:
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
-
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.”
-
Axes tab
- Horizontal bar chart for P/E/U/C/S (0–1).
- Each bar labelled and clickable (shows short explanation tooltip: “P: normalized by deployment count; current value driven by 134 workloads”).
-
Uncertainty tab
-
Full list of
UnknownFlagsas 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”.
-
-
Evidence tab
-
Data:
EvidenceSetHashGraphSliceHashNormalizationTrace(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
-
-
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.