new advisories work and features gaps work

This commit is contained in:
master
2026-01-14 18:39:19 +02:00
parent 95d5898650
commit 15aeac8e8b
148 changed files with 16731 additions and 554 deletions

View File

@@ -0,0 +1,99 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260112_004_LB_attested_reduction_scoring (EWS-ATT-001)
// Description: Anchor metadata for attested evidence inputs
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Anchor metadata for cryptographically attested evidence.
/// Provides provenance information for VEX, patch proof, reachability, and telemetry inputs.
/// </summary>
public sealed record AnchorMetadata
{
/// <summary>
/// Whether this evidence is cryptographically anchored (has valid attestation).
/// </summary>
public required bool IsAnchored { get; init; }
/// <summary>
/// DSSE envelope digest (SHA-256) if evidence is signed.
/// Format: "sha256:&lt;hex&gt;"
/// </summary>
public string? DsseEnvelopeDigest { get; init; }
/// <summary>
/// Predicate type from the attestation (e.g., "https://stellaops.io/attestation/vex-override/v1").
/// </summary>
public string? PredicateType { get; init; }
/// <summary>
/// Rekor transparency log index (if recorded).
/// </summary>
public long? RekorLogIndex { get; init; }
/// <summary>
/// Rekor entry UUID (if recorded).
/// </summary>
public string? RekorEntryId { get; init; }
/// <summary>
/// RFC 3161 timestamp token digest (if timestamped).
/// </summary>
public string? TimestampTokenDigest { get; init; }
/// <summary>
/// Key ID used for signing (if known).
/// </summary>
public string? SigningKeyId { get; init; }
/// <summary>
/// When the attestation was created (UTC).
/// </summary>
public DateTimeOffset? AttestationTimestamp { get; init; }
/// <summary>
/// Attestation verification status.
/// </summary>
public AnchorVerificationStatus VerificationStatus { get; init; } = AnchorVerificationStatus.Unverified;
/// <summary>
/// Creates an unanchored metadata instance.
/// </summary>
public static AnchorMetadata Unanchored => new() { IsAnchored = false };
/// <summary>
/// Creates an anchored metadata instance with basic info.
/// </summary>
public static AnchorMetadata CreateAnchored(
string dsseDigest,
string predicateType,
long? rekorLogIndex = null,
string? rekorEntryId = null) => new()
{
IsAnchored = true,
DsseEnvelopeDigest = dsseDigest,
PredicateType = predicateType,
RekorLogIndex = rekorLogIndex,
RekorEntryId = rekorEntryId,
VerificationStatus = AnchorVerificationStatus.Verified
};
}
/// <summary>
/// Verification status for anchor metadata.
/// </summary>
public enum AnchorVerificationStatus
{
/// <summary>Anchor has not been verified.</summary>
Unverified = 0,
/// <summary>Anchor signature and/or inclusion proof verified successfully.</summary>
Verified = 1,
/// <summary>Anchor verification failed (invalid signature, missing proof, etc.).</summary>
Failed = 2,
/// <summary>Anchor verification was skipped (offline mode, policy decision, etc.).</summary>
Skipped = 3
}

View File

@@ -80,6 +80,12 @@ public sealed record BackportInput
/// <summary>Distribution/vendor that issued the backport.</summary>
public string? Distributor { get; init; }
/// <summary>
/// Anchor metadata for cryptographically attested backport/patch proof.
/// Used by attested-reduction scoring profile to determine precedence.
/// </summary>
public AnchorMetadata? Anchor { get; init; }
/// <summary>
/// Validates the backport input.
/// </summary>

View File

@@ -166,6 +166,83 @@ public sealed record SpeculativeCapConfig
public static SpeculativeCapConfig Default => new();
}
/// <summary>
/// Attested-reduction scoring configuration.
/// Sprint: SPRINT_20260112_004_LB_attested_reduction_scoring (EWS-ATT-002)
/// </summary>
public sealed record AttestedReductionConfig
{
/// <summary>Whether attested-reduction scoring is enabled.</summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Precedence list for anchored evidence types (higher index = higher priority).
/// Default order: VEX not_affected/fixed > anchored backport > anchored reachability.
/// </summary>
public IReadOnlyList<string> PrecedenceList { get; init; } = [
"vex.not_affected",
"vex.fixed",
"backport.signed_proof",
"backport.vendor_vex",
"reachability.not_reachable",
"runtime.not_observed"
];
/// <summary>
/// Reachability bonus (R) for EPSS reduction formula.
/// Applied when anchored reachability evidence shows not-reachable.
/// </summary>
public double ReachabilityBonus { get; init; } = 0.3;
/// <summary>
/// Telemetry bonus (T) for EPSS reduction formula.
/// Applied when anchored runtime evidence shows no observation.
/// </summary>
public double TelemetryBonus { get; init; } = 0.2;
/// <summary>
/// Patch proof reduction (P) for EPSS reduction formula.
/// Applied when anchored backport evidence confirms patch.
/// </summary>
public double PatchProofReduction { get; init; } = 0.5;
/// <summary>
/// Minimum score for clamp operation.
/// </summary>
public double ClampMin { get; init; } = 0.0;
/// <summary>
/// Maximum score for clamp operation.
/// </summary>
public double ClampMax { get; init; } = 1.0;
/// <summary>
/// Hard-fail when anchored affected + runtime telemetry confirms active use.
/// </summary>
public bool HardFailOnAffectedWithRuntime { get; init; } = true;
/// <summary>
/// Hard-fail score (typically 1.0 = maximum severity).
/// </summary>
public double HardFailScore { get; init; } = 1.0;
/// <summary>
/// Skip EPSS (XPL) dimension when stronger anchored evidence exists.
/// </summary>
public bool SkipEpssWhenAnchored { get; init; } = true;
/// <summary>
/// Minimum anchor verification status required for precedence.
/// </summary>
public AnchorVerificationStatus RequiredVerificationStatus { get; init; } = AnchorVerificationStatus.Verified;
/// <summary>Default configuration (disabled).</summary>
public static AttestedReductionConfig Default => new();
/// <summary>Enabled configuration with default values.</summary>
public static AttestedReductionConfig EnabledDefault => new() { Enabled = true };
}
/// <summary>
/// Score bucket threshold configuration.
/// </summary>
@@ -204,6 +281,9 @@ public sealed record EvidenceWeightPolicy
/// <summary>Bucket thresholds.</summary>
public BucketThresholds Buckets { get; init; } = BucketThresholds.Default;
/// <summary>Attested-reduction scoring configuration.</summary>
public AttestedReductionConfig AttestedReduction { get; init; } = AttestedReductionConfig.Default;
/// <summary>Optional tenant ID for multi-tenant scenarios.</summary>
public string? TenantId { get; init; }
@@ -285,6 +365,19 @@ public sealed record EvidenceWeightPolicy
act_now_min = Buckets.ActNowMin,
schedule_next_min = Buckets.ScheduleNextMin,
investigate_min = Buckets.InvestigateMin
},
attested_reduction = new
{
enabled = AttestedReduction.Enabled,
precedence_list = AttestedReduction.PrecedenceList,
reachability_bonus = AttestedReduction.ReachabilityBonus,
telemetry_bonus = AttestedReduction.TelemetryBonus,
patch_proof_reduction = AttestedReduction.PatchProofReduction,
clamp_min = AttestedReduction.ClampMin,
clamp_max = AttestedReduction.ClampMax,
hard_fail_on_affected_with_runtime = AttestedReduction.HardFailOnAffectedWithRuntime,
hard_fail_score = AttestedReduction.HardFailScore,
skip_epss_when_anchored = AttestedReduction.SkipEpssWhenAnchored
}
};

View File

@@ -161,6 +161,20 @@ public sealed class EvidenceWeightedScoreCalculator : IEvidenceWeightedScoreCalc
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(policy);
// Check if attested-reduction scoring is enabled
if (policy.AttestedReduction.Enabled)
{
return CalculateAttestedReduction(input, policy);
}
return CalculateStandard(input, policy);
}
/// <summary>
/// Standard EWS calculation path.
/// </summary>
private EvidenceWeightedScoreResult CalculateStandard(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy)
{
// Clamp input values to ensure they're in valid range
var clampedInput = input.Clamp();
var weights = policy.Weights;
@@ -214,6 +228,188 @@ public sealed class EvidenceWeightedScoreCalculator : IEvidenceWeightedScoreCalc
};
}
/// <summary>
/// Attested-reduction scoring path.
/// Sprint: SPRINT_20260112_004_LB_attested_reduction_scoring (EWS-ATT-003)
/// Formula: score = clamp(base_epss * (1 + R + T) - P, 0, 1)
/// Short-circuits:
/// - Anchored VEX not_affected/fixed -> score 0
/// - Anchored affected + runtime telemetry -> hard fail (score 1.0)
/// </summary>
private EvidenceWeightedScoreResult CalculateAttestedReduction(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy)
{
var clampedInput = input.Clamp();
var weights = policy.Weights;
var config = policy.AttestedReduction;
var flags = new List<string>();
var explanations = new List<string>();
// Check for anchored VEX evidence
var hasAnchoredVex = IsAnchoredWithStatus(input.VexAnchor, config.RequiredVerificationStatus);
var hasAnchoredBackport = IsAnchoredWithStatus(input.BackportDetails?.Anchor, config.RequiredVerificationStatus);
var hasAnchoredReachability = IsAnchoredWithStatus(input.ReachabilityDetails?.Anchor, config.RequiredVerificationStatus);
var hasAnchoredRuntime = IsAnchoredWithStatus(input.RuntimeDetails?.Anchor, config.RequiredVerificationStatus);
// Short-circuit 1: Anchored VEX not_affected or fixed -> score 0
if (hasAnchoredVex &&
(string.Equals(input.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase) ||
string.Equals(input.VexStatus, "fixed", StringComparison.OrdinalIgnoreCase)))
{
flags.Add("anchored-vex");
flags.Add("vendor-na");
explanations.Add($"Anchored VEX statement: {input.VexStatus} - score reduced to 0");
return CreateAttestedResult(input, policy, clampedInput, weights,
score: 0,
bucket: ScoreBucket.Watchlist,
flags: flags,
explanations: explanations,
attestedReductionApplied: true,
hardFailApplied: false);
}
// Short-circuit 2: Anchored affected + runtime confirmed -> hard fail
if (config.HardFailOnAffectedWithRuntime &&
hasAnchoredVex &&
string.Equals(input.VexStatus, "affected", StringComparison.OrdinalIgnoreCase) &&
hasAnchoredRuntime &&
input.RuntimeDetails?.DirectPathObserved == true)
{
flags.Add("anchored-vex");
flags.Add("anchored-runtime");
flags.Add("hard-fail");
explanations.Add("Anchored VEX affected + runtime confirmed vulnerable path - hard fail");
var hardFailScore = (int)Math.Round(config.HardFailScore * 100);
return CreateAttestedResult(input, policy, clampedInput, weights,
score: hardFailScore,
bucket: ScoreBucket.ActNow,
flags: flags,
explanations: explanations,
attestedReductionApplied: true,
hardFailApplied: true);
}
// Calculate reduction formula: score = clamp(base_epss * (1 + R + T) - P, min, max)
var baseEpss = clampedInput.Xpl;
var reachabilityBonus = 0.0;
var telemetryBonus = 0.0;
var patchReduction = 0.0;
// Apply reachability bonus if anchored not-reachable
if (hasAnchoredReachability &&
input.ReachabilityDetails?.State == ReachabilityState.NotReachable)
{
reachabilityBonus = config.ReachabilityBonus;
flags.Add("anchored-reachability");
explanations.Add($"Anchored reachability: not reachable - R bonus {reachabilityBonus:P0}");
}
// Apply telemetry bonus if anchored no-observation
if (hasAnchoredRuntime &&
(input.RuntimeDetails?.Posture == RuntimePosture.None ||
input.RuntimeDetails?.ObservationCount == 0))
{
telemetryBonus = config.TelemetryBonus;
flags.Add("anchored-runtime");
explanations.Add($"Anchored runtime: no observations - T bonus {telemetryBonus:P0}");
}
// Apply patch proof reduction if anchored backport
if (hasAnchoredBackport &&
(input.BackportDetails?.Status == BackportStatus.Fixed ||
input.BackportDetails?.Status == BackportStatus.NotAffected))
{
patchReduction = config.PatchProofReduction;
flags.Add("anchored-backport");
explanations.Add($"Anchored backport: {input.BackportDetails.Status} - P reduction {patchReduction:P0}");
}
// Apply EPSS-skip behavior
var effectiveEpss = baseEpss;
if (config.SkipEpssWhenAnchored && (hasAnchoredBackport || hasAnchoredReachability))
{
// Reduce EPSS influence when stronger anchored evidence exists
effectiveEpss *= 0.5;
flags.Add("epss-reduced");
explanations.Add("EPSS influence reduced due to anchored evidence");
}
// Compute final score using reduction formula
var rawReduction = effectiveEpss * (1.0 + reachabilityBonus + telemetryBonus) - patchReduction;
var clampedScore = Math.Clamp(rawReduction, config.ClampMin, config.ClampMax);
var scaledScore = (int)Math.Round(clampedScore * 100);
// Apply standard guardrails on top
var (finalScore, guardrails) = ApplyGuardrails(scaledScore, clampedInput, policy.Guardrails);
// Generate standard flags on top
var standardFlags = GenerateFlags(clampedInput, guardrails);
foreach (var flag in standardFlags)
{
if (!flags.Contains(flag))
flags.Add(flag);
}
// Determine bucket
var bucket = GetBucket(finalScore, policy.Buckets);
return CreateAttestedResult(input, policy, clampedInput, weights,
score: finalScore,
bucket: bucket,
flags: flags,
explanations: explanations,
attestedReductionApplied: true,
hardFailApplied: false,
guardrails: guardrails);
}
private static bool IsAnchoredWithStatus(AnchorMetadata? anchor, AnchorVerificationStatus requiredStatus)
{
if (anchor is null || !anchor.IsAnchored)
return false;
return anchor.VerificationStatus >= requiredStatus;
}
private EvidenceWeightedScoreResult CreateAttestedResult(
EvidenceWeightedScoreInput input,
EvidenceWeightPolicy policy,
EvidenceWeightedScoreInput clampedInput,
EvidenceWeights weights,
int score,
ScoreBucket bucket,
List<string> flags,
List<string> explanations,
bool attestedReductionApplied,
bool hardFailApplied,
AppliedGuardrails? guardrails = null)
{
var breakdown = CalculateBreakdown(clampedInput, weights);
if (attestedReductionApplied)
flags.Add("attested-reduction");
if (hardFailApplied)
flags.Add("hard-fail");
return new EvidenceWeightedScoreResult
{
FindingId = input.FindingId,
Score = score,
Bucket = bucket,
Inputs = new EvidenceInputValues(
clampedInput.Rch, clampedInput.Rts, clampedInput.Bkp,
clampedInput.Xpl, clampedInput.Src, clampedInput.Mit),
Weights = weights,
Breakdown = breakdown,
Flags = flags,
Explanations = explanations,
Caps = guardrails ?? AppliedGuardrails.None(score),
PolicyDigest = policy.ComputeDigest(),
CalculatedAt = _timeProvider.GetUtcNow()
};
}
private static (int finalScore, AppliedGuardrails guardrails) ApplyGuardrails(
int score,
EvidenceWeightedScoreInput input,

View File

@@ -33,6 +33,12 @@ public sealed record EvidenceWeightedScoreInput
/// <summary>VEX status for backport guardrail evaluation (e.g., "not_affected", "affected", "fixed").</summary>
public string? VexStatus { get; init; }
/// <summary>
/// Anchor metadata for the primary VEX/advisory evidence.
/// Used by attested-reduction scoring profile for precedence determination.
/// </summary>
public AnchorMetadata? VexAnchor { get; init; }
/// <summary>Detailed inputs for explanation generation (reachability).</summary>
public ReachabilityInput? ReachabilityDetails { get; init; }

View File

@@ -59,6 +59,12 @@ public sealed record ReachabilityInput
/// <summary>Evidence timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EvidenceTimestamp { get; init; }
/// <summary>
/// Anchor metadata for cryptographically attested reachability evidence.
/// Used by attested-reduction scoring profile to determine precedence.
/// </summary>
public AnchorMetadata? Anchor { get; init; }
/// <summary>
/// Validates the reachability input.
/// </summary>

View File

@@ -56,6 +56,12 @@ public sealed record RuntimeInput
/// <summary>Correlation ID linking to runtime evidence.</summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Anchor metadata for cryptographically attested runtime telemetry.
/// Used by attested-reduction scoring profile to determine precedence.
/// </summary>
public AnchorMetadata? Anchor { get; init; }
/// <summary>
/// Validates the runtime input.
/// </summary>

View File

@@ -65,6 +65,12 @@ public sealed record SourceTrustInput
/// <summary>Number of corroborating sources.</summary>
public int CorroboratingSourceCount { get; init; }
/// <summary>
/// Anchor metadata for cryptographically attested VEX/advisory evidence.
/// Used by attested-reduction scoring profile to determine precedence.
/// </summary>
public AnchorMetadata? Anchor { get; init; }
/// <summary>
/// Validates the source trust input.
/// </summary>

View File

@@ -0,0 +1,330 @@
// <copyright file="RuntimeUpdatedEvent.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_008_SIGNALS_runtime_telemetry_events (SIG-RUN-001)
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// Event emitted when runtime observations change for a CVE and product pair.
/// Used to drive policy reanalysis of unknowns.
/// </summary>
public sealed record RuntimeUpdatedEvent
{
/// <summary>
/// Unique event identifier (deterministic based on content).
/// </summary>
[JsonPropertyName("eventId")]
public required string EventId { get; init; }
/// <summary>
/// Event type constant.
/// </summary>
[JsonPropertyName("eventType")]
public string EventType { get; init; } = RuntimeEventTypes.Updated;
/// <summary>
/// Event version for schema compatibility.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; init; } = "1.0.0";
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant")]
public required string Tenant { get; init; }
/// <summary>
/// CVE identifier affected by this update.
/// </summary>
[JsonPropertyName("cveId")]
public string? CveId { get; init; }
/// <summary>
/// Product PURL affected by this update.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Subject key (canonical identifier for this CVE+product pair).
/// </summary>
[JsonPropertyName("subjectKey")]
public required string SubjectKey { get; init; }
/// <summary>
/// Callgraph ID associated with this update.
/// </summary>
[JsonPropertyName("callgraphId")]
public string? CallgraphId { get; init; }
/// <summary>
/// SHA-256 digest of the runtime evidence that triggered this update.
/// </summary>
[JsonPropertyName("evidenceDigest")]
public required string EvidenceDigest { get; init; }
/// <summary>
/// Type of runtime update.
/// </summary>
[JsonPropertyName("updateType")]
public required RuntimeUpdateType UpdateType { get; init; }
/// <summary>
/// Previous reachability state (null for new observations).
/// </summary>
[JsonPropertyName("previousState")]
public string? PreviousState { get; init; }
/// <summary>
/// New reachability state.
/// </summary>
[JsonPropertyName("newState")]
public required string NewState { get; init; }
/// <summary>
/// Confidence score for the new state (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Whether this update is from runtime observation (vs static analysis).
/// </summary>
[JsonPropertyName("fromRuntime")]
public required bool FromRuntime { get; init; }
/// <summary>
/// Runtime observation method (e.g., "ebpf", "agent", "probe").
/// </summary>
[JsonPropertyName("runtimeMethod")]
public string? RuntimeMethod { get; init; }
/// <summary>
/// Node hashes observed at runtime.
/// </summary>
[JsonPropertyName("observedNodeHashes")]
public ImmutableArray<string> ObservedNodeHashes { get; init; } = [];
/// <summary>
/// Path hash for the observed call path.
/// </summary>
[JsonPropertyName("pathHash")]
public string? PathHash { get; init; }
/// <summary>
/// Whether this update should trigger policy reanalysis.
/// </summary>
[JsonPropertyName("triggerReanalysis")]
public required bool TriggerReanalysis { get; init; }
/// <summary>
/// Reason for reanalysis (if triggered).
/// </summary>
[JsonPropertyName("reanalysisReason")]
public string? ReanalysisReason { get; init; }
/// <summary>
/// UTC timestamp when this event occurred.
/// </summary>
[JsonPropertyName("occurredAtUtc")]
public required DateTimeOffset OccurredAtUtc { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("traceId")]
public string? TraceId { get; init; }
}
/// <summary>
/// Type of runtime update.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RuntimeUpdateType
{
/// <summary>New runtime observation.</summary>
NewObservation,
/// <summary>State change from previous observation.</summary>
StateChange,
/// <summary>Confidence increase from new evidence.</summary>
ConfidenceIncrease,
/// <summary>New call path observed.</summary>
NewCallPath,
/// <summary>Exploit telemetry detected.</summary>
ExploitTelemetry
}
/// <summary>
/// Well-known runtime event types.
/// </summary>
public static class RuntimeEventTypes
{
/// <summary>
/// Runtime observations updated for a subject.
/// </summary>
public const string Updated = "runtime.updated";
/// <summary>
/// Versioned event type alias for routing.
/// </summary>
public const string UpdatedV1 = "runtime.updated@1";
/// <summary>
/// New runtime observation ingested.
/// </summary>
public const string Ingested = "runtime.ingested";
/// <summary>
/// Runtime fact confirmed by new evidence.
/// </summary>
public const string Confirmed = "runtime.confirmed";
/// <summary>
/// Exploit behavior detected at runtime.
/// </summary>
public const string ExploitDetected = "runtime.exploit_detected";
}
/// <summary>
/// Factory for creating deterministic runtime updated events.
/// </summary>
public static class RuntimeUpdatedEventFactory
{
/// <summary>
/// Creates a runtime updated event with deterministic event ID.
/// </summary>
public static RuntimeUpdatedEvent Create(
string tenant,
string subjectKey,
string evidenceDigest,
RuntimeUpdateType updateType,
string newState,
double confidence,
bool fromRuntime,
DateTimeOffset occurredAtUtc,
string? cveId = null,
string? purl = null,
string? callgraphId = null,
string? previousState = null,
string? runtimeMethod = null,
IReadOnlyList<string>? observedNodeHashes = null,
string? pathHash = null,
string? traceId = null)
{
// Determine if reanalysis is needed
var triggerReanalysis = ShouldTriggerReanalysis(updateType, previousState, newState, confidence, fromRuntime);
var reanalysisReason = triggerReanalysis
? DetermineReanalysisReason(updateType, previousState, newState, fromRuntime)
: null;
var eventId = ComputeEventId(
subjectKey,
evidenceDigest,
occurredAtUtc);
return new RuntimeUpdatedEvent
{
EventId = eventId,
Tenant = tenant,
CveId = cveId,
Purl = purl,
SubjectKey = subjectKey,
CallgraphId = callgraphId,
EvidenceDigest = evidenceDigest,
UpdateType = updateType,
PreviousState = previousState,
NewState = newState,
Confidence = confidence,
FromRuntime = fromRuntime,
RuntimeMethod = runtimeMethod,
ObservedNodeHashes = observedNodeHashes?.ToImmutableArray() ?? [],
PathHash = pathHash,
TriggerReanalysis = triggerReanalysis,
ReanalysisReason = reanalysisReason,
OccurredAtUtc = occurredAtUtc,
TraceId = traceId
};
}
private static bool ShouldTriggerReanalysis(
RuntimeUpdateType updateType,
string? previousState,
string newState,
double confidence,
bool fromRuntime)
{
// Always trigger for exploit telemetry
if (updateType == RuntimeUpdateType.ExploitTelemetry)
{
return true;
}
// Trigger for state changes
if (previousState is not null && !string.Equals(previousState, newState, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Trigger for high-confidence runtime observations
if (fromRuntime && confidence >= 0.9)
{
return true;
}
// Trigger for new call paths
if (updateType == RuntimeUpdateType.NewCallPath)
{
return true;
}
return false;
}
private static string DetermineReanalysisReason(
RuntimeUpdateType updateType,
string? previousState,
string newState,
bool fromRuntime)
{
if (updateType == RuntimeUpdateType.ExploitTelemetry)
{
return "exploit_telemetry_detected";
}
if (previousState is not null && !string.Equals(previousState, newState, StringComparison.OrdinalIgnoreCase))
{
return $"state_change_{previousState}_to_{newState}";
}
if (fromRuntime)
{
return "high_confidence_runtime_observation";
}
if (updateType == RuntimeUpdateType.NewCallPath)
{
return "new_call_path_observed";
}
return "unknown";
}
private static string ComputeEventId(string subjectKey, string evidenceDigest, DateTimeOffset occurredAtUtc)
{
var input = $"{subjectKey}|{evidenceDigest}|{occurredAtUtc.ToString("O", CultureInfo.InvariantCulture)}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"runtime-evt-{Convert.ToHexStringLower(hash)[..16]}";
}
}

View File

@@ -74,6 +74,34 @@ public sealed record RuntimeCallEvent
/// UTC timestamp when this event was received by the collector.
/// </summary>
public DateTimeOffset ReceivedAt { get; init; } = DateTimeOffset.UtcNow;
// --- Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-001) ---
/// <summary>
/// Fully qualified function signature (namespace.type.method(params)).
/// </summary>
public string? FunctionSignature { get; init; }
/// <summary>
/// SHA256 digest of the binary containing this function.
/// </summary>
public string? BinaryDigest { get; init; }
/// <summary>
/// Offset within the binary where the function is located.
/// </summary>
public ulong? BinaryOffset { get; init; }
/// <summary>
/// Canonical node hash (sha256:hex) for static/runtime evidence joining.
/// Computed using NodeHashRecipe from PURL + FunctionSignature.
/// </summary>
public string? NodeHash { get; init; }
/// <summary>
/// SHA256 hash of the callstack for deterministic aggregation.
/// </summary>
public string? CallstackHash { get; init; }
}
/// <summary>
@@ -141,6 +169,38 @@ public sealed record ObservedCallPath
/// Last observation timestamp.
/// </summary>
public DateTimeOffset LastObservedAt { get; init; }
// --- Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-001) ---
/// <summary>
/// Canonical node hashes for each symbol in the path (deterministic order).
/// </summary>
public IReadOnlyList<string>? NodeHashes { get; init; }
/// <summary>
/// Canonical path hash (sha256:hex) computed from ordered node hashes.
/// </summary>
public string? PathHash { get; init; }
/// <summary>
/// Callstack hash for efficient deduplication.
/// </summary>
public string? CallstackHash { get; init; }
/// <summary>
/// Function signatures for each symbol in the path.
/// </summary>
public IReadOnlyList<string>? FunctionSignatures { get; init; }
/// <summary>
/// Binary digests for each symbol in the path (null if not resolvable).
/// </summary>
public IReadOnlyList<string?>? BinaryDigests { get; init; }
/// <summary>
/// Binary offsets for each symbol in the path (null if not resolvable).
/// </summary>
public IReadOnlyList<ulong?>? BinaryOffsets { get; init; }
}
/// <summary>
@@ -187,6 +247,23 @@ public sealed record RuntimeSignalSummary
/// Runtime types detected in this container.
/// </summary>
public IReadOnlyList<RuntimeType> DetectedRuntimes { get; init; } = [];
// --- Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-004) ---
/// <summary>
/// Unique node hashes observed in this summary (deterministic sorted order).
/// </summary>
public IReadOnlyList<string>? ObservedNodeHashes { get; init; }
/// <summary>
/// Unique path hashes for all observed call paths (deterministic sorted order).
/// </summary>
public IReadOnlyList<string>? ObservedPathHashes { get; init; }
/// <summary>
/// Combined hash of all observed paths for summary-level identity.
/// </summary>
public string? CombinedPathHash { get; init; }
}
/// <summary>

View File

@@ -6,7 +6,10 @@ namespace StellaOps.Signals.Ebpf.Services;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Reachability.Core;
using StellaOps.Signals.Ebpf.Probes;
using StellaOps.Signals.Ebpf.Schema;
@@ -142,6 +145,18 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
var observedSymbols = ExtractUniqueSymbols(session.Events);
var detectedRuntimes = DetectRuntimes(session.Events);
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-004)
var observedNodeHashes = ExtractUniqueNodeHashes(session.Events);
var observedPathHashes = callPaths
.Where(p => p.PathHash is not null)
.Select(p => p.PathHash!)
.Distinct()
.Order(StringComparer.Ordinal)
.ToList();
var combinedPathHash = observedPathHashes.Count > 0
? PathHashRecipe.ComputeCombinedHash(observedPathHashes)
: null;
session.ProcessingCts.Dispose();
_logger.LogInformation(
@@ -160,6 +175,10 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
ObservedSymbols = observedSymbols,
DroppedEvents = session.DroppedEvents,
DetectedRuntimes = detectedRuntimes,
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-004)
ObservedNodeHashes = observedNodeHashes,
ObservedPathHashes = observedPathHashes,
CombinedPathHash = combinedPathHash,
};
}
@@ -339,13 +358,59 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
Library = library,
Purl = purl,
ReceivedAt = DateTimeOffset.UtcNow,
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-002)
FunctionSignature = symbol,
NodeHash = ComputeNodeHash(purl, symbol),
CallstackHash = ComputeCallstackHash(stackTrace),
};
}
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-002)
private static string? ComputeNodeHash(string? purl, string? symbol)
{
if (string.IsNullOrEmpty(purl) || string.IsNullOrEmpty(symbol))
{
return null;
}
try
{
return NodeHashRecipe.ComputeHash(purl, symbol);
}
catch
{
return null;
}
}
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-002)
private static string ComputeCallstackHash(IReadOnlyList<ulong> stackTrace)
{
// Hash the callstack addresses for deduplication (privacy-safe: no raw addresses in output)
var sb = new StringBuilder();
foreach (var addr in stackTrace)
{
sb.Append(addr.ToString("X16"));
sb.Append(':');
}
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
return "sha256:" + Convert.ToHexStringLower(hashBytes);
}
private static IReadOnlyList<ObservedCallPath> AggregateCallPaths(
ConcurrentQueue<RuntimeCallEvent> events)
{
var pathCounts = new Dictionary<string, (List<string> Symbols, int Count, string? Purl, RuntimeType Runtime, DateTimeOffset First, DateTimeOffset Last)>();
var pathCounts = new Dictionary<string, (
List<string> Symbols,
List<string?> NodeHashes,
List<string?> FunctionSigs,
int Count,
string? Purl,
RuntimeType Runtime,
DateTimeOffset First,
DateTimeOffset Last,
string? CallstackHash)>();
foreach (var evt in events)
{
@@ -366,29 +431,59 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
{
pathCounts[pathKey] = (
existing.Symbols,
existing.NodeHashes,
existing.FunctionSigs,
existing.Count + 1,
existing.Purl ?? evt.Purl,
existing.Runtime,
existing.First,
evt.ReceivedAt);
evt.ReceivedAt,
existing.CallstackHash ?? evt.CallstackHash);
}
else
{
pathCounts[pathKey] = (symbols, 1, evt.Purl, evt.RuntimeType, evt.ReceivedAt, evt.ReceivedAt);
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-002)
// Compute node hashes for the path (only first symbol has real hash currently)
var nodeHashes = new List<string?> { evt.NodeHash };
var funcSigs = new List<string?> { evt.FunctionSignature };
pathCounts[pathKey] = (
symbols,
nodeHashes,
funcSigs,
1,
evt.Purl,
evt.RuntimeType,
evt.ReceivedAt,
evt.ReceivedAt,
evt.CallstackHash);
}
}
return pathCounts.Values
.OrderByDescending(p => p.Count)
.Take(1000) // Limit to top 1000 paths
.Select(p => new ObservedCallPath
.Select(p =>
{
Symbols = p.Symbols,
ObservationCount = p.Count,
Purl = p.Purl,
RuntimeType = p.Runtime,
FirstObservedAt = p.First,
LastObservedAt = p.Last,
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-002)
// Compute path hash from node hashes
var validNodeHashes = p.NodeHashes.Where(h => h is not null).Cast<string>().ToList();
var pathHash = validNodeHashes.Count > 0 ? PathHashRecipe.ComputeHash(validNodeHashes) : null;
return new ObservedCallPath
{
Symbols = p.Symbols,
ObservationCount = p.Count,
Purl = p.Purl,
RuntimeType = p.Runtime,
FirstObservedAt = p.First,
LastObservedAt = p.Last,
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-002)
NodeHashes = p.NodeHashes.Where(h => h is not null).Cast<string>().ToList(),
PathHash = pathHash,
CallstackHash = p.CallstackHash,
FunctionSignatures = p.FunctionSigs.Where(s => s is not null).Cast<string>().ToList(),
};
})
.ToList();
}
@@ -404,6 +499,18 @@ public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposabl
.ToList();
}
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-004)
private static IReadOnlyList<string> ExtractUniqueNodeHashes(
ConcurrentQueue<RuntimeCallEvent> events)
{
return events
.Where(e => e.NodeHash is not null)
.Select(e => e.NodeHash!)
.Distinct()
.OrderBy(h => h, StringComparer.Ordinal)
.ToList();
}
private static IReadOnlyList<RuntimeType> DetectRuntimes(
ConcurrentQueue<RuntimeCallEvent> events)
{

View File

@@ -15,4 +15,9 @@
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<!-- Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-002) -->
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,310 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260112_004_LB_attested_reduction_scoring (EWS-ATT-005)
// Description: Tests for attested-reduction scoring path
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
[Trait("Category", "Unit")]
public sealed class AttestedReductionScoringTests
{
private readonly EvidenceWeightedScoreCalculator _calculator;
private readonly EvidenceWeightPolicy _policy;
public AttestedReductionScoringTests()
{
_calculator = new EvidenceWeightedScoreCalculator(TimeProvider.System);
_policy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "test",
Weights = EvidenceWeights.Default,
AttestedReduction = AttestedReductionConfig.EnabledDefault
};
}
[Fact]
public void Calculate_WithAttestedReductionDisabled_UsesStandardPath()
{
var policy = _policy with { AttestedReduction = AttestedReductionConfig.Default };
var input = CreateInput(xpl: 0.5);
var result = _calculator.Calculate(input, policy);
Assert.DoesNotContain("attested-reduction", result.Flags);
}
[Fact]
public void Calculate_WithAttestedReductionEnabled_UsesAttestedPath()
{
var input = CreateInput(xpl: 0.5);
var result = _calculator.Calculate(input, _policy);
Assert.Contains("attested-reduction", result.Flags);
}
[Fact]
public void Calculate_AnchoredVexNotAffected_ReturnsZeroScore()
{
var input = CreateInput(
xpl: 0.8,
vexStatus: "not_affected",
vexAnchor: AnchorMetadata.CreateAnchored("sha256:abc", "vex/v1"));
var result = _calculator.Calculate(input, _policy);
Assert.Equal(0, result.Score);
Assert.Equal(ScoreBucket.Watchlist, result.Bucket);
Assert.Contains("anchored-vex", result.Flags);
Assert.Contains("vendor-na", result.Flags);
}
[Fact]
public void Calculate_AnchoredVexFixed_ReturnsZeroScore()
{
var input = CreateInput(
xpl: 0.9,
vexStatus: "fixed",
vexAnchor: AnchorMetadata.CreateAnchored("sha256:def", "vex/v1"));
var result = _calculator.Calculate(input, _policy);
Assert.Equal(0, result.Score);
Assert.Contains("anchored-vex", result.Flags);
}
[Fact]
public void Calculate_AnchoredAffectedWithRuntime_HardFails()
{
var input = CreateInput(
xpl: 0.5,
vexStatus: "affected",
vexAnchor: AnchorMetadata.CreateAnchored("sha256:ghi", "vex/v1"),
runtimeDetails: new RuntimeInput
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 10,
RecencyFactor = 0.9,
DirectPathObserved = true,
Anchor = AnchorMetadata.CreateAnchored("sha256:jkl", "runtime/v1")
});
var result = _calculator.Calculate(input, _policy);
Assert.Equal(100, result.Score); // Hard fail = 1.0 * 100
Assert.Equal(ScoreBucket.ActNow, result.Bucket);
Assert.Contains("hard-fail", result.Flags);
Assert.Contains("anchored-vex", result.Flags);
Assert.Contains("anchored-runtime", result.Flags);
}
[Fact]
public void Calculate_UnanchoredVexNotAffected_DoesNotShortCircuit()
{
var input = CreateInput(
xpl: 0.5,
vexStatus: "not_affected",
vexAnchor: null); // No anchor
var result = _calculator.Calculate(input, _policy);
// Should not be 0 because VEX is not anchored
Assert.NotEqual(0, result.Score);
Assert.DoesNotContain("anchored-vex", result.Flags);
}
[Fact]
public void Calculate_AnchoredReachabilityNotReachable_AppliesBonus()
{
var input = CreateInput(
xpl: 0.5,
reachabilityDetails: new ReachabilityInput
{
State = ReachabilityState.NotReachable,
Confidence = 0.9,
Anchor = AnchorMetadata.CreateAnchored("sha256:mno", "reachability/v1")
});
var result = _calculator.Calculate(input, _policy);
Assert.Contains("anchored-reachability", result.Flags);
// Score should be affected by reachability bonus
}
[Fact]
public void Calculate_AnchoredBackportFixed_AppliesReduction()
{
var input = CreateInput(
xpl: 0.5,
backportDetails: new BackportInput
{
EvidenceTier = BackportEvidenceTier.SignedProof,
Status = BackportStatus.Fixed,
Confidence = 0.95,
Anchor = AnchorMetadata.CreateAnchored("sha256:pqr", "backport/v1")
});
var result = _calculator.Calculate(input, _policy);
Assert.Contains("anchored-backport", result.Flags);
// Score should be reduced by patch proof reduction
}
[Fact]
public void Calculate_WithAnchoredEvidence_ReducesEpssInfluence()
{
var input = CreateInput(
xpl: 0.8,
backportDetails: new BackportInput
{
EvidenceTier = BackportEvidenceTier.SignedProof,
Status = BackportStatus.NotAffected,
Confidence = 0.9,
Anchor = AnchorMetadata.CreateAnchored("sha256:stu", "backport/v1")
});
var result = _calculator.Calculate(input, _policy);
Assert.Contains("epss-reduced", result.Flags);
}
[Fact]
public void Calculate_PolicyDigest_IncludesAttestedReductionConfig()
{
var policy1 = _policy;
var policy2 = _policy with
{
AttestedReduction = _policy.AttestedReduction with { ReachabilityBonus = 0.5 }
};
var input = CreateInput(xpl: 0.5);
var result1 = _calculator.Calculate(input, policy1);
var result2 = _calculator.Calculate(input, policy2);
// Different attested-reduction config should produce different digests
Assert.NotEqual(result1.PolicyDigest, result2.PolicyDigest);
}
[Fact]
public void Calculate_AttestedReduction_IsDeterministic()
{
var input = CreateInput(
xpl: 0.5,
vexStatus: "affected",
backportDetails: new BackportInput
{
EvidenceTier = BackportEvidenceTier.VendorVex,
Status = BackportStatus.NotAffected,
Confidence = 0.8,
Anchor = AnchorMetadata.CreateAnchored("sha256:xyz", "backport/v1")
});
var result1 = _calculator.Calculate(input, _policy);
var result2 = _calculator.Calculate(input, _policy);
Assert.Equal(result1.Score, result2.Score);
Assert.Equal(result1.Bucket, result2.Bucket);
Assert.Equal(result1.PolicyDigest, result2.PolicyDigest);
}
[Fact]
public void Calculate_UnverifiedAnchor_DoesNotTriggerPrecedence()
{
var input = CreateInput(
xpl: 0.5,
vexStatus: "not_affected",
vexAnchor: new AnchorMetadata
{
IsAnchored = true,
DsseEnvelopeDigest = "sha256:abc",
PredicateType = "vex/v1",
VerificationStatus = AnchorVerificationStatus.Unverified // Not verified
});
var result = _calculator.Calculate(input, _policy);
// Should not short-circuit because anchor is unverified
Assert.NotEqual(0, result.Score);
Assert.DoesNotContain("anchored-vex", result.Flags);
}
[Fact]
public void Calculate_VerifiedAnchor_TriggersPrecedence()
{
var input = CreateInput(
xpl: 0.5,
vexStatus: "not_affected",
vexAnchor: new AnchorMetadata
{
IsAnchored = true,
DsseEnvelopeDigest = "sha256:abc",
PredicateType = "vex/v1",
VerificationStatus = AnchorVerificationStatus.Verified
});
var result = _calculator.Calculate(input, _policy);
Assert.Equal(0, result.Score);
Assert.Contains("anchored-vex", result.Flags);
}
[Theory]
[InlineData("not_affected", 0)]
[InlineData("fixed", 0)]
[InlineData("under_investigation", -1)] // -1 means not short-circuited
[InlineData("affected", -1)]
public void Calculate_VexStatusPrecedence_ReturnsExpectedScore(string vexStatus, int expectedScore)
{
var input = CreateInput(
xpl: 0.5,
vexStatus: vexStatus,
vexAnchor: AnchorMetadata.CreateAnchored("sha256:test", "vex/v1"));
var result = _calculator.Calculate(input, _policy);
if (expectedScore >= 0)
{
Assert.Equal(expectedScore, result.Score);
}
else
{
// Not short-circuited, should have some score
Assert.True(result.Score > 0 || result.Flags.Contains("hard-fail"));
}
}
private static EvidenceWeightedScoreInput CreateInput(
double xpl = 0.0,
double rch = 0.0,
double rts = 0.0,
double bkp = 0.0,
double src = 0.5,
double mit = 0.0,
string? vexStatus = null,
AnchorMetadata? vexAnchor = null,
ReachabilityInput? reachabilityDetails = null,
RuntimeInput? runtimeDetails = null,
BackportInput? backportDetails = null)
{
return new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-1234@pkg:test/lib@1.0.0",
Xpl = xpl,
Rch = rch,
Rts = rts,
Bkp = bkp,
Src = src,
Mit = mit,
VexStatus = vexStatus,
VexAnchor = vexAnchor,
ReachabilityDetails = reachabilityDetails,
RuntimeDetails = runtimeDetails,
BackportDetails = backportDetails
};
}
}