new advisories work and features gaps work
This commit is contained in:
@@ -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:<hex>"
|
||||
/// </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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
330
src/Signals/StellaOps.Signals/Models/RuntimeUpdatedEvent.cs
Normal file
330
src/Signals/StellaOps.Signals/Models/RuntimeUpdatedEvent.cs
Normal 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]}";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user