old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -235,6 +235,7 @@ builder.Services.AddSingleton<IEvidenceBundleService, EvidenceBundleService>();
|
||||
|
||||
// Evidence-Weighted Score services (SPRINT_8200.0012.0004)
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IFindingEvidenceProvider, AnchoredFindingEvidenceProvider>();
|
||||
builder.Services.AddSingleton<IFindingScoringService, FindingScoringService>();
|
||||
|
||||
// Webhook services (SPRINT_8200.0012.0004 - Wave 6)
|
||||
|
||||
@@ -411,4 +411,16 @@ public sealed record AttestationVerificationResult
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-002)
|
||||
// Extended anchor metadata fields
|
||||
|
||||
/// <summary>Rekor entry ID if transparency-anchored.</summary>
|
||||
public string? RekorEntryId { get; init; }
|
||||
|
||||
/// <summary>Predicate type of the attestation.</summary>
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
/// <summary>Scope of the attestation (e.g., finding, package, image).</summary>
|
||||
public string? Scope { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-002)
|
||||
// Task: Implement IFindingEvidenceProvider to populate anchor metadata
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IFindingEvidenceProvider that returns no evidence.
|
||||
/// Use this as a placeholder until real evidence sources are integrated.
|
||||
/// </summary>
|
||||
internal sealed class NullFindingEvidenceProvider : IFindingEvidenceProvider
|
||||
{
|
||||
public Task<FindingEvidence?> GetEvidenceAsync(string findingId, CancellationToken ct)
|
||||
=> Task.FromResult<FindingEvidence?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence provider that aggregates from multiple sources and populates anchor metadata.
|
||||
/// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-002)
|
||||
/// </summary>
|
||||
public sealed class AnchoredFindingEvidenceProvider : IFindingEvidenceProvider
|
||||
{
|
||||
private readonly IEvidenceRepository _evidenceRepository;
|
||||
private readonly IAttestationVerifier _attestationVerifier;
|
||||
private readonly ILogger<AnchoredFindingEvidenceProvider> _logger;
|
||||
|
||||
public AnchoredFindingEvidenceProvider(
|
||||
IEvidenceRepository evidenceRepository,
|
||||
IAttestationVerifier attestationVerifier,
|
||||
ILogger<AnchoredFindingEvidenceProvider> logger)
|
||||
{
|
||||
_evidenceRepository = evidenceRepository ?? throw new ArgumentNullException(nameof(evidenceRepository));
|
||||
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FindingEvidence?> GetEvidenceAsync(string findingId, CancellationToken ct)
|
||||
{
|
||||
// Parse finding ID to extract GUID if needed
|
||||
if (!TryParseGuid(findingId, out var findingGuid))
|
||||
{
|
||||
_logger.LogWarning("Could not parse finding ID {FindingId} as GUID", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get full evidence from repository
|
||||
var fullEvidence = await _evidenceRepository.GetFullEvidenceAsync(findingGuid, ct).ConfigureAwait(false);
|
||||
if (fullEvidence is null)
|
||||
{
|
||||
_logger.LogDebug("No evidence found for finding {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build anchor metadata from various evidence sources
|
||||
EvidenceAnchor? reachabilityAnchor = null;
|
||||
EvidenceAnchor? runtimeAnchor = null;
|
||||
EvidenceAnchor? vexAnchor = null;
|
||||
EvidenceAnchor? primaryAnchor = null;
|
||||
|
||||
// Check reachability attestation
|
||||
if (fullEvidence.Reachability?.AttestationDigest is not null)
|
||||
{
|
||||
var result = await _attestationVerifier.VerifyAsync(fullEvidence.Reachability.AttestationDigest, ct).ConfigureAwait(false);
|
||||
reachabilityAnchor = MapToAnchor(result, fullEvidence.Reachability.AttestationDigest);
|
||||
primaryAnchor ??= reachabilityAnchor;
|
||||
}
|
||||
|
||||
// Check runtime attestations
|
||||
var latestRuntime = fullEvidence.RuntimeObservations
|
||||
.Where(r => r.AttestationDigest is not null)
|
||||
.OrderByDescending(r => r.Timestamp)
|
||||
.FirstOrDefault();
|
||||
if (latestRuntime?.AttestationDigest is not null)
|
||||
{
|
||||
var result = await _attestationVerifier.VerifyAsync(latestRuntime.AttestationDigest, ct).ConfigureAwait(false);
|
||||
runtimeAnchor = MapToAnchor(result, latestRuntime.AttestationDigest);
|
||||
primaryAnchor ??= runtimeAnchor;
|
||||
}
|
||||
|
||||
// Check VEX attestations
|
||||
var latestVex = fullEvidence.VexStatements
|
||||
.Where(v => v.AttestationDigest is not null)
|
||||
.OrderByDescending(v => v.Timestamp)
|
||||
.FirstOrDefault();
|
||||
if (latestVex?.AttestationDigest is not null)
|
||||
{
|
||||
var result = await _attestationVerifier.VerifyAsync(latestVex.AttestationDigest, ct).ConfigureAwait(false);
|
||||
vexAnchor = MapToAnchor(result, latestVex.AttestationDigest);
|
||||
primaryAnchor ??= vexAnchor;
|
||||
}
|
||||
|
||||
// Check policy trace attestation
|
||||
if (primaryAnchor is null && fullEvidence.PolicyTrace?.AttestationDigest is not null)
|
||||
{
|
||||
var result = await _attestationVerifier.VerifyAsync(fullEvidence.PolicyTrace.AttestationDigest, ct).ConfigureAwait(false);
|
||||
primaryAnchor = MapToAnchor(result, fullEvidence.PolicyTrace.AttestationDigest);
|
||||
}
|
||||
|
||||
return new FindingEvidence
|
||||
{
|
||||
FindingId = findingId,
|
||||
Reachability = MapReachability(fullEvidence, reachabilityAnchor),
|
||||
Runtime = MapRuntime(fullEvidence, runtimeAnchor),
|
||||
Backport = null, // Backport evidence not available in FullEvidence yet
|
||||
Exploit = null, // Exploit evidence not available in FullEvidence yet
|
||||
SourceTrust = null, // Source trust not available in FullEvidence yet
|
||||
Mitigations = MapMitigations(fullEvidence),
|
||||
Anchor = primaryAnchor,
|
||||
ReachabilityAnchor = reachabilityAnchor,
|
||||
RuntimeAnchor = runtimeAnchor,
|
||||
VexAnchor = vexAnchor
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceAnchor MapToAnchor(AttestationVerificationResult result, string digest)
|
||||
{
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return new EvidenceAnchor
|
||||
{
|
||||
Anchored = false
|
||||
};
|
||||
}
|
||||
|
||||
return new EvidenceAnchor
|
||||
{
|
||||
Anchored = true,
|
||||
EnvelopeDigest = digest,
|
||||
PredicateType = result.PredicateType,
|
||||
RekorLogIndex = result.RekorLogIndex,
|
||||
RekorEntryId = result.RekorEntryId,
|
||||
Scope = result.Scope,
|
||||
Verified = result.IsValid,
|
||||
AttestedAt = result.SignedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilityInput? MapReachability(FullEvidence evidence, EvidenceAnchor? anchor)
|
||||
{
|
||||
if (evidence.Reachability is null)
|
||||
return null;
|
||||
|
||||
// Map state string to enum
|
||||
var state = evidence.Reachability.State switch
|
||||
{
|
||||
"reachable" => ReachabilityState.StaticReachable,
|
||||
"confirmed_reachable" => ReachabilityState.DynamicReachable,
|
||||
"potentially_reachable" => ReachabilityState.PotentiallyReachable,
|
||||
"not_reachable" => ReachabilityState.NotReachable,
|
||||
"unreachable" => ReachabilityState.NotReachable,
|
||||
_ => ReachabilityState.Unknown
|
||||
};
|
||||
|
||||
// Map anchor to AnchorMetadata if present
|
||||
AnchorMetadata? anchorMetadata = null;
|
||||
if (anchor?.Anchored == true)
|
||||
{
|
||||
anchorMetadata = new AnchorMetadata
|
||||
{
|
||||
IsAnchored = true,
|
||||
DsseEnvelopeDigest = anchor.EnvelopeDigest,
|
||||
PredicateType = anchor.PredicateType,
|
||||
RekorLogIndex = anchor.RekorLogIndex,
|
||||
RekorEntryId = anchor.RekorEntryId,
|
||||
AttestationTimestamp = anchor.AttestedAt,
|
||||
VerificationStatus = anchor.Verified == true ? AnchorVerificationStatus.Verified : AnchorVerificationStatus.Unverified
|
||||
};
|
||||
}
|
||||
|
||||
return new ReachabilityInput
|
||||
{
|
||||
State = state,
|
||||
Confidence = (double)evidence.Reachability.Confidence,
|
||||
HopCount = 0, // Not available in current FullEvidence
|
||||
HasInterproceduralFlow = false,
|
||||
HasTaintTracking = false,
|
||||
HasDataFlowSensitivity = false,
|
||||
EvidenceSource = evidence.Reachability.Issuer,
|
||||
EvidenceTimestamp = evidence.Reachability.Timestamp,
|
||||
Anchor = anchorMetadata
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeInput? MapRuntime(FullEvidence evidence, EvidenceAnchor? anchor)
|
||||
{
|
||||
if (evidence.RuntimeObservations.Count == 0)
|
||||
return null;
|
||||
|
||||
var latest = evidence.RuntimeObservations
|
||||
.OrderByDescending(r => r.Timestamp)
|
||||
.First();
|
||||
|
||||
// Calculate recency factor based on observation age
|
||||
var age = DateTimeOffset.UtcNow - latest.Timestamp;
|
||||
var recencyFactor = age.TotalHours <= 24 ? 1.0 :
|
||||
age.TotalDays <= 7 ? 0.7 :
|
||||
age.TotalDays <= 30 ? 0.4 : 0.1;
|
||||
|
||||
// Map anchor to AnchorMetadata if present
|
||||
AnchorMetadata? anchorMetadata = null;
|
||||
if (anchor?.Anchored == true)
|
||||
{
|
||||
anchorMetadata = new AnchorMetadata
|
||||
{
|
||||
IsAnchored = true,
|
||||
DsseEnvelopeDigest = anchor.EnvelopeDigest,
|
||||
PredicateType = anchor.PredicateType,
|
||||
RekorLogIndex = anchor.RekorLogIndex,
|
||||
RekorEntryId = anchor.RekorEntryId,
|
||||
AttestationTimestamp = anchor.AttestedAt,
|
||||
VerificationStatus = anchor.Verified == true ? AnchorVerificationStatus.Verified : AnchorVerificationStatus.Unverified
|
||||
};
|
||||
}
|
||||
|
||||
return new RuntimeInput
|
||||
{
|
||||
Posture = RuntimePosture.ActiveTracing,
|
||||
ObservationCount = evidence.RuntimeObservations.Count,
|
||||
LastObservation = latest.Timestamp,
|
||||
RecencyFactor = recencyFactor,
|
||||
DirectPathObserved = latest.ObservationType == "direct",
|
||||
IsProductionTraffic = true, // Assume production unless specified
|
||||
EvidenceSource = latest.Issuer,
|
||||
Anchor = anchorMetadata
|
||||
};
|
||||
}
|
||||
|
||||
private static MitigationInput? MapMitigations(FullEvidence evidence)
|
||||
{
|
||||
if (evidence.VexStatements.Count == 0)
|
||||
return null;
|
||||
|
||||
var mitigations = evidence.VexStatements
|
||||
.Select(v => new ActiveMitigation
|
||||
{
|
||||
Type = MitigationType.Unknown, // VEX is not directly in MitigationType enum
|
||||
Name = $"VEX: {v.Status}",
|
||||
Effectiveness = v.Status switch
|
||||
{
|
||||
"not_affected" => 1.0,
|
||||
"fixed" => 0.9,
|
||||
"under_investigation" => 0.3,
|
||||
"affected" => 0.0,
|
||||
_ => 0.5
|
||||
},
|
||||
Verified = v.AttestationDigest is not null
|
||||
})
|
||||
.OrderByDescending(m => m.Effectiveness)
|
||||
.ThenBy(m => m.Name ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var combinedEffectiveness = MitigationInput.CalculateCombinedEffectiveness(mitigations);
|
||||
var latestVex = evidence.VexStatements.OrderByDescending(v => v.Timestamp).FirstOrDefault();
|
||||
|
||||
return new MitigationInput
|
||||
{
|
||||
ActiveMitigations = mitigations,
|
||||
CombinedEffectiveness = combinedEffectiveness,
|
||||
RuntimeVerified = mitigations.Any(m => m.Verified),
|
||||
EvidenceTimestamp = latestVex?.Timestamp
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseGuid(string input, out Guid result)
|
||||
{
|
||||
// Handle CVE@PURL format by extracting the GUID portion if present
|
||||
if (input.Contains('@'))
|
||||
{
|
||||
// Try to find a GUID in the string
|
||||
var parts = input.Split('@', '/', ':');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (Guid.TryParse(part, out result))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return Guid.TryParse(input, out result);
|
||||
}
|
||||
}
|
||||
@@ -166,10 +166,13 @@ public sealed class FindingScoringService : IFindingScoringService
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cacheDuration = TimeSpan.FromMinutes(_options.CacheTtlMinutes);
|
||||
|
||||
var response = MapToResponse(result, request.IncludeBreakdown, now, cacheDuration);
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-003)
|
||||
// Pass policy and evidence to MapToResponse for reduction profile and anchor metadata
|
||||
var response = MapToResponse(result, request.IncludeBreakdown, now, cacheDuration, policy, evidence);
|
||||
|
||||
// Cache the result
|
||||
var cacheKey = GetCacheKey(findingId);
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-003)
|
||||
// Use cache key that includes policy digest and reduction profile
|
||||
var cacheKey = GetCacheKey(findingId, policy.ComputeDigest(), policy.AttestedReduction.Enabled);
|
||||
_cache.Set(cacheKey, response, cacheDuration);
|
||||
|
||||
// Record in history
|
||||
@@ -363,12 +366,69 @@ public sealed class FindingScoringService : IFindingScoringService
|
||||
|
||||
private static string GetCacheKey(string findingId) => $"ews:score:{findingId}";
|
||||
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-003)
|
||||
// Include policy digest and reduction profile in cache key for determinism
|
||||
private static string GetCacheKey(string findingId, string policyDigest, bool reductionEnabled)
|
||||
=> $"ews:score:{findingId}:{policyDigest}:{(reductionEnabled ? "reduction" : "standard")}";
|
||||
|
||||
private static EvidenceWeightedScoreResponse MapToResponse(
|
||||
EvidenceWeightedScoreResult result,
|
||||
bool includeBreakdown,
|
||||
DateTimeOffset calculatedAt,
|
||||
TimeSpan cacheDuration)
|
||||
TimeSpan cacheDuration,
|
||||
EvidenceWeightPolicy? policy = null,
|
||||
FindingEvidence? evidence = null)
|
||||
{
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-003)
|
||||
// Extract reduction profile and hard-fail status from flags
|
||||
var isAttestedReduction = result.Flags.Contains("attested-reduction");
|
||||
var isHardFail = result.Flags.Contains("hard-fail");
|
||||
|
||||
// Determine short-circuit reason from flags/explanations
|
||||
string? shortCircuitReason = null;
|
||||
if (result.Flags.Contains("anchored-vex") && result.Score == 0)
|
||||
{
|
||||
shortCircuitReason = "anchored_vex_not_affected";
|
||||
}
|
||||
else if (isHardFail)
|
||||
{
|
||||
shortCircuitReason = "anchored_affected_runtime_confirmed";
|
||||
}
|
||||
|
||||
// Build reduction profile DTO if policy has attested reduction enabled
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-003)
|
||||
ReductionProfileDto? reductionProfile = null;
|
||||
if (policy?.AttestedReduction.Enabled == true)
|
||||
{
|
||||
var ar = policy.AttestedReduction;
|
||||
reductionProfile = new ReductionProfileDto
|
||||
{
|
||||
Enabled = true,
|
||||
Mode = ar.HardFailOnAffectedWithRuntime ? "aggressive" : "conservative",
|
||||
ProfileId = $"attested-{ar.RequiredVerificationStatus.ToString().ToLowerInvariant()}",
|
||||
MaxReductionPercent = (int)((1.0 - ar.ClampMin) * 100),
|
||||
RequireVexAnchoring = ar.RequiredVerificationStatus >= AnchorVerificationStatus.Verified,
|
||||
RequireRekorVerification = ar.RequiredVerificationStatus >= AnchorVerificationStatus.Verified
|
||||
};
|
||||
}
|
||||
|
||||
// Build anchor DTO from evidence if available
|
||||
EvidenceAnchorDto? anchorDto = null;
|
||||
if (evidence?.Anchor is not null && evidence.Anchor.Anchored)
|
||||
{
|
||||
anchorDto = new EvidenceAnchorDto
|
||||
{
|
||||
Anchored = true,
|
||||
EnvelopeDigest = evidence.Anchor.EnvelopeDigest,
|
||||
PredicateType = evidence.Anchor.PredicateType,
|
||||
RekorLogIndex = evidence.Anchor.RekorLogIndex,
|
||||
RekorEntryId = evidence.Anchor.RekorEntryId,
|
||||
Scope = evidence.Anchor.Scope,
|
||||
Verified = evidence.Anchor.Verified,
|
||||
AttestedAt = evidence.Anchor.AttestedAt
|
||||
};
|
||||
}
|
||||
|
||||
return new EvidenceWeightedScoreResponse
|
||||
{
|
||||
FindingId = result.FindingId,
|
||||
@@ -403,7 +463,12 @@ public sealed class FindingScoringService : IFindingScoringService
|
||||
PolicyDigest = result.PolicyDigest,
|
||||
CalculatedAt = calculatedAt,
|
||||
CachedUntil = calculatedAt.Add(cacheDuration),
|
||||
FromCache = false
|
||||
FromCache = false,
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-003)
|
||||
ReductionProfile = reductionProfile,
|
||||
HardFail = isHardFail,
|
||||
ShortCircuitReason = shortCircuitReason,
|
||||
Anchor = anchorDto
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-004)
|
||||
// Task: Unit tests for attested-reduction response fields
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MsOptions = Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Services;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class FindingScoringServiceTests
|
||||
{
|
||||
private readonly Mock<INormalizerAggregator> _normalizer = new();
|
||||
private readonly Mock<IEvidenceWeightedScoreCalculator> _calculator = new();
|
||||
private readonly Mock<IEvidenceWeightPolicyProvider> _policyProvider = new();
|
||||
private readonly Mock<IFindingEvidenceProvider> _evidenceProvider = new();
|
||||
private readonly Mock<IScoreHistoryStore> _historyStore = new();
|
||||
private readonly Mock<TimeProvider> _timeProvider = new();
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly FindingScoringService _service;
|
||||
private readonly DateTimeOffset _now = new(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public FindingScoringServiceTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = MsOptions.Options.Create(new FindingScoringOptions
|
||||
{
|
||||
CacheTtlMinutes = 60,
|
||||
MaxBatchSize = 100,
|
||||
MaxConcurrency = 10
|
||||
});
|
||||
_timeProvider.Setup(tp => tp.GetUtcNow()).Returns(_now);
|
||||
|
||||
_service = new FindingScoringService(
|
||||
_normalizer.Object,
|
||||
_calculator.Object,
|
||||
_policyProvider.Object,
|
||||
_evidenceProvider.Object,
|
||||
_historyStore.Object,
|
||||
_cache,
|
||||
options,
|
||||
NullLogger<FindingScoringService>.Instance,
|
||||
_timeProvider.Object);
|
||||
}
|
||||
|
||||
#region Attested Reduction Response Fields Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateScoreAsync_AttestedReductionEnabled_PopulatesReductionProfile()
|
||||
{
|
||||
// Arrange
|
||||
var findingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20";
|
||||
var evidence = CreateFindingEvidence(findingId);
|
||||
var policy = CreateAttestedReductionPolicy(enabled: true);
|
||||
var input = CreateEvidenceWeightedScoreInput(findingId);
|
||||
var result = CreateScoreResult(findingId, withAttestedReduction: true);
|
||||
|
||||
SetupMocks(evidence, policy, input, result);
|
||||
|
||||
// Act
|
||||
var response = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest { IncludeBreakdown = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response!.ReductionProfile.Should().NotBeNull();
|
||||
response.ReductionProfile!.Enabled.Should().BeTrue();
|
||||
response.ReductionProfile.Mode.Should().NotBeNullOrEmpty();
|
||||
response.ReductionProfile.MaxReductionPercent.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateScoreAsync_HardFailTriggered_SetsHardFailTrue()
|
||||
{
|
||||
// Arrange
|
||||
var findingId = "CVE-2024-9999@pkg:npm/critical@1.0.0";
|
||||
var evidence = CreateFindingEvidence(findingId);
|
||||
var policy = CreateAttestedReductionPolicy(enabled: true);
|
||||
var input = CreateEvidenceWeightedScoreInput(findingId);
|
||||
var result = CreateScoreResult(findingId, withHardFail: true);
|
||||
|
||||
SetupMocks(evidence, policy, input, result);
|
||||
|
||||
// Act
|
||||
var response = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest { IncludeBreakdown = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response!.HardFail.Should().BeTrue();
|
||||
response.ShortCircuitReason.Should().Be("anchored_affected_runtime_confirmed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateScoreAsync_AnchoredVexNotAffected_SetsShortCircuitReason()
|
||||
{
|
||||
// Arrange
|
||||
var findingId = "CVE-2024-5555@pkg:npm/not-affected@1.0.0";
|
||||
var evidence = CreateFindingEvidenceWithAnchor(findingId);
|
||||
var policy = CreateAttestedReductionPolicy(enabled: true);
|
||||
var input = CreateEvidenceWeightedScoreInput(findingId);
|
||||
var result = CreateScoreResult(findingId, withAnchoredVex: true, score: 0);
|
||||
|
||||
SetupMocks(evidence, policy, input, result);
|
||||
|
||||
// Act
|
||||
var response = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest { IncludeBreakdown = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response!.Score.Should().Be(0);
|
||||
response.ShortCircuitReason.Should().Be("anchored_vex_not_affected");
|
||||
response.HardFail.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateScoreAsync_WithAnchor_PopulatesAnchorDto()
|
||||
{
|
||||
// Arrange
|
||||
var findingId = "CVE-2024-1111@pkg:npm/anchored@2.0.0";
|
||||
var evidence = CreateFindingEvidenceWithAnchor(findingId);
|
||||
var policy = CreateAttestedReductionPolicy(enabled: true);
|
||||
var input = CreateEvidenceWeightedScoreInput(findingId);
|
||||
var result = CreateScoreResult(findingId);
|
||||
|
||||
SetupMocks(evidence, policy, input, result);
|
||||
|
||||
// Act
|
||||
var response = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest { IncludeBreakdown = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response!.Anchor.Should().NotBeNull();
|
||||
response.Anchor!.Anchored.Should().BeTrue();
|
||||
response.Anchor.EnvelopeDigest.Should().Be("sha256:abc123");
|
||||
response.Anchor.RekorLogIndex.Should().Be(12345);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateScoreAsync_NoReductionProfile_ReturnsNullReductionProfile()
|
||||
{
|
||||
// Arrange
|
||||
var findingId = "CVE-2024-2222@pkg:npm/standard@1.0.0";
|
||||
var evidence = CreateFindingEvidence(findingId);
|
||||
var policy = CreateStandardPolicy(); // No attested reduction
|
||||
var input = CreateEvidenceWeightedScoreInput(findingId);
|
||||
var result = CreateScoreResult(findingId);
|
||||
|
||||
SetupMocks(evidence, policy, input, result);
|
||||
|
||||
// Act
|
||||
var response = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest { IncludeBreakdown = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response!.ReductionProfile.Should().BeNull();
|
||||
response.HardFail.Should().BeFalse();
|
||||
response.ShortCircuitReason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateScoreAsync_NoEvidence_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var findingId = "CVE-2024-0000@pkg:npm/missing@1.0.0";
|
||||
|
||||
_evidenceProvider.Setup(p => p.GetEvidenceAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FindingEvidence?)null);
|
||||
|
||||
// Act
|
||||
var response = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest(),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cache Key Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateScoreAsync_DifferentPolicies_UseDifferentCacheKeys()
|
||||
{
|
||||
// Arrange
|
||||
var findingId = "CVE-2024-3333@pkg:npm/cached@1.0.0";
|
||||
var evidence = CreateFindingEvidence(findingId);
|
||||
var policy1 = CreateAttestedReductionPolicy(enabled: true);
|
||||
var policy2 = CreateAttestedReductionPolicy(enabled: false);
|
||||
var input = CreateEvidenceWeightedScoreInput(findingId);
|
||||
var result1 = CreateScoreResult(findingId, withAttestedReduction: true, score: 25);
|
||||
var result2 = CreateScoreResult(findingId, score: 75);
|
||||
|
||||
// First call with reduction enabled
|
||||
SetupMocks(evidence, policy1, input, result1);
|
||||
var response1 = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest { ForceRecalculate = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Change policy to disabled
|
||||
SetupMocks(evidence, policy2, input, result2);
|
||||
var response2 = await _service.CalculateScoreAsync(
|
||||
findingId,
|
||||
new CalculateScoreRequest { ForceRecalculate = true },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert - different scores due to different cache keys
|
||||
response1!.Score.Should().NotBe(response2!.Score);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupMocks(
|
||||
FindingEvidence evidence,
|
||||
EvidenceWeightPolicy policy,
|
||||
EvidenceWeightedScoreInput input,
|
||||
EvidenceWeightedScoreResult result)
|
||||
{
|
||||
_evidenceProvider.Setup(p => p.GetEvidenceAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
_policyProvider.Setup(p => p.GetDefaultPolicyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policy);
|
||||
_normalizer.Setup(n => n.Aggregate(It.IsAny<FindingEvidence>()))
|
||||
.Returns(input);
|
||||
_calculator.Setup(c => c.Calculate(It.IsAny<EvidenceWeightedScoreInput>(), It.IsAny<EvidenceWeightPolicy>()))
|
||||
.Returns(result);
|
||||
}
|
||||
|
||||
private static FindingEvidence CreateFindingEvidence(string findingId) => new()
|
||||
{
|
||||
FindingId = findingId
|
||||
};
|
||||
|
||||
private static FindingEvidence CreateFindingEvidenceWithAnchor(string findingId) => new()
|
||||
{
|
||||
FindingId = findingId,
|
||||
Anchor = new EvidenceAnchor
|
||||
{
|
||||
Anchored = true,
|
||||
EnvelopeDigest = "sha256:abc123",
|
||||
PredicateType = "https://stellaops.io/attestation/vex/v1",
|
||||
RekorLogIndex = 12345,
|
||||
RekorEntryId = "entry-123",
|
||||
Scope = "finding",
|
||||
Verified = true,
|
||||
AttestedAt = DateTimeOffset.UtcNow.AddHours(-1)
|
||||
}
|
||||
};
|
||||
|
||||
private static EvidenceWeightedScoreInput CreateEvidenceWeightedScoreInput(string findingId) => new()
|
||||
{
|
||||
FindingId = findingId,
|
||||
Rch = 0.5,
|
||||
Rts = 0.3,
|
||||
Bkp = 0.0,
|
||||
Xpl = 0.4,
|
||||
Src = 0.6,
|
||||
Mit = 0.1
|
||||
};
|
||||
|
||||
private static EvidenceWeightPolicy CreateAttestedReductionPolicy(bool enabled) => new()
|
||||
{
|
||||
Version = "1.0.0",
|
||||
Profile = "test",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Weights = EvidenceWeights.Default,
|
||||
Guardrails = GuardrailConfig.Default,
|
||||
Buckets = BucketThresholds.Default,
|
||||
AttestedReduction = enabled
|
||||
? AttestedReductionConfig.EnabledDefault
|
||||
: AttestedReductionConfig.Default
|
||||
};
|
||||
|
||||
private static EvidenceWeightPolicy CreateStandardPolicy() => new()
|
||||
{
|
||||
Version = "1.0.0",
|
||||
Profile = "standard",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Weights = EvidenceWeights.Default,
|
||||
Guardrails = GuardrailConfig.Default,
|
||||
Buckets = BucketThresholds.Default,
|
||||
AttestedReduction = AttestedReductionConfig.Default
|
||||
};
|
||||
|
||||
private EvidenceWeightedScoreResult CreateScoreResult(
|
||||
string findingId,
|
||||
bool withAttestedReduction = false,
|
||||
bool withHardFail = false,
|
||||
bool withAnchoredVex = false,
|
||||
int score = 50)
|
||||
{
|
||||
var flags = new List<string>();
|
||||
if (withAttestedReduction) flags.Add("attested-reduction");
|
||||
if (withHardFail)
|
||||
{
|
||||
flags.Add("hard-fail");
|
||||
flags.Add("anchored-vex");
|
||||
flags.Add("anchored-runtime");
|
||||
}
|
||||
if (withAnchoredVex)
|
||||
{
|
||||
flags.Add("anchored-vex");
|
||||
flags.Add("vendor-na");
|
||||
}
|
||||
|
||||
return new EvidenceWeightedScoreResult
|
||||
{
|
||||
FindingId = findingId,
|
||||
Score = score,
|
||||
Bucket = score >= 90 ? ScoreBucket.ActNow :
|
||||
score >= 70 ? ScoreBucket.ScheduleNext :
|
||||
score >= 40 ? ScoreBucket.Investigate : ScoreBucket.Watchlist,
|
||||
Inputs = new EvidenceInputValues(0.5, 0.3, 0.0, 0.4, 0.6, 0.1),
|
||||
Weights = EvidenceWeights.Default,
|
||||
Breakdown = [],
|
||||
Flags = flags,
|
||||
Explanations = ["Test explanation"],
|
||||
Caps = AppliedGuardrails.None(score),
|
||||
PolicyDigest = "sha256:policy123",
|
||||
CalculatedAt = _now
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user